From a551aa5fe67ec4a6e3dd81fe384a146248b610b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:26:27 -0700 Subject: [PATCH] OpenCode structured chat over the Agent Client Protocol (#25845) * 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. * Add chat-owned OpenCode server transport foundation * Combine server fixture type imports for CI lint * 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. * Bound request serialization and correct transport fixture types * 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. * 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. * Bound OpenCode event consumers and isolate chat caller identity * 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. * 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 0ef6d218157. 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). * fix(acp): a first reopen warns when Grok forgets a chat that exchanged turns A Grok session the chat created was treated as one Grok never saved, so when Grok reported it missing on the chat's first reopen, Orca swapped in a fresh session silently, even after completed exchanges: the person saw the old messages while Grok had forgotten them. The launch now counts a created session as never saved only when the chat's journal, read at the failed reopen, holds no turn of that Grok session; anything else, an unreadable journal included, takes the normal path: the fresh session is recorded as replacing the old one and the one warning row is written. * Add setting-gated OpenCode server native chat integration * Use checked native event ordinals in timeline identities * Validate nested account tags and preserve bounded restore failures * Keep native history restoration within its session module * Check native protocol records and support registered-agent drafts * fix(acp): a start writes the warning row an earlier attach failure dropped The row saying Grok forgot the chat was written only into the attach's deferred sink, while the fresh session's link was saved earlier. An attach failure, quit or crash in between dropped the row forever. Every start now derives the owed rows: each conversation the chain says was lost to a failed restore gets its row unless the chat's journal already holds it. The row now names the lost conversation rather than the fresh session, so a row an unused replacement wrote still counts after Grok supersedes it. * fix(acp): a question during a turn the agent began itself gets its cancelled reply A question or other card-opening request the agent sends while no prompt of Orca's runs (a turn it began itself, as when a background task wakes it) now gets the agent's own cancelled reply and opens no card, the same rule permissions already follow there. Nobody is waiting on that turn. A plan the agent shares in it is still shown. * Update launch and status tests for registered structured agents * test(native-chat): import the unfinished-work capture from the module that owns it Main's reasoning sweep test (#19221) imported it from the dead-generation settlement, which this branch split it out of. * Run OpenCode structured chat over ACP OpenCode joins the ACP lane as two launch rows (opencode and opencode2 run 'acp'), with a dialect that reads command output and exit codes from OpenCode's metadata and labels its project-wide 'always' grant honestly. The launch forces OPENCODE_CLIENT=acp and turns OpenCode's question tool off, strips Orca's terminal status overlay from the child, and pins the chat's OpenCode account as a tagged locator (managed profile, or data and state directories). Image prompts go as ACP image blocks when the agent advertises them, and restart recovery reads OpenCode's own store for presence only. * OpenCode 2 over ACP only for its default account OpenCode 2's ACP command runs inside the user's own background service, which the chat's environment never reaches, so a managed profile cannot be honoured: while one is selected a new OpenCode 2 chat opens in the terminal, and a record pinned to one never launches. * Test OpenCode over ACP against its recorded traffic Seven scrubbed recordings of opencode acp (1.18.31 and 2.0.14) replay through the adapter: an approved shell command with its exit code and the project-wide always label, deny on both majors, the 1.x subagent approval that never arrives (Stop still ends the turn), the 2.x subagent approval answered on the chat's session, the stray 1.x file write, and a provider error shown in OpenCode's own words. The replay now plays recorded error answers too. * Name the normalized tool update for what it is * OpenCode over ACP: 1.x only, honest grants, sound recovery Review round 1: - OpenCode 2 stays on its terminal-backed chat: its acp command runs in the user's own background service, which a chat's environment and account pin don't reach, and a declined local launch has no terminal fallback yet. - The model-pick launch change is out of this PR (it could leave a worktree behind when the host later chose the terminal). - 'Always allow' reads 'Allow for this chat': OpenCode 1.x keeps it in the chat's own process. - A file write OpenCode repeats to a client with no file system leaves no row. - Images are bounded by the encoded prompt line, not just raw bytes. - Restart recovery: each accepted send claims its own stored copy first, and queued cards never trigger the read. - A send whose child ended during the image read settles as not sent. - An account Orca cannot pin (inline auth, relative directories) is refused as unsupported; default XDG directories the user never set stay unset; the retired shared plugin directory and data-account variables never reach the child; newer locator fields are tolerated. * Round 2: recovery reads the chat's own database, honest grant label - Restart recovery resolves OpenCode's database under the chat's own home, not Orca's, now that default data directories stay unset. - 'Always allow' reads 'Allow until OpenCode restarts': Stop, quitting and an idle chat's stopped agent all end it. - Only a message with images is refused for passing one protocol line, so a long text message never reads as an image problem. * refactor(native-chat): keep the session host within its line budget after main's Stop work Main's #25949 left the host at exactly its 300-line budget, and this branch's acquire-abort wiring adds one line. The reveal module now comes in as a namespace import, as the host already does for its other helper modules, and the earlier reorder of two type imports is undone. * test(codex): move the stdout-before-exit test into its own file Main's connection test file is at its 800-line test budget, and this branch's exit-order test pushed it over (CI lint, max-lines). * Keep the record's path bound, which the launch directory now uses * Narrow the refusal in the prompt content test instead of casting it * Type the replayed error answer as the protocol's error * Read the replayed message through the replay's own schema * Match recovered OpenCode messages through main's send fingerprint Main gave every stored send one fingerprint helper (it drops the sender field); restart recovery now hashes stored messages through it instead of rebuilding the hash by hand. * Keep an `opencode` that is 2.x on the terminal chat Homebrew's `opencode` is 2.x now, so the binary, not only the agent name, decides whether OpenCode's acp runs in-process. An ACP launch spec can name the --version releases it runs on; OpenCode takes stable 1.x from 1.18.31. createSupport asks the binary a launch would spawn, with the environment a launch starts from, so a desktop launch of 2.x opens the terminal chat; every launch asks again before spawning, so a binary swapped after create never starts. The --version probe is shared and bounded, and a version it cannot read counts as unsupported. * Type the mocked login-shell environment instead of asserting it * Run the OpenCode the Command setting names, and say why a version check refused The structured OpenCode and Grok chats resolved a bare `opencode`/`grok` from PATH while detection and the terminal chat honoured the per-agent Command setting, so a user who pointed Orca at a specific binary got another one (and, with the version gate, another version) in the structured chat. resolveStructuredAgentCommand now takes any agent id and the stock binary name; the ACP launch and createSupport's version check both resolve through it, so they ask about and spawn the same file. An unrunnable Command is admitted at create and refused at launch with agentCommandNotRunnable, as Claude and Codex do, instead of silently opening a terminal. The --version probe now logs why it refused (exit code, timeout, oversized or versionless output, unsupported release), with the binary's own first words, so a refusal on a real host can be diagnosed. A real-probe test drives createSupport and the launch against stand-in binaries: per-agent PATH, Command setting, 2.x first on PATH, and an unrunnable Command. * Say why a new OpenCode tab opened a terminal, and ask the host again for its agents Live QA run 5 opened the terminal-backed OpenCode chat with the private 1.18.31 set as the Command and on the per-agent PATH, and Orca's main log had no version-check line. The host never decided: a local launch routes to the structured chat only once the renderer has learned the host's registered agents, and that list was read once at startup, so a read that failed (or was skipped while the local runtime's capabilities were unknown) kept OpenCode on the terminal for the whole session. A launch that finds the list unlearned now asks the host again, so the next launch opens the chat. Every point that sends a launch to a terminal now says so, naming the check and never a value: the renderer's route decision ([agent-launch-route], in the renderer console), createSupport's refusing check ([structured-create-support]), a version check that passed as well as one that refused ([agent-cli-version]), an OpenCode account that cannot be pinned ([opencode-account]), and a host launch the default asked to be a chat ([agent-launch]). createSupport's composition moves out of the unchecked runtime class into structured-agent-launch-support.ts. Tests reproduce run 5: the host's create check with HOME and every XDG directory in the rig, the Command and per-agent PATH naming a stand-in 1.18.31, and the account pinned to the rig's XDG directories; and the renderer route with those settings, before and after the host's list is learned (ablation: without the re-ask the route stays on the terminal chat). * Type the mocked login-shell environment in the OpenCode probe tests instead of asserting it * Pass the OpenCode Command and environment through the route test's store helper, which types its settings * Open OpenCode's chat on the first new tab, and learn the host's agents when they become available The renderer routes an OpenCode tab to the structured chat only once it has learned the host's registered agents, and that list was read once at startup with nothing to read it again, so a read that came too early or failed sent every OpenCode tab to the terminal chat. - agentSession.agents answers from the registrations the host is built from, as createSupport already does, instead of installing the host first: the answer is fixed for this build, so it no longer waits on, or fails with, opening the chat journal. - The local list is read again when the local runtime's capabilities land, so a startup read made while they were unknown is redone on the event that makes it possible. - A new tab whose chat route waits only on that list asks the host for it and opens nothing until it answers or 3 seconds pass (the local admission bound), then decides: the first launch opens the chat. A host that does not publish its agents is never waited on. * Type the new-tab route request as a launch request, which carries the delivery callback * Declare that agentSession.agents answers from the registrations in the cross-version manifest Older builds read the list from the installed host, newer ones from the registrations it is built from; the manifest now asks only that Codex is listed, which holds for both. * Wait for this computer's runtime too before routing a new tab during a slow startup Field QA: soon after a slow startup, a new tab opened the terminal chat because the local runtime had not reported its capabilities yet, which routes straight to the terminal; the bounded wait covered only an unlearned agent list. A new tab whose chat route waits only on either answer now asks for it and opens nothing until it comes or 3 seconds pass, then decides; the [agent-launch-route] line still names the blocker if it falls back. Tests that never meant to exercise startup now start with the runtime's answer known. * Wait for the probe that lands the runtime capabilities, not one that failed at startup When the startup probe fails (a rig whose terminal daemon failed to start), asking again answers null at once, so the new-tab wait ended early and the tab opened the terminal chat. The wait now subscribes to the event that delivers the capabilities, probes once, and stops at the 3 s cap; it applies to both routes, so OpenCode (whose blocker is the unlearned agent list) and Claude (unknown capabilities) both wait for it. Tests run the real capability module with a failed startup probe: Claude and OpenCode open their chat when a later probe lands within the wait, and the terminal chat after the cap otherwise. * Answer an OpenCode account Orca cannot pin at create support, not as a failed chat Live QA with inline OpenCode credentials (OPENCODE_AUTH_CONTENT) passed the version check at create support and was refused only at create, so the window showed a failed "OpenCode Chat" with Retry where a declined launch opens the terminal chat. Create support now resolves the account a new chat would pin through the same read-only resolver the model catalog and create use, and answers no, logging the account-pin check, when the host refuses to pin it; any other failure is left for create to state. Create's own refusal stays as the backstop. * fix(opencode): start the chat with the user's OpenCode environment as-is An OpenCode chat with no managed profile no longer records data/state folders or a database choice, no longer refuses inline credentials or relative paths, and launches with the user's environment unchanged. Drops the create-support account-pin probe that only those refusals used. * test(cross-version): build each method's params in their own file, under the line limit --- src/main/acp/acp-account-binding.ts | 40 +++ src/main/acp/acp-dialects/acp-dialect.ts | 6 +- src/main/acp/acp-dialects/opencode-dialect.ts | 49 ++++ src/main/acp/acp-launch-specs.ts | 61 ++++- src/main/acp/acp-prompt-content.test.ts | 109 ++++++++ src/main/acp/acp-prompt-content.ts | 161 ++++++++++++ src/main/acp/acp-recovery-history.test.ts | 219 ++++++++++++++++ src/main/acp/acp-recovery-history.ts | 145 ++++++++++ src/main/acp/acp-structured-acquire.ts | 2 +- .../acp-structured-adapter.test-support.ts | 16 +- .../acp/acp-structured-agent-definitions.ts | 6 +- .../acp/acp-structured-electron-free.test.ts | 5 +- ...-structured-fixture-replay.test-support.ts | 9 +- .../acp/acp-structured-host.test-support.ts | 1 + .../acp/acp-structured-image-prompts.test.ts | 59 +++++ ...uctured-launch-resolution-opencode.test.ts | 247 ++++++++++++++++++ .../acp/acp-structured-launch-resolution.ts | 88 +++++-- ...acp-structured-opencode-recordings.test.ts | 129 +++++++++ .../acp-structured-session-adapter-deps.ts | 3 + ...ructured-session-adapter-lifecycle.test.ts | 1 + .../acp/acp-structured-session-adapter.ts | 20 +- src/main/acp/acp-structured-turns.ts | 18 +- .../acp/acp-timeline-fixture.test-support.ts | 3 +- .../acp/acp-timeline-opencode-dialect.test.ts | 106 ++++++++ src/main/acp/acp-timeline-requests.ts | 18 +- src/main/acp/acp-tool-timeline.ts | 6 +- src/main/acp/fixtures/opencode-v1-deny.jsonl | 21 ++ .../fixtures/opencode-v1-provider-error.jsonl | 2 + .../fixtures/opencode-v1-stray-write.jsonl | 30 +++ .../fixtures/opencode-v1-subagent-hang.jsonl | 13 + src/main/acp/fixtures/opencode-v1-tool.jsonl | 40 +++ src/main/agent-cli-version-probe.test.ts | 94 +++++++ src/main/agent-cli-version-probe.ts | 66 +++++ .../agent-launch/agent-launch-executor.ts | 4 +- src/main/agent-launch/agent-launch-mode.ts | 10 + src/main/claude/claude-at-rest-commands.ts | 12 +- .../claude-structured-agent-definition.ts | 4 +- .../claude-structured-launch-resolution.ts | 10 +- .../claude-structured-session-adapter.ts | 3 +- .../codex-structured-agent-definition.ts | 4 +- .../codex-structured-launch-resolution.ts | 4 +- .../managed-data-accounts/service.test.ts | 21 ++ src/main/managed-data-accounts/service.ts | 8 +- .../agent-model-catalog-fingerprint.ts | 32 +-- .../agent-model-catalog-service.ts | 11 +- .../journal-restart-reconciliation.ts | 6 +- .../journal-submission-reconciler.ts | 7 +- .../structured-agent-definition.ts | 5 + .../structured-agent-session-launch-env.ts | 5 +- ...ructured-agent-session-provider-support.ts | 5 +- ...tructured-agent-command-resolution.test.ts | 15 ++ .../structured-agent-command-resolution.ts | 15 +- ...structured-agent-session-create-support.ts | 21 ++ .../opencode-database-discovery.ts | 19 +- .../opencode-usage/scanner-wsl-gate.test.ts | 3 +- src/main/opencode/opencode-acp-environment.ts | 69 +++++ .../opencode-acp-stored-messages.test.ts | 49 ++++ .../opencode/opencode-acp-stored-messages.ts | 38 +++ .../opencode/opencode-data-directory.test.ts | 14 + src/main/opencode/opencode-data-directory.ts | 16 +- .../opencode-structured-account-home.test.ts | 126 +++++++++ .../opencode-structured-account-home.ts | 95 +++++++ .../agent-session-reservation-admission.ts | 4 +- ...structured-agent-session-create-support.ts | 75 +++--- .../runtime/orca-runtime-get-worktree-ps.ts | 1 + ...structured-agent-registration-seam.test.ts | 7 +- .../structured-agent-session-agents.test.ts | 45 ++++ .../structured-agent-session-agents.ts | 19 +- ...-agent-create-support-opencode-rig.test.ts | 131 ++++++++++ ...uctured-agent-launch-support-probe.test.ts | 135 ++++++++++ .../structured-agent-launch-support.test.ts | 110 ++++++++ .../structured-agent-launch-support.ts | 93 +++++++ ...ed-agent-runtime-registrations-acp.test.ts | 35 ++- .../structured-agent-runtime-registrations.ts | 86 ++++-- .../structured-agent-session-runtime.ts | 3 + ...ent-launch-route-registered-agents.test.ts | 63 ++++- src/renderer/src/lib/agent-launch-routing.ts | 46 +++- .../src/lib/agent-session-launch-plan.test.ts | 3 +- .../src/lib/agent-session-launch-plan.ts | 98 +++++++ ...aunch-agent-in-new-tab-host-agents.test.ts | 243 +++++++++++++++++ .../launch-agent-in-new-tab-host-agents.ts | 91 +++++++ .../launch-agent-in-new-tab-placement.test.ts | 3 + .../src/lib/launch-agent-in-new-tab.test.ts | 3 + .../src/lib/launch-agent-in-new-tab.ts | 35 +-- ...launch-agent-structured-chat-guard.test.ts | 13 +- .../runtime/host-structured-agents-sync.ts | 30 ++- .../runtime/host-structured-agents.test.ts | 21 +- .../src/runtime/host-structured-agents.ts | 20 ++ .../src/runtime/local-runtime-capabilities.ts | 39 ++- src/shared/agent-session-account-home.test.ts | 37 ++- src/shared/agent-session-account-home.ts | 129 +++++++-- src/shared/agent-session-stored-agent.ts | 16 +- .../structured-agent-session-surface-calls.ts | 10 +- 93 files changed, 3789 insertions(+), 279 deletions(-) create mode 100644 src/main/acp/acp-account-binding.ts create mode 100644 src/main/acp/acp-dialects/opencode-dialect.ts create mode 100644 src/main/acp/acp-prompt-content.test.ts create mode 100644 src/main/acp/acp-prompt-content.ts create mode 100644 src/main/acp/acp-recovery-history.test.ts create mode 100644 src/main/acp/acp-recovery-history.ts create mode 100644 src/main/acp/acp-structured-image-prompts.test.ts create mode 100644 src/main/acp/acp-structured-launch-resolution-opencode.test.ts create mode 100644 src/main/acp/acp-structured-opencode-recordings.test.ts create mode 100644 src/main/acp/acp-timeline-opencode-dialect.test.ts create mode 100644 src/main/acp/fixtures/opencode-v1-deny.jsonl create mode 100644 src/main/acp/fixtures/opencode-v1-provider-error.jsonl create mode 100644 src/main/acp/fixtures/opencode-v1-stray-write.jsonl create mode 100644 src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl create mode 100644 src/main/acp/fixtures/opencode-v1-tool.jsonl create mode 100644 src/main/agent-cli-version-probe.test.ts create mode 100644 src/main/agent-cli-version-probe.ts create mode 100644 src/main/opencode/opencode-acp-environment.ts create mode 100644 src/main/opencode/opencode-acp-stored-messages.test.ts create mode 100644 src/main/opencode/opencode-acp-stored-messages.ts create mode 100644 src/main/opencode/opencode-structured-account-home.test.ts create mode 100644 src/main/opencode/opencode-structured-account-home.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts create mode 100644 src/main/runtime/structured-agent-create-support-opencode-rig.test.ts create mode 100644 src/main/runtime/structured-agent-launch-support-probe.test.ts create mode 100644 src/main/runtime/structured-agent-launch-support.test.ts create mode 100644 src/main/runtime/structured-agent-launch-support.ts create mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts create mode 100644 src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts diff --git a/src/main/acp/acp-account-binding.ts b/src/main/acp/acp-account-binding.ts new file mode 100644 index 00000000000..4adddaa4882 --- /dev/null +++ b/src/main/acp/acp-account-binding.ts @@ -0,0 +1,40 @@ +// How an ACP agent's chats pin their account: what a new chat records, and how a launch points the +// agent at the account its record pinned. Both run on the machine that runs the agent. + +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import { resolveStructuredEnvAccountHomePath } from '../runtime/structured-agent-account-home' + +type AccountPin = { accountHomeVariable: string } | { accountLocatorKind: 'opencode' } + +export type AcpAccountBinding = { + /** What the agent's definition says its records pin. */ + pin: AccountPin + /** The account a new chat pins: the current selection, read without side effects. */ + resolve(input: { launchEnv: NodeJS.ProcessEnv }): Promise + /** `env` pointed at the pinned account; throws for an account of another kind. */ + environment(home: AgentSessionAccountHome, env: Record): Record +} + +/** An agent whose account is one config directory named by `variable`, with a default under the + * user's home. */ +export function directoryAccountBinding( + variable: string, + defaultHome: (homePath: string) => string +): AcpAccountBinding { + return { + pin: { accountHomeVariable: variable }, + resolve: async ({ launchEnv }) => ({ + variable, + path: resolveStructuredEnvAccountHomePath({ launchEnv, variable, defaultPath: defaultHome }) + }), + environment: (home, env) => { + if (!isLegacyAgentSessionAccountHome(home) || home.variable !== variable) { + throw new Error(`sessions of this agent pin ${variable}`) + } + return { ...env, [variable]: home.path } + } + } +} diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 596ddcb4049..c554e9b6115 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -4,7 +4,7 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { NativeChatBackgroundTaskBlock } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpAgentError } from '../acp-errors' -import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { PermissionOption, ToolCallUpdate } from '../generated/acp-protocol.generated' export type AcpRequestPresentation = { body: ProviderTimelineRequestBody @@ -41,6 +41,10 @@ export type AcpDialectNotification = /** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { injectedPromptIdentity?: true + /** A tool update in the shared shape (`rawOutput.stdout`, `rawOutput.exitCode`), read first. */ + normalizeToolUpdate?(update: ToolCallUpdate): ToolCallUpdate + /** The words for a permission option whose own name misstates what it grants. */ + permissionOptionLabel?(option: PermissionOption): string | undefined toolName?(update: ToolCallUpdate): string | undefined toolBackgroundTasks?( update: ToolCallUpdate, diff --git a/src/main/acp/acp-dialects/opencode-dialect.ts b/src/main/acp/acp-dialects/opencode-dialect.ts new file mode 100644 index 00000000000..e2904d61870 --- /dev/null +++ b/src/main/acp/acp-dialects/opencode-dialect.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AcpDialect } from './acp-dialect' + +// OpenCode reports a command's output and exit code under `rawOutput.metadata`; the shared shape +// is `stdout` and `exitCode`. +const commandRawOutputSchema = z.looseObject({ + output: z.unknown().optional(), + metadata: z + .looseObject({ + exit: z.number().int().nullable().optional(), + output: z.string().optional() + }) + .optional() +}) + +function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { + const parsed = commandRawOutputSchema.safeParse(update.rawOutput) + if (!parsed.success) { + return update + } + const rawOutput = parsed.data + const output = + typeof rawOutput.output === 'string' ? rawOutput.output : rawOutput.metadata?.output + const hasSharedOutput = ['stdout', 'stderr', 'output_for_prompt'].some( + (key) => rawOutput[key] !== undefined + ) + const hasSharedExitCode = rawOutput.exitCode !== undefined || rawOutput.exit_code !== undefined + const exitCode = rawOutput.metadata?.exit ?? undefined + if ((output === undefined || hasSharedOutput) && (exitCode === undefined || hasSharedExitCode)) { + return update + } + return { + ...update, + rawOutput: { + ...rawOutput, + ...(output === undefined || hasSharedOutput ? {} : { stdout: output }), + ...(exitCode === undefined || hasSharedExitCode ? {} : { exitCode }) + } + } +} + +export const OPENCODE_ACP_DIALECT: AcpDialect = { + normalizeToolUpdate, + // OpenCode 1.x keeps an "always" grant in its own process, which is this chat's alone and ends + // whenever Orca ends it (a Stop, quitting, or stopping an idle chat's agent): not "always". + permissionOptionLabel: (option) => + option.kind === 'allow_always' ? 'Allow until OpenCode restarts' : undefined +} diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 2b705aa0dd7..dcf91c1d4f8 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -9,15 +9,26 @@ import { import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import type { AcpDialect } from './acp-dialects/acp-dialect' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' +import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' +import { directoryAccountBinding, type AcpAccountBinding } from './acp-account-binding' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' +import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-stored-messages' +import type { AcpStoredUserMessagesReader } from './acp-recovery-history' +import { isStableCliVersionOnLine } from '../agent-cli-version-probe' +import type { TuiAgent } from '../../shared/tui-agent' export type AcpLaunchSpec = { - /** The Orca agent id (a `TuiAgent`), which names the agent's records and its catalog label. */ - agent: string + /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ + agent: TuiAgent command: string /** Built per launch: `fullAccess` is the Agent Permissions setting's bypass posture. */ args(input: { fullAccess: boolean }): string[] - /** Overlaid on the child's environment. */ + /** Laid over the child's environment last, after the account and the user's own variables. */ env: Readonly> + /** Rewrites what the child would inherit from Orca's own plumbing; returns the keys it must not + * inherit at all. `inherited` is the environment the child process starts from. */ + scrubEnvironment?(env: Record, inherited: NodeJS.ProcessEnv): string[] dialect: AcpDialect /** The agent's own sign-in command, for a person to run when it reports auth required. */ loginCommand: readonly string[] @@ -27,12 +38,17 @@ export type AcpLaunchSpec = { advertised: readonly string[] env: Readonly> }): string | undefined - /** Variable naming the agent's config directory, pinned as each record's account home. */ - accountHomeVariable: string - /** The config directory the agent uses when the variable is unset, under the user's home. */ - defaultAccountHome(homePath: string): string + /** The account each chat pins, and how a launch points the agent at it. */ + account: AcpAccountBinding + /** The `--version` releases a structured chat runs on, asked before a create and again at every + * launch; any other release keeps the terminal chat. Absent runs whatever is installed. */ + supportsVersion?(version: string): boolean /** Where the agent installs its own binary, searched after PATH. */ - installDirectories(accountHomePath: string): string[] + installDirectories(input: { env: Readonly>; homePath: string }): string[] + /** Images go to the agent when it also advertises them; off sends text prompts only. */ + imagePrompts?: true + /** The agent's own store of a session's user messages, read for restart recovery only. */ + readStoredUserMessages?: AcpStoredUserMessagesReader } const GROK_LAUNCH_SPEC: AcpLaunchSpec = { @@ -50,12 +66,33 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { : advertised.includes('cached_token') ? 'cached_token' : undefined, - accountHomeVariable: 'GROK_HOME', - defaultAccountHome: (homePath) => join(homePath, '.grok'), - installDirectories: (accountHomePath) => [join(accountHomePath, 'bin')] + account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), + installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []) } -export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC] +// OpenCode 1.x serves ACP in-process through `opencode acp`. OpenCode 2 (`opencode2`, and any +// `opencode` that is 2.x) is not: its `acp` runs inside the user's own background service, which a +// chat's environment and account pin do not reach, so it keeps its terminal-backed chat. +const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { + agent: 'opencode', + command: 'opencode', + // OpenCode has no bypass flag: full access answers each permission request yes. + args: () => ['acp'], + // Applied last: the client name ACP sessions report, and no question tool, which ACP cannot + // answer (it would wait forever). + env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' }, + scrubEnvironment: scrubOpenCodeAcpEnvironment, + dialect: OPENCODE_ACP_DIALECT, + loginCommand: ['opencode', 'auth', 'login'], + account: openCodeAcpAccountBinding(), + installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')], + // Stable 1.x from 1.18.31, the release the recorded sessions capture. + supportsVersion: (version) => isStableCliVersionOnLine(version, { major: 1, floor: '1.18.31' }), + imagePrompts: true, + readStoredUserMessages: openCodeStoredUserMessagesReader() +} + +export const ACP_LAUNCH_SPECS: readonly AcpLaunchSpec[] = [GROK_LAUNCH_SPEC, OPENCODE_LAUNCH_SPEC] export function acpLaunchSpecFor(agent: string): AcpLaunchSpec | null { return ACP_LAUNCH_SPECS.find((spec) => spec.agent === agent) ?? null diff --git a/src/main/acp/acp-prompt-content.test.ts b/src/main/acp/acp-prompt-content.test.ts new file mode 100644 index 00000000000..63b7363ca5f --- /dev/null +++ b/src/main/acp/acp-prompt-content.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + ACP_PROMPT_IMAGE_MAX_BYTES, + AcpPromptContentError, + acpPromptBlocks +} from './acp-prompt-content' + +const PNG = Buffer.from('89504e470d0a1a0a', 'hex') +const dir = mkdtempSync(join(tmpdir(), 'acp-prompt-content-')) +const pngPath = join(dir, 'shot.png') +writeFileSync(pngPath, PNG) + +function message(...blocks: NativeChatBlock[]): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +async function refusal(body: AgentJournalMessageItem, images = true) { + const error = await acpPromptBlocks(body, images).catch((caught: unknown) => caught) + if (!(error instanceof AcpPromptContentError)) { + throw new Error(`expected a refusal, got ${String(error)}`) + } + return error.failure +} + +describe('ACP prompt content', () => { + it('sends text as text blocks', async () => { + await expect(acpPromptBlocks(message({ type: 'text', text: 'hi' }), false)).resolves.toEqual([ + { type: 'text', text: 'hi' } + ]) + }) + + it('sends a file or data image as an ACP image block, in order', async () => { + const data = PNG.toString('base64') + await expect( + acpPromptBlocks( + message( + { type: 'image-ref', path: pngPath }, + { type: 'text', text: 'what is this?' }, + { type: 'image-ref', url: pathToFileURL(pngPath).href }, + { type: 'image-ref', url: `data:image/png;base64,${data}` } + ), + true + ) + ).resolves.toEqual([ + { type: 'image', mimeType: 'image/png', data }, + { type: 'text', text: 'what is this?' }, + { type: 'image', mimeType: 'image/png', data }, + { type: 'image', mimeType: 'image/png', data } + ]) + }) + + it('refuses an image when the agent takes none', async () => { + expect(await refusal(message({ type: 'image-ref', path: pngPath }), false)).toMatchObject({ + kind: 'attachmentInvalid', + attachment: { reason: 'unsupportedType' } + }) + }) + + it('refuses what it cannot read or send before anything leaves Orca', async () => { + expect(await refusal(message({ type: 'image-ref', path: 'relative.png' }))).toMatchObject({ + attachment: { reason: 'noSource' } + }) + expect(await refusal(message({ type: 'image-ref', path: join(dir, 'a.bmp') }))).toMatchObject({ + attachment: { reason: 'unsupportedType' } + }) + expect(await refusal(message({ type: 'image-ref', path: `${dir}.png` }))).toMatchObject({ + kind: 'attachmentUnreadable' + }) + expect( + await refusal(message({ type: 'image-ref', url: 'data:image/svg+xml;base64,PHN2Zz4=' })) + ).toMatchObject({ attachment: { reason: 'unsupportedType' } }) + }) + + it('keeps a message with images within one protocol line', async () => { + // The images fit their raw limit, but with this much text the prompt would pass the line. + const text = 'x'.repeat(8 * 1024 * 1024) + const image = { + type: 'image-ref' as const, + url: `data:image/png;base64,${PNG.toString('base64')}` + } + expect( + await refusal(message({ type: 'text', text }, { type: 'text', text }, image)) + ).toMatchObject({ + attachment: { reason: 'totalTooLarge' } + }) + expect(ACP_PROMPT_IMAGE_MAX_BYTES * (4 / 3)).toBeLessThan(16 * 1024 * 1024) + }) + + it('leaves a text-only message to the agent, however long', async () => { + const text = 'x'.repeat(9 * 1024 * 1024) + await expect( + acpPromptBlocks(message({ type: 'text', text }, { type: 'text', text }), true) + ).resolves.toHaveLength(2) + }) + + it("bounds one message's images together", async () => { + const big = Buffer.alloc(ACP_PROMPT_IMAGE_MAX_BYTES / 2 + 1).toString('base64') + const image = { type: 'image-ref' as const, url: `data:image/png;base64,${big}` } + expect(await refusal(message(image, image))).toMatchObject({ + attachment: { reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES } + }) + }) +}) diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts new file mode 100644 index 00000000000..2d1c6e336b1 --- /dev/null +++ b/src/main/acp/acp-prompt-content.ts @@ -0,0 +1,161 @@ +// A person's message as an ACP prompt. Images go as the protocol's image blocks, and only to an +// agent that advertised them; each is checked and read before anything reaches the agent. + +import { extname, isAbsolute } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { IMAGE_FILE_MIME_TYPES } from '../../shared/image-file-extensions' +import type { NativeChatImageRefBlock } from '../../shared/native-chat-types' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/node-bounded-file-reader' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { resolveAcpPeerOptions } from './acp-peer-limits' +import { acpAgentName } from './acp-structured-acquire' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { ContentBlock } from './generated/acp-protocol.generated' + +const ACP_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) +/** A prompt is one JSON-RPC line; this leaves room for the request around its blocks. */ +const ACP_PROMPT_LINE_BUDGET = resolveAcpPeerOptions().maxLineBytes - 64 * 1024 +/** One message's images together, raw: base64 makes each 4 bytes of them 3 more. */ +export const ACP_PROMPT_IMAGE_MAX_BYTES = Math.floor((ACP_PROMPT_LINE_BUDGET * 3) / 4) - 1024 * 1024 +const MAX_IMAGE_COUNT = 20 +const READ_TIMEOUT_MS = 15_000 + +/** Why Orca did not send a message; never the agent's refusal. */ +export class AcpPromptContentError extends Error { + constructor(readonly failure: SubmissionRejectionFact) { + super(`ACP prompt refused: ${failure.kind}`) + this.name = 'AcpPromptContentError' + } +} + +function attachmentProblem(attachment: AgentSessionAttachmentProblem): AcpPromptContentError { + return new AcpPromptContentError(agentSessionFailureFact('attachmentInvalid', { attachment })) +} + +async function imageBlock( + block: NativeChatImageRefBlock, + remainingBytes: number, + signal: AbortSignal +): Promise<{ block: ContentBlock; bytes: number }> { + const tooLarge = (): AcpPromptContentError => + attachmentProblem({ + reason: remainingBytes < ACP_PROMPT_IMAGE_MAX_BYTES ? 'totalTooLarge' : 'tooLarge', + limit: ACP_PROMPT_IMAGE_MAX_BYTES + }) + if (block.url?.startsWith('data:')) { + const match = /^data:(image\/[a-z]+);base64,([A-Za-z0-9+/]*={0,2})$/.exec(block.url) + if (!match || !ACP_IMAGE_MIME_TYPES.has(match[1]) || match[2].length % 4 !== 0) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + const bytes = Buffer.byteLength(match[2], 'base64') + if (bytes === 0) { + throw attachmentProblem({ reason: 'empty' }) + } + if (bytes > remainingBytes) { + throw tooLarge() + } + return { block: { type: 'image', mimeType: match[1], data: match[2] }, bytes } + } + let path = block.path + if (!path && block.url?.startsWith('file:')) { + try { + path = fileURLToPath(block.url) + } catch { + throw attachmentProblem({ reason: 'noSource' }) + } + } + if (!path || !isAbsolute(path)) { + throw attachmentProblem({ reason: 'noSource' }) + } + const mimeType = IMAGE_FILE_MIME_TYPES[extname(path).toLowerCase()] + if (!mimeType || !ACP_IMAGE_MIME_TYPES.has(mimeType)) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + let data: Buffer + try { + data = (await readNodeFileWithinLimit(path, remainingBytes, { regularFileOnly: true, signal })) + .buffer + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw tooLarge() + } + if (error instanceof Error && error.message === 'Expected a regular file') { + throw attachmentProblem({ reason: 'notAFile' }) + } + throw new AcpPromptContentError(agentSessionFailureFact('attachmentUnreadable')) + } + if (data.byteLength === 0) { + throw attachmentProblem({ reason: 'empty' }) + } + return { block: { type: 'image', mimeType, data: data.toString('base64') }, bytes: data.length } +} + +/** The prompt for `body`; throws `AcpPromptContentError` for what Orca will not send. */ +export async function acpPromptBlocks( + body: AgentJournalMessageItem, + acceptsImages: boolean +): Promise { + const blocks: ContentBlock[] = [] + const signal = AbortSignal.timeout(READ_TIMEOUT_MS) + let imageCount = 0 + let imageBytes = 0 + for (const block of body.blocks) { + if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text }) + continue + } + if (block.type !== 'image-ref' || !acceptsImages) { + throw attachmentProblem({ reason: 'unsupportedType' }) + } + imageCount += 1 + if (imageCount > MAX_IMAGE_COUNT) { + throw attachmentProblem({ reason: 'tooMany', limit: MAX_IMAGE_COUNT }) + } + const image = await imageBlock(block, ACP_PROMPT_IMAGE_MAX_BYTES - imageBytes, signal) + imageBytes += image.bytes + blocks.push(image.block) + } + // Only images can carry a message past one line here; their words are the refusal's. + if (imageCount > 0 && Buffer.byteLength(JSON.stringify(blocks)) > ACP_PROMPT_LINE_BUDGET) { + throw attachmentProblem({ reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES }) + } + return blocks +} + +/** The prompt for `body` to `session`'s agent, or Orca's refusal of it in words that name the + * agent. Images go only where the agent's row offers them and its start advertised them. */ +export async function acpDispatchPrompt( + body: AgentJournalMessageItem, + session: { + spec: Pick + connection: Pick + } +): Promise { + try { + const advertised = + session.spec.imagePrompts === true && + (await session.connection.initialize()).agentCapabilities?.promptCapabilities?.image === true + return await acpPromptBlocks(body, advertised) + } catch (error) { + if (error instanceof AcpPromptContentError) { + return agentSessionFailureWords(error.failure, { + surface: 'rejection', + agentName: acpAgentName(session.spec.agent) + }) + } + throw error + } +} diff --git a/src/main/acp/acp-recovery-history.test.ts b/src/main/acp/acp-recovery-history.test.ts new file mode 100644 index 00000000000..402b9e2f76d --- /dev/null +++ b/src/main/acp/acp-recovery-history.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' +import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { readAcpRecoveryHistory, type AcpStoredUserMessage } from './acp-recovery-history' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' + +const SESSION = 'session-alpha-1' +const identity = { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null +} + +function fingerprint(text: string): string { + return computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } + }) +} + +function submission( + clientMessageId: string, + text: string, + dispatchState: AgentJournalSubmission['dispatchState'], + submittedAt: number, + resolvedAt: number | null = null +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: fingerprint(text), + dispatchState, + providerItemId: null, + reason: null, + submittedAt, + resolvedAt + } +} + +function journal(...submissions: AgentJournalSubmission[]): JournalLoad { + const state = createJournalReducerState(SESSION, 'epoch-1') + for (const entry of submissions) { + state.submissions.set(entry.clientMessageId, entry) + } + return { state, newer: null, damage: null } +} + +const launch: AcpStructuredLaunch = { + spec: acpLaunchSpecFor('opencode')!, + command: '/resolved/opencode', + args: ['acp'], + cwd: '/repo', + env: { XDG_DATA_HOME: '/data' }, + envToDelete: [], + fullAccess: false, + resume: { + sessionId: 'ses_1', + key: 'acp:opencode:ses_1', + mayBeUnsaved: () => false, + unannouncedLosses: () => [] + } +} + +function stored(id: string, text: string, createdAt: number): AcpStoredUserMessage { + return { id, blocks: [{ type: 'text', text }], createdAt } +} + +async function windowFor(load: JournalLoad | null, messages: AcpStoredUserMessage[] | null) { + const readStoredUserMessages = vi.fn(async () => messages) + const window = await readAcpRecoveryHistory( + { + spec: { readStoredUserMessages }, + resolveLaunch: async () => launch, + readJournal: () => load + }, + identity + ) + return { window, readStoredUserMessages } +} + +describe('ACP restart recovery from the agent store', () => { + it('settles a message the agent recorded after the crash as sent, under its own row', async () => { + const load = journal( + submission('first', 'hello', 'accepted', 100, 110), + submission('held', 'run the tests', 'unknown', 200) + ) + const { window, readStoredUserMessages } = await windowFor(load, [ + stored('msg_1', 'hello', 105), + stored('msg_2', 'run the tests', 205) + ]) + expect(readStoredUserMessages).toHaveBeenCalledWith( + expect.objectContaining({ env: launch.env, providerSessionId: 'ses_1' }) + ) + expect(window).toMatchObject({ boundaryConsistent: false, turnInFlight: true }) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toEqual([ + { + clientMessageId: 'held', + outcome: 'accepted', + providerItemId: 'msg_2', + identity: { provider: 'orca', clientMessageId: 'held' } + } + ]) + }) + + it('never offers an older identical message as the one in doubt', async () => { + const load = journal( + submission('first', 'continue', 'accepted', 100, 110), + submission('held', 'continue', 'unknown', 200) + ) + const { window } = await windowFor(load, [stored('msg_1', 'continue', 105)]) + expect(window?.items).toEqual([]) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toMatchObject([{ clientMessageId: 'held', outcome: 'unknown' }]) + }) + + it('lets an accepted send claim its own copy even when the agent stored it after the next send', async () => { + // Accepted at dispatch (r=500) and stored at 505; the identical send behind it was lost. + const load = journal( + submission('first', 'continue', 'accepted', 100, 500), + submission('held', 'continue', 'unknown', 110) + ) + const { window } = await windowFor(load, [stored('msg_1', 'continue', 505)]) + expect(window?.items).toEqual([]) + }) + + it('keeps an older unconfirmed send from widening the window over a delivered copy', async () => { + const load = journal( + submission('old', 'yes', 'unknown', 50), + submission('first', 'yes', 'accepted', 300, 300), + submission('held', 'yes', 'unknown', 400) + ) + const { window } = await windowFor(load, [stored('msg_1', 'yes', 303)]) + expect(window?.items).toEqual([]) + }) + + it('confirms the doubted send when both identical copies are stored', async () => { + const load = journal( + submission('first', 'yes', 'accepted', 300, 300), + submission('held', 'yes', 'unknown', 400) + ) + const { window } = await windowFor(load, [ + stored('msg_1', 'yes', 303), + stored('msg_2', 'yes', 405) + ]) + expect( + reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! }) + ).toMatchObject([{ clientMessageId: 'held', outcome: 'accepted', providerItemId: 'msg_2' }]) + }) + + it('reads nothing for a queued card the agent was never handed', async () => { + const queued = { ...submission('queued', 'x', 'pending', 1), handoverRecorded: true as const } + const { window, readStoredUserMessages } = await windowFor(journal(queued), []) + expect(window).toBeNull() + expect(readStoredUserMessages).not.toHaveBeenCalled() + }) + + it('reads nothing with no message in doubt, a damaged journal, or no resumable session', async () => { + expect((await windowFor(journal(submission('a', 'x', 'accepted', 1, 2)), [])).window).toBeNull() + const damaged = journal(submission('held', 'x', 'unknown', 1)) + expect( + (await windowFor({ ...damaged, damage: { sequence: 3, cause: 'sequence-gap' } }, [])).window + ).toBeNull() + const readStoredUserMessages = vi.fn(async () => []) + await expect( + readAcpRecoveryHistory( + { + spec: { readStoredUserMessages }, + resolveLaunch: async () => ({ ...launch, resume: null }), + readJournal: () => journal(submission('held', 'x', 'unknown', 1)) + }, + identity + ) + ).resolves.toBeNull() + expect(readStoredUserMessages).not.toHaveBeenCalled() + }) + + it('reads nothing for an agent whose store Orca does not read', async () => { + await expect( + readAcpRecoveryHistory( + { + spec: {}, + resolveLaunch: async () => launch, + readJournal: () => journal(submission('held', 'x', 'unknown', 1)) + }, + identity + ) + ).resolves.toBeNull() + }) + + it('answers null, never throws, when the store cannot be read', async () => { + const warn = vi.fn() + await expect( + readAcpRecoveryHistory( + { + spec: { + readStoredUserMessages: async () => { + throw new Error('database is locked') + } + }, + resolveLaunch: async () => launch, + readJournal: () => journal(submission('held', 'x', 'unknown', 1)), + logger: { warn, error: vi.fn() } + }, + identity + ) + ).resolves.toBeNull() + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/src/main/acp/acp-recovery-history.ts b/src/main/acp/acp-recovery-history.ts new file mode 100644 index 00000000000..d00a21a6d86 --- /dev/null +++ b/src/main/acp/acp-recovery-history.ts @@ -0,0 +1,145 @@ +// Restart recovery for an ACP agent that keeps its own store: whether a message a crash left +// unconfirmed reached the agent. Presence only: a match settles it as sent, while absence proves +// nothing, so nothing is ever called undelivered. Bounded, and null on any failure; the user can +// always resend or discard, so this never stands between them and the chat. + +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { agentSessionSendBodyFingerprint } from '../../shared/structured-agent-session-send-mutation' +import type { + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' +import type { + ProviderHistoryItem, + ProviderHistoryWindow +} from '../native-chat/agent-session-journal/journal-submission-reconciler' +import { isQueuedAgentJournalSubmission } from '../../shared/agent-session-queued-submission' +import type { AcpLaunchSpec } from './acp-launch-specs' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +const RECOVERY_READ_MS = 5_000 + +/** A person's message as the agent's own store holds it. */ +export type AcpStoredUserMessage = { + /** The store's id for it, claimed at most once. */ + id: string + blocks: NativeChatBlock[] + /** Epoch ms the agent recorded it, on this machine's clock. */ + createdAt: number +} + +/** Reads the user messages of one provider session from the agent's own store; null when it cannot. */ +export type AcpStoredUserMessagesReader = (input: { + env: Record + providerSessionId: string + signal: AbortSignal +}) => Promise + +/** Sends a crash may have left unconfirmed; a queued card was never handed over, so the agent + * cannot hold it. */ +function sendsInDoubt(load: JournalLoad): AgentJournalSubmission[] { + return [...load.state.submissions.values()].filter( + (submission) => + (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') && + !isQueuedAgentJournalSubmission(submission) + ) +} + +function fingerprintOf(sessionId: string, message: AcpStoredUserMessage): string { + return agentSessionSendBodyFingerprint(sessionId, { + kind: 'message', + role: 'user', + blocks: message.blocks + }) +} + +/** + * The agent's store keeps no id of Orca's, so a stored message can only match a send by content. + * Each send the journal already accepted first claims the earliest stored copy of its text written + * no earlier than it was sent, so an identical message that did reach the agent is never offered as + * the one in doubt; what is left, from the first send in doubt on, is the window. Claiming more than + * a send's own copy only leaves a send in doubt unconfirmed, never wrongly confirmed. + */ +function recoveryWindow( + load: JournalLoad, + inDoubt: readonly AgentJournalSubmission[], + messages: readonly AcpStoredUserMessage[] +): ProviderHistoryItem[] { + const submissions = [...load.state.submissions.values()] + const from = Math.min(...inDoubt.map((submission) => submission.submittedAt)) + const stored = messages + .map((message) => ({ message, fingerprint: fingerprintOf(load.state.sessionId, message) })) + .toSorted((left, right) => left.message.createdAt - right.message.createdAt) + const claimed = new Set() + const accepted = submissions + .filter((submission) => submission.dispatchState === 'accepted') + .toSorted((left, right) => left.submittedAt - right.submittedAt) + for (const submission of accepted) { + const index = stored.findIndex( + ({ message, fingerprint }, at) => + !claimed.has(at) && + fingerprint === submission.payloadFingerprint && + message.createdAt >= submission.submittedAt + ) + if (index !== -1) { + claimed.add(index) + } + } + return stored.flatMap(({ message, fingerprint }, at) => + claimed.has(at) || message.createdAt < from + ? [] + : [{ providerItemId: message.id, clientMessageId: null, payloadFingerprint: fingerprint }] + ) +} + +/** The window for one chat; null for an agent whose store Orca does not read. */ +export async function readAcpRecoveryHistory( + input: Pick & { + spec: Pick + }, + identity: AgentSessionJournalIdentity +): Promise { + const { readStoredUserMessages } = input.spec + if (!readStoredUserMessages || !input.readJournal) { + return null + } + const sessionId = identity.sessionId + const signal = AbortSignal.timeout(RECOVERY_READ_MS) + try { + const load = input.readJournal(sessionId) + const inDoubt = load && !load.damage && !load.newer ? sendsInDoubt(load) : [] + if (!load || inDoubt.length === 0) { + return null + } + const launch = await waitForPromiseWithSignal(input.resolveLaunch({ identity }), signal) + if (!launch.resume) { + return null + } + const messages = await waitForPromiseWithSignal( + readStoredUserMessages({ + env: launch.env, + providerSessionId: launch.resume.sessionId, + signal + }), + signal + ) + if (!messages) { + return null + } + return { + // Absence proves nothing: neither the start of the read nor the end of the agent's work is known. + boundaryConsistent: false, + turnInFlight: true, + items: recoveryWindow(load, inDoubt, messages) + } + } catch (error) { + input.logger?.warn('ACP recovery history could not be read', { + scope: 'acp-recovery-history', + sessionId, + error + }) + return null + } +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index cf7466f27d8..b9c8b41d209 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -124,7 +124,7 @@ export async function acquireAcpStructuredSession(input: { ...structuredSessionChildIdentityEnv(sessionId, launch.env), [PROVIDER_SPAWN_TOKEN_ENV]: acquire.spawnToken }, - envToDelete: ACP_CHILD_ENV_TO_DELETE + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] }, { clientInfo: { name: 'orca', version: '1' }, diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index e39ade6c7b9..a81fb2c8198 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -11,7 +11,7 @@ import { SESSION, type ProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { acpLaunchSpecFor } from './acp-launch-specs' +import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support' import type { AcpAgentConnectionOptions } from './acp-agent-connection' import { AcpConnectionClosedError } from './acp-errors' @@ -156,6 +156,8 @@ export type AcpAdapterRig = { export async function openAcpAdapterRig( options: { + /** The agent's row; Grok's unless a test drives another agent. */ + spec?: AcpLaunchSpec launch?: Partial initialize?: Record script?: (agent: AcpScriptedAgent) => void @@ -167,14 +169,16 @@ export async function openAcpAdapterRig( const spawned: string[] = [] const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] + const spec = options.spec ?? GROK const adapter = new AcpStructuredSessionAdapter({ - spec: GROK, + spec, resolveLaunch: async () => ({ - spec: GROK, - command: '/opt/grok/bin/grok', - args: GROK.args({ fullAccess: false }), + spec, + command: `/opt/bin/${spec.command}`, + args: spec.args({ fullAccess: false }), cwd: '/workspace/project', env: { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }, + envToDelete: [], fullAccess: false, resume: null, ...options.launch @@ -226,7 +230,7 @@ export async function openAcpAdapterRig( sessionId: SESSION, workspaceId: 'workspace-1', hostId: 'local', - agent: 'grok', + agent: spec.agent, providerHandle: null }, fence: acquireOptions.fence ?? 1, diff --git a/src/main/acp/acp-structured-agent-definitions.ts b/src/main/acp/acp-structured-agent-definitions.ts index d51816db450..c82d5483ad8 100644 --- a/src/main/acp/acp-structured-agent-definitions.ts +++ b/src/main/acp/acp-structured-agent-definitions.ts @@ -16,15 +16,15 @@ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAge return { agent: spec.agent, handleTransport: ACP_HANDLE_TRANSPORT, - accountHomeVariable: spec.accountHomeVariable, + ...spec.account.pin, capabilities: { // ACP has no stable rewind, compact or goal method; a command arrives through `/` instead. rewind: false, compact: false, threadGoal: false, contextUsage: true, - // Off: Orca sends text prompts only until ACP image prompts have a path. - imagePrompts: false, + // An agent that does not also advertise images at its start has a message with one refused. + imagePrompts: spec.imagePrompts === true, steering: 'queue', // Orca answers every permission request the agent sends; the agent decides when it asks. approvalEnforcement: 'orca' diff --git a/src/main/acp/acp-structured-electron-free.test.ts b/src/main/acp/acp-structured-electron-free.test.ts index 0a63542a199..dfc3ceb45eb 100644 --- a/src/main/acp/acp-structured-electron-free.test.ts +++ b/src/main/acp/acp-structured-electron-free.test.ts @@ -6,12 +6,13 @@ import { build } from 'esbuild' import { describe, expect, it } from 'vitest' describe('ACP structured lane', () => { - it('reaches no Electron import from the adapter or its launch resolution', async () => { + it('reaches no Electron import from the adapter, its launch resolution or any agent row', async () => { const result = await build({ entryPoints: [ resolve(__dirname, 'acp-structured-session-adapter.ts'), resolve(__dirname, 'acp-structured-launch-resolution.ts'), - resolve(__dirname, 'acp-agent-connection.ts') + resolve(__dirname, 'acp-agent-connection.ts'), + resolve(__dirname, 'acp-launch-specs.ts') ], bundle: true, write: false, diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts index c4cf1605c79..51555fe9a73 100644 --- a/src/main/acp/acp-structured-fixture-replay.test-support.ts +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -16,7 +16,10 @@ const messageSchema = z.object({ id: z.union([z.string(), z.number()]).optional(), method: z.string().optional(), params: z.unknown().optional(), - result: z.unknown().optional() + result: z.unknown().optional(), + error: z + .object({ code: z.number(), message: z.string(), data: z.unknown().optional() }) + .optional() }) function promptIdOf(params: unknown): string | undefined { @@ -83,13 +86,13 @@ export class GrokFixtureReplay { agent.send({ jsonrpc: '2.0', id: this.requestIds.get(message.id ?? '') ?? message.id, - result: message.result + ...(message.error === undefined ? { result: message.result } : { error: message.error }) }) } } } - private rewrite(message: AcpFixtureFrame['message']): AcpFixtureFrame['message'] { + private rewrite(message: AcpFixtureFrame['message']): z.infer { let text = JSON.stringify(message).replaceAll( RECORDED_SESSION, JSON.stringify(PROVIDER_SESSION) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index afc1c1c4240..cc6dc724491 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -61,6 +61,7 @@ export function launch(resume: () => boolean): () => Promise { + it('sends an image to an agent whose row offers images and whose start advertised them', async () => { + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('opencode')!, + initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toEqual({ state: 'admitted' }) + expect((await rig.frame('session/prompt')).params).toMatchObject({ + prompt: [ + { type: 'image', mimeType: 'image/png', data: DATA }, + { type: 'text', text: 'what is this?' } + ] + }) + }) + + it('refuses the message, sending nothing, when the agent did not advertise images', async () => { + const rig = await openAcpAdapterRig({ spec: acpLaunchSpecFor('opencode')! }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'attachmentInvalid' } }) + expect(rig.sent('session/prompt')).toEqual([]) + }) + + it('refuses an image for an agent whose row offers none, whatever it advertises', async () => { + const rig = await openAcpAdapterRig({ + initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) + ).resolves.toMatchObject({ state: 'rejected' }) + expect(rig.sent('session/prompt')).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts new file mode 100644 index 00000000000..db5c10a551d --- /dev/null +++ b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { ManagedDataAccountsState } from '../../shared/managed-account-types' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { createProviderSpawnSpec } from '../provider-process/provider-process-supervisor' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' +import { ACP_CHILD_ENV_TO_DELETE, acpLaunchSpecFor } from './acp-launch-specs' +import { + createAcpStructuredLaunchResolver, + type AcpStructuredLaunchResolverDeps +} from './acp-structured-launch-resolution' + +const PROFILE = '123e4567-e89b-42d3-a456-426614174000' + +const managedAccounts = { + list: (): ManagedDataAccountsState => ({ + accounts: [{ id: PROFILE, label: 'work', integrations: [], createdAt: 0 }], + activeAccountId: PROFILE + }), + restoreOriginalEnvironment: (environment: Record) => + restoreManagedDataAccountEnvironment(environment), + environmentForAccount: (_provider: 'opencode' | 'devin', id: string) => ({ + XDG_DATA_HOME: `/profiles/${id}/data`, + XDG_STATE_HOME: `/profiles/${id}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + }) +} + +const OPENCODE = { + ...acpLaunchSpecFor('opencode')!, + account: openCodeAcpAccountBinding(() => managedAccounts) +} +const identity = { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null +} + +function openCodeRecord(accountHome: AgentSessionRecord['accountHome']): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome + } +} + +const UNMANAGED: AgentSessionRecord['accountHome'] = { + kind: 'opencode', + locator: { kind: 'unmanaged' } +} + +function resolver( + record: AgentSessionRecord, + options: { + launchEnv?: Record + inheritedEnv?: NodeJS.ProcessEnv + base?: Record + probeVersion?: AcpStructuredLaunchResolverDeps['probeVersion'] + } = {} +) { + return createAcpStructuredLaunchResolver(OPENCODE, { + store: { getRecord: () => record }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user', ...options.base }), + resolveLaunchEnv: () => options.launchEnv ?? {}, + resolveCommand: (command) => `/resolved/${command}`, + inheritedEnv: options.inheritedEnv ?? {}, + probeVersion: options.probeVersion ?? (async (_input, supports) => supports('1.18.31')) + }) +} + +describe('OpenCode ACP launch resolution', () => { + it('runs `opencode acp` as an ACP client with no question tool, whatever the user set', async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + launchEnv: { OPENCODE_CLIENT: 'tui', OPENCODE_ENABLE_QUESTION_TOOL: 'true' } + })({ identity }) + expect(launch).toMatchObject({ command: '/resolved/opencode', args: ['acp'] }) + expect(launch.env).toMatchObject({ + OPENCODE_CLIENT: 'acp', + OPENCODE_ENABLE_QUESTION_TOOL: 'false' + }) + }) + + it("starts OpenCode with the user's own folders, database and inline credentials", async () => { + const userEnv = { + HOME: 'relative/home', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: '/data/opencode-state', + OPENCODE_DB: 'work.db', + OPENCODE_AUTH_CONTENT: '{"secret":1}' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { + base: { XDG_DATA_HOME: '/elsewhere' }, + launchEnv: userEnv + })({ identity }) + expect(launch.env).toMatchObject(userEnv) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + {}, + 'darwin' + ).env + expect(child).toMatchObject(userEnv) + }) + + it('leaves unset what the user left unset, whatever Orca itself inherited', async () => { + const inheritedEnv = { OPENCODE_DB: '/elsewhere/other.db', OPENCODE_AUTH_CONTENT: '' } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + expect(launch.env.XDG_DATA_HOME).toBeUndefined() + expect(launch.env.OPENCODE_DB).toBeUndefined() + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: launch.envToDelete + }, + inheritedEnv, + 'darwin' + ).env + expect(child.OPENCODE_DB).toBeUndefined() + expect(child.OPENCODE_AUTH_CONTENT).toBeUndefined() + }) + + it('points a managed profile at its own directories', async () => { + const launch = await resolver( + openCodeRecord({ kind: 'opencode', locator: { kind: 'managed', managedProfileId: PROFILE } }) + )({ identity }) + expect(launch.env).toMatchObject({ + XDG_DATA_HOME: `/profiles/${PROFILE}/data`, + XDG_STATE_HOME: `/profiles/${PROFILE}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + }) + }) + + it("restores the user's own config directory over Orca's status overlay", async () => { + const inheritedEnv = { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/home/user/.config/opencode-mine', + ORCA_OPENCODE_AGENT: 'opencode' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + expect(launch.env.OPENCODE_CONFIG_DIR).toBe('/home/user/.config/opencode-mine') + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + inheritedEnv, + 'darwin' + ).env + expect(Object.keys(child).filter((key) => key.startsWith('ORCA_OPENCODE_'))).toEqual([]) + expect(child.OPENCODE_CONFIG_DIR).toBe('/home/user/.config/opencode-mine') + }) + + it('drops an inherited overlay with no recorded source, so the default config is read', async () => { + const inheritedEnv = { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay' + } + const launch = await resolver(openCodeRecord(UNMANAGED), { inheritedEnv })({ identity }) + const child = createProviderSpawnSpec( + { + command: launch.command, + args: launch.args, + env: launch.env, + envToDelete: [...ACP_CHILD_ENV_TO_DELETE, ...launch.envToDelete] + }, + inheritedEnv, + 'darwin' + ).env + expect(child.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(child.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it("drops Orca's retired shared plugin directory an old shell still exports", () => { + const env: Record = { + OPENCODE_CONFIG_DIR: '/orca-data/opencode-hooks/shared', + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode' + } + const removed = scrubOpenCodeAcpEnvironment(env, {}, '/orca-data') + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(removed).toEqual( + expect.arrayContaining(['OPENCODE_CONFIG_DIR', 'ORCA_DATA_ACCOUNT_PROVIDER']) + ) + }) + + it("keeps a config directory the user set for OpenCode's launches", async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + launchEnv: { OPENCODE_CONFIG_DIR: '/home/user/team-config' }, + inheritedEnv: { + OPENCODE_CONFIG_DIR: '/orca/overlay', + ORCA_OPENCODE_CONFIG_DIR: '/orca/overlay' + } + })({ identity }) + expect(launch.env.OPENCODE_CONFIG_DIR).toBe('/home/user/team-config') + expect(launch.envToDelete).not.toContain('OPENCODE_CONFIG_DIR') + }) + + it('refuses a record that pins a single directory instead of an OpenCode account', async () => { + await expect( + resolver(openCodeRecord({ variable: 'XDG_DATA_HOME', path: '/data' }))({ identity }) + ).rejects.toThrow(/pinned data account/) + }) + + it('refuses an `opencode` that is 2.x before spawning it, as a host that cannot run the chat', async () => { + const launch = resolver(openCodeRecord(UNMANAGED), { + probeVersion: async (_input, supports) => supports('2.0.21') + })({ identity }) + await expect(launch).rejects.toMatchObject({ + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + }) + }) + + it('refuses when the version cannot be read', async () => { + const launch = resolver(openCodeRecord(UNMANAGED), { probeVersion: async () => false }) + await expect(launch({ identity })).rejects.toMatchObject({ + refusal: { code: 'structured_agent_session_unsupported' } + }) + }) + + it('asks the version of exactly the binary, folder and environment it spawns', async () => { + const asked: Parameters>[0][] = [] + const launch = await resolver(openCodeRecord(UNMANAGED), { + probeVersion: async (input, supports) => (asked.push(input), supports('1.18.31')) + })({ identity }) + expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }]) + }) +}) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index dbe372495a0..8c13e2f3e62 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -14,7 +14,13 @@ import { type AgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + resolveStructuredAgentCommand, + type StructuredAgentCommandSettings +} from '../native-chat/structured-agent-command-resolution' +import { probeAgentCliVersion } from '../agent-cli-version-probe' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import { @@ -32,6 +38,8 @@ export type AcpStructuredLaunch = { args: string[] cwd: string env: Record + /** Keys the child must not inherit from this process's own environment. */ + envToDelete: string[] fullAccess: boolean /** The provider session to load; null starts a new one. */ resume: { @@ -58,8 +66,52 @@ export type AcpStructuredLaunchResolverDeps = { resolveLaunchEnv?: (agent: string) => Record /** The Agent Permissions setting's bypass posture for this agent, re-read per acquisition. */ resolveFullAccess?: (agent: string) => boolean + /** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */ + resolveCommandSettings?: () => StructuredAgentCommandSettings resolveCommand?: typeof resolveCliCommand homePath?: string + /** The environment the child process inherits at spawn; this process's own by default. */ + inheritedEnv?: NodeJS.ProcessEnv + probeVersion?: typeof probeAgentCliVersion +} + +/** The binary a launch with `env` spawns: the user's Command setting when set, else the stock + * binary from PATH, then the agent's own install directories. A create's version check resolves + * through here too, so it asks about the same file. */ +export function resolveAcpLaunchCommand( + spec: AcpLaunchSpec, + env: Readonly>, + options: { + commandSettings?: StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + homePath?: string + } = {} +): string { + const homePath = env.HOME ?? env.USERPROFILE ?? options.homePath ?? homedir() + const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories({ env, homePath })] + .filter((entry): entry is string => Boolean(entry)) + .join(delimiter) + return resolveStructuredAgentCommand( + spec.agent, + options.commandSettings ?? {}, + { pathEnv, homePath }, + { + command: spec.command, + ...(options.resolveCommand ? { resolve: options.resolveCommand } : {}) + } + ) +} + +/** Whether `command` is a release the spec runs a structured chat on; true when it names none. */ +export async function acpLaunchVersionSupported( + spec: AcpLaunchSpec, + launch: { command: string; cwd: string; env: Record }, + probe: typeof probeAgentCliVersion = probeAgentCliVersion +): Promise { + return ( + !spec.supportsVersion || + probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, spec.supportsVersion) + ) } export function createAcpStructuredLaunchResolver( @@ -82,33 +134,33 @@ export function createAcpStructuredLaunchResolver( `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` ) } - if (accountHome.variable !== spec.accountHomeVariable) { - throw new Error( - `${spec.agent} sessions pin ${spec.accountHomeVariable}, not ${accountHome.variable}` - ) - } const base = await deps.resolveEnvironment() - const env: Record = { + const env: Record = spec.account.environment(accountHome, { ...base, - ...deps.resolveLaunchEnv?.(spec.agent), - ...spec.env, - [spec.accountHomeVariable]: accountHome.path - } - const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories(accountHome.path)] - .filter((entry): entry is string => Boolean(entry)) - .join(delimiter) - const command = (deps.resolveCommand ?? resolveCliCommand)(spec.command, { - pathEnv, - homePath: env.HOME ?? env.USERPROFILE ?? deps.homePath ?? homedir() + ...deps.resolveLaunchEnv?.(spec.agent) }) + const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] + Object.assign(env, spec.env) + const command = resolveAcpLaunchCommand(spec, env, { + ...deps, + ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) + }) + const cwd = await deps.resolveWorkspacePath(location.workspaceId) + // Again at every launch: the binary on PATH may have changed since the chat was created. + if (!(await acpLaunchVersionSupported(spec, { command, cwd, env }, deps.probeVersion))) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } const fullAccess = deps.resolveFullAccess?.(spec.agent) ?? false const head = agentSessionProviderHandleChainHead(record.providerHandleChain) return { spec, command, args: spec.args({ fullAccess }), - cwd: await deps.resolveWorkspacePath(location.workspaceId), + cwd, env, + envToDelete, fullAccess, resume: head ? { diff --git a/src/main/acp/acp-structured-opencode-recordings.test.ts b/src/main/acp/acp-structured-opencode-recordings.test.ts new file mode 100644 index 00000000000..36a1105697c --- /dev/null +++ b/src/main/acp/acp-structured-opencode-recordings.test.ts @@ -0,0 +1,129 @@ +// The adapter against OpenCode's recorded `opencode acp` traffic (1.18.31): each +// recording plays back as the agent, and the assertions read the journal a client would see. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + closeProviderTimelineRigs, + SESSION +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { acpLaunchSpecFor } from './acp-launch-specs' +import { + openAcpAdapterRig, + waitFor, + type AcpAdapterRig +} from './acp-structured-adapter.test-support' +import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' +import { readAcpFixture } from './acp-timeline-fixture.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +const ask: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Fixture prompt' }] +} + +async function replaying(name: string) { + const replay = new GrokFixtureReplay(await readAcpFixture(name)) + const rig = await openAcpAdapterRig({ + spec: acpLaunchSpecFor('opencode')!, + script: (scripted) => replay.attach(scripted) + }) + await rig.acquire() + await rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'send-1', body: ask, fence: 1 }) + return { rig, replay } +} + +async function turns(rig: AcpAdapterRig) { + return (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) +} + +async function rowsOf(rig: AcpAdapterRig, kind: string) { + return (await rig.rig.rows()).filter((row) => row.body.kind === kind) +} + +async function answer(rig: AcpAdapterRig, replay: GrokFixtureReplay, optionId: string, index = 0) { + const approval = await waitFor(async () => { + const found = (await rowsOf(rig, 'approval'))[index] + if (!found || replay.awaiting !== 'reply') { + throw new Error('no pending approval yet') + } + return found + }) + await rig.adapter.answerPrompt({ + sessionId: SESSION, + itemId: approval.itemId, + kind: 'approval', + response: { kind: 'option', optionId }, + fence: 1, + commit: async () => {} + }) + return approval +} + +describe('OpenCode recordings through the adapter', () => { + it('an approved shell command lands with its exit code, and "always" says it lasts until OpenCode restarts', async () => { + const { rig, replay } = await replaying('opencode-v1-tool') + const approval = await answer(rig, replay, 'once') + expect(approval.body).toMatchObject({ + kind: 'approval', + options: [ + { id: 'once', label: 'Allow once' }, + { id: 'always', label: 'Allow until OpenCode restarts' }, + { id: 'reject', label: 'Reject' } + ] + }) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) + ) + const shell = (await rowsOf(rig, 'tool-call')).find( + (row) => row.body.kind === 'tool-call' && row.body.exitCode !== undefined + ) + expect(shell?.body).toMatchObject({ state: 'completed', exitCode: 0 }) + }) + + it('a rejected permission fails its tool and the turn still ends', async () => { + const { rig, replay } = await replaying('opencode-v1-deny') + await answer(rig, replay, 'reject') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }])) + expect((await rowsOf(rig, 'tool-call')).at(-1)?.body).toMatchObject({ state: 'failed' }) + }) + + it("a subagent's approval never arrives (a known OpenCode bug); Stop still ends the turn", async () => { + const { rig, replay } = await replaying('opencode-v1-subagent-hang') + await waitFor(() => expect(replay.awaiting).toBe('session/cancel')) + expect(await rowsOf(rig, 'approval')).toEqual([]) + await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ + cancelled: true + }) + await waitFor(async () => + expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) + ) + }) + + it("OpenCode's stray file write after an approved edit is refused without breaking the turn", async () => { + const { rig, replay } = await replaying('opencode-v1-stray-write') + await answer(rig, replay, 'once', 0) + await answer(rig, replay, 'reject', 1) + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }])) + // The write OpenCode repeats to a client that took no file system leaves no row. + expect(JSON.stringify((await rig.rig.rows()).map((row) => row.body))).not.toContain( + 'fs/write_text_file' + ) + }) + + it("a provider error fails the turn in OpenCode's own words", async () => { + const { rig, replay } = await replaying('opencode-v1-provider-error') + await waitFor(() => expect(replay.awaiting).toBeNull()) + await waitFor(async () => expect(await turns(rig)).toMatchObject([{ outcome: 'failure' }])) + const texts = JSON.stringify((await rig.rig.rows()).map((row) => row.body)) + expect(texts).toContain('CAPTURE_PROVIDER_400') + }) +}) diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 3f59f4db3e4..f8605b12daf 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -5,6 +5,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import type { AcpLaunchSpec } from './acp-launch-specs' import type { ConnectAcpAgent } from './acp-structured-connection' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' @@ -17,6 +18,8 @@ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** The chat's journal as it stands, read without opening it; null when it has none. */ + readJournal?: (sessionId: string) => JournalLoad | null /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ connect: ConnectAcpAgent readProcessStartTime?: (pid: number) => Promise diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index bb845eb429f..e20058bfd72 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -421,6 +421,7 @@ describe('ACP startup that never answers', () => { args: [], cwd: '/workspace/project', env: {}, + envToDelete: [], fullAccess: false, resume: null } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index b0dd9804b5e..e1fd1f31715 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -30,14 +30,15 @@ import { import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' import { waitForAcpExit } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' -import { acpPromptBlocks } from './acp-structured-turns' import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' +import { acpDispatchPrompt } from './acp-prompt-content' import { ACP_OPTION_WRITE_TIMEOUT_MS, ACP_STOP_GRACE_MS, type AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { writeAcpSessionOption } from './acp-structured-options' +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. */ @@ -46,6 +47,11 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} + /** Restart recovery's evidence; null for an agent whose own store Orca cannot read. */ + providerHistoryWindow: NonNullable = ({ + identity + }) => readAcpRecoveryHistory(this.deps, identity) + // The child runs on this runtime's own machine; Windows needs process start-time proof. supportsLocation = (location: AgentSessionExecutionLocation): boolean => supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) @@ -142,9 +148,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return this.rejected(lost, 'providerExited') } const session = this.live(input.sessionId) - const prompt = acpPromptBlocks(input.body) - if (!prompt) { - return this.rejected(session, 'attachmentInvalid') + const prompt = await acpDispatchPrompt(input.body, session) + if (!Array.isArray(prompt)) { + return { state: 'rejected', ...prompt } + } + if (this.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { + // The child ended while its attachments were read: nothing left Orca. + return this.rejected(session, 'providerExited') } await input.beforeDispatch?.() session.turns.dispatch({ @@ -312,7 +322,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte private rejected( session: AcpStructuredSession, - kind: 'providerExited' | 'attachmentInvalid' + kind: 'providerExited' ): AgentSessionDispatchOutcome { return { state: 'rejected', diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index df5495f65ed..4bbf773ea51 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -17,10 +17,7 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../shared/agent-session-journal-types' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { AcpAgentError, AcpConnectionClosedError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredLane } from './acp-structured-lane' @@ -32,19 +29,6 @@ export type AcpDispatchSettlement = { clientMessageId: string } & ( | { state: 'unknown'; reason: string } ) -/** A person's message as an ACP prompt; null when it carries what the agent cannot take. */ -export function acpPromptBlocks(body: AgentJournalMessageItem): ContentBlock[] | null { - const blocks: ContentBlock[] = [] - for (const block of body.blocks) { - if (block.type !== 'text') { - // Images wait for an ACP image path; the chat offers none while `imagePrompts` is off. - return null - } - blocks.push({ type: 'text', text: block.text }) - } - return blocks -} - type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: number } export type AcpStructuredTurnsDeps = { diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index ae9eac456e4..c611cfabbb9 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -20,7 +20,8 @@ const frameSchema = z.object({ id: z.union([z.string(), z.number()]).optional(), method: z.string().optional(), params: z.unknown().optional(), - result: z.unknown().optional() + result: z.unknown().optional(), + error: z.unknown().optional() }) }) export type AcpFixtureFrame = z.infer diff --git a/src/main/acp/acp-timeline-opencode-dialect.test.ts b/src/main/acp/acp-timeline-opencode-dialect.test.ts new file mode 100644 index 00000000000..ac1b8313f3f --- /dev/null +++ b/src/main/acp/acp-timeline-opencode-dialect.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' +import { AcpTimelineTranslator } from './acp-timeline-translator' + +afterEach(closeProviderTimelineRigs) + +async function openCodeRig() { + const rig = await openProviderTimelineRig() + const translator = new AcpTimelineTranslator({ + sessionId: 'provider-1', + dialect: OPENCODE_ACP_DIALECT + }) + const apply = (events: ProviderTimelineEvent[]) => { + for (const event of events) { + expect(rig.assembler.apply(event).admission.accepted).toBe(true) + } + } + const update = (body: Record, at: number) => + apply(translator.notification('session/update', { sessionId: 'provider-1', update: body }, at)) + apply(translator.openPrompt('send-1', 1000).events) + return { rig, translator, apply, update } +} + +// The shapes OpenCode's ACP server writes for a shell tool: its output and exit code ride under +// `rawOutput.metadata`, beside the text content. +const bashStart = { + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'List files', + kind: 'execute', + status: 'pending', + rawInput: { command: 'ls missing', description: 'List files' } +} + +describe('OpenCode over ACP', () => { + it("reads a shell tool's exit code and output from OpenCode's metadata", async () => { + const { rig, update } = await openCodeRig() + update(bashStart, 1001) + update( + { + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + rawOutput: { + output: 'ls: missing: No such file or directory\n', + metadata: { output: 'ls: missing: No such file or directory\n', exit: 1 } + } + }, + 1002 + ) + const tool = (await rig.rows()).find((row) => row.body.kind === 'tool-call')?.body + expect(tool).toMatchObject({ + kind: 'tool-call', + state: 'completed', + exitCode: 1, + output: { head: 'ls: missing: No such file or directory\n' } + }) + }) + + it('keeps a shared exit code the agent already reported', () => { + const normalized = OPENCODE_ACP_DIALECT.normalizeToolUpdate!({ + toolCallId: 'call-1', + rawOutput: { exitCode: 0, metadata: { exit: 3 } } + }) + expect(normalized.rawOutput).toEqual({ exitCode: 0, metadata: { exit: 3 } }) + }) + + it('leaves a tool with no command metadata as it came', () => { + const plain = { toolCallId: 'call-2', rawOutput: 'done' } + expect(OPENCODE_ACP_DIALECT.normalizeToolUpdate!(plain)).toBe(plain) + }) + + it('says that "always" lasts only until OpenCode restarts', async () => { + const { translator, apply, update } = await openCodeRig() + update(bashStart, 1001) + const request = translator.request( + 'session/request_permission', + { + sessionId: 'provider-1', + toolCall: { toolCallId: 'call-1', title: 'ls missing', kind: 'execute' }, + options: [ + { optionId: 'once', kind: 'allow_once', name: 'Allow once' }, + { optionId: 'always', kind: 'allow_always', name: 'Always allow' }, + { optionId: 'reject', kind: 'reject_once', name: 'Reject' } + ] + }, + 0 + ) + apply(request.events) + expect(request.presentation?.body).toMatchObject({ + options: [ + { id: 'once', label: 'Allow once' }, + { id: 'always', label: 'Allow until OpenCode restarts' }, + { id: 'reject', label: 'Reject' } + ] + }) + expect(request.presentation?.reply({ kind: 'option', optionId: 'always' })).toEqual({ + outcome: { outcome: 'selected', optionId: 'always' } + }) + }) +}) diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 176967409c9..4a3cecdf010 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -16,7 +16,10 @@ export const pendingAcpResolution = { resolvedAt: null } as const -export function acpPermissionPresentation(params: unknown): AcpRequestPresentation { +export function acpPermissionPresentation( + params: unknown, + dialect: AcpDialect +): AcpRequestPresentation { // The runtime already read this request and reported any field it dropped. const request = readAcpPermissionRequest(params, () => {}) if (!request) { @@ -28,7 +31,10 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati kind: 'approval', title: toolCall.title ?? 'Permission requested', detail: null, - options: options.map((option) => ({ id: option.optionId, label: option.name })), + options: options.map((option) => ({ + id: option.optionId, + label: dialect.permissionOptionLabel?.(option) ?? option.name + })), resolution: pendingAcpResolution }, reply: (response) => { @@ -47,6 +53,7 @@ export function acpPermissionPresentation(params: unknown): AcpRequestPresentati } const requestSessionSchema = z.object({ sessionId: z.string() }) +const UNADVERTISED_CLIENT_METHOD = /^(?:fs|terminal)\// const requestToolSchema = z.object({ toolCallId: z.string().optional(), toolCall: z.object({ toolCallId: z.string() }).optional() @@ -102,9 +109,14 @@ export function translateAcpRequest( } const presentation = method === 'session/request_permission' - ? acpPermissionPresentation(params) + ? acpPermissionPresentation(params, options.dialect) : options.dialect.request?.(method, params) if (!presentation) { + // A file system or terminal call Orca never advertised is the agent's own misstep (OpenCode 1.x + // repeats each approved edit as a file write it already made): refused, with no row. + if (UNADVERTISED_CLIENT_METHOD.test(method)) { + return { events: [] } + } return { events: [{ type: 'provider.frame', frameKind: `request:${method}`, payload: params, join }] } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 28762bc4646..f6b4a22df2f 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -19,6 +19,7 @@ import type { AcpDialect } from './acp-dialects/acp-dialect' import type { ToolCallUpdate } from './generated/acp-protocol.generated' const rawStreamsSchema = z.object({ stdout: z.string().optional(), stderr: z.string().optional() }) +const rawExitCodeSchema = z.looseObject({ exitCode: z.number().int().safe() }) function outputText(update: ToolCallUpdate): string | undefined { const text = update.content?.flatMap((block) => @@ -89,12 +90,14 @@ export class AcpToolTimeline { }) translate( - update: ToolCallUpdate, + raw: ToolCallUpdate, dialect: AcpDialect, join: ProviderTimelineJoin ): ProviderTimelineEvent[] { + const update = dialect.normalizeToolUpdate?.(raw) ?? raw const previous = this.tools.get(update.toolCallId) const output = outputText(update) + const exitCode = rawExitCodeSchema.safeParse(update.rawOutput).data?.exitCode const state = update.status === 'completed' ? 'completed' @@ -118,6 +121,7 @@ export class AcpToolTimeline { ? (previous?.body.input ?? null) : boundToolInput(update.rawInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS), state, + ...(exitCode === undefined ? {} : { exitCode }), ...(output === undefined ? previous?.body.output ? { output: previous.body.output } diff --git a/src/main/acp/fixtures/opencode-v1-deny.jsonl b/src/main/acp/fixtures/opencode-v1-deny.jsonl new file mode 100644 index 00000000000..11fd57bd211 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-deny.jsonl @@ -0,0 +1,21 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the write or edit tool to create denied.txt containing exactly DENY-ME. If permission is denied, stop and report it; do not retry via another tool."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":"The"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" user wants me to"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" create denied.txt"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" with content"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" DENY-ME. Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" me just do"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" it.\n\nBut wait -"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" there's a permission"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" system."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" Let me try the write"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" tool. If denied, stop"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cda15001TZVLpZfbqbt4B8","content":{"type":"text","text":" and report."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","title":"write","kind":"edit","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","status":"in_progress","kind":"edit","title":"write","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filePath":"/workspace/project/denied.txt","content":"DENY-ME"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","title":"/workspace/project/denied.txt","kind":"edit","status":"pending","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filepath":"/workspace/project/denied.txt","diff":"Index: /workspace/project/denied.txt\n===================================================================\n--- /workspace/project/denied.txt\n+++ /workspace/project/denied.txt\n@@ -0,0 +1,1 @@\n+DENY-ME\n\\ No newline at end of file\n"},"content":[{"type":"diff","path":"/workspace/project/denied.txt","oldText":"","newText":"DENY-ME"}]},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rmd4myxlisfqpsm7qnvhcqvm","status":"failed","kind":"edit","title":"write","locations":[{"path":"/workspace/project/denied.txt"}],"rawInput":{"filePath":"/workspace/project/denied.txt","content":"DENY-ME"},"content":[{"type":"content","content":{"type":"text","text":"The user rejected permission to use this specific tool call."}}],"rawOutput":{"error":"The user rejected permission to use this specific tool call."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10390,"size":1000000,"cost":{"amount":0.00013182,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":150,"outputTokens":87,"totalTokens":10521,"thoughtTokens":44,"cachedReadTokens":10240},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-provider-error.jsonl b/src/main/acp/fixtures/opencode-v1-provider-error.jsonl new file mode 100644 index 00000000000..612772542c5 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-provider-error.jsonl @@ -0,0 +1,2 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Reply OK."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":4,"error":{"code":-32603,"message":"Internal error: CAPTURE_PROVIDER_400","data":{"service":"session","errorName":"APIError"}}}} diff --git a/src/main/acp/fixtures/opencode-v1-stray-write.jsonl b/src/main/acp/fixtures/opencode-v1-stray-write.jsonl new file mode 100644 index 00000000000..8249b591c0e --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-stray-write.jsonl @@ -0,0 +1,30 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"First use the edit tool to change edit.txt from before to approved. After that succeeds, run bash with exactly: printf \"STRAY\\n\" > stray.txt. Do not combine these actions. If the shell write permission is denied, stop and report it."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":"Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" me first"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" read edit.txt. Actually"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" the edit"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" tool requires reading first. Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605aaa001IbEN879E3yY64j","content":{"type":"text","text":" me read it."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","title":"read","kind":"read","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","status":"in_progress","kind":"read","title":"read","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_436gprdnre54g9m10b8sx6qv","status":"completed","title":"/workspace/project/edit.txt","content":[{"type":"content","content":{"type":"text","text":"before"}}],"rawOutput":{"output":"/workspace/project/edit.txt\nfile\n\n1: before\n\n(End of file - total 1 lines)\n","metadata":{"preview":"before","truncated":false,"loaded":[],"display":{"type":"file","path":"/workspace/project/edit.txt","text":"before","lineStart":1,"lineEnd":1,"totalLines":1,"truncated":false}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":"The"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" instructions say change"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" \"before\""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_112605fae001cQnOHi1XrRfxQ4","content":{"type":"text","text":" to \"approved\"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","title":"edit","kind":"edit","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"in_progress","kind":"edit","title":"edit","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt","oldString":"before","newString":"approved"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","title":"/workspace/project/edit.txt","kind":"edit","status":"pending","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filepath":"/workspace/project/edit.txt","diff":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n"},"content":[{"type":"diff","path":"/workspace/project/edit.txt","oldText":"before\n","newText":"approved\n"}]},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":1,"method":"fs/write_text_file","params":{"sessionId":"session-1","path":"/workspace/project/edit.txt","content":"approved\n"}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: fs/write_text_file"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"in_progress","kind":"edit","title":"edit","locations":[{"path":"/workspace/project/edit.txt"}],"rawInput":{"filePath":"/workspace/project/edit.txt","oldString":"before","newString":"approved"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_eu9lzl303a1dxo1u78uj9xkc","status":"completed","title":"/workspace/project/edit.txt","content":[{"type":"content","content":{"type":"text","text":"Edit applied successfully."}},{"type":"diff","path":"/workspace/project/edit.txt","oldText":"before","newText":"approved"}],"rawOutput":{"output":"Edit applied successfully.","metadata":{"diagnostics":{},"diff":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n","filediff":{"file":"/workspace/project/edit.txt","patch":"Index: /workspace/project/edit.txt\n===================================================================\n--- /workspace/project/edit.txt\n+++ /workspace/project/edit.txt\n@@ -1,1 +1,1 @@\n-before\n+approved\n","additions":1,"deletions":1},"truncated":false}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/workspace/project"}],"rawInput":{"cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","status":"in_progress","kind":"execute","title":"printf \"STRAY\\n\" > stray.txt","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":2,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","title":"printf \"STRAY\\n\" > stray.txt","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt"}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":2,"result":{"outcome":{"outcome":"selected","optionId":"reject"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0qy5hwrmp85vvitkrmkhxm8","status":"failed","kind":"execute","title":"printf \"STRAY\\n\" > stray.txt","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"STRAY\\n\" > stray.txt","cwd":"/workspace/project"},"content":[{"type":"content","content":{"type":"text","text":"The user rejected permission to use this specific tool call."}}],"rawOutput":{"error":"The user rejected permission to use this specific tool call."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10690,"size":1000000,"cost":{"amount":0.000322062,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":194,"outputTokens":45,"totalTokens":10735,"cachedReadTokens":10496},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl b/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl new file mode 100644 index 00000000000..1c176531db2 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-subagent-hang.jsonl @@ -0,0 +1,13 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Use the Task tool to launch one general subagent. The subagent must create child.txt in this project containing CHILD-OK, using a write or edit tool. Do not perform the edit yourself. Let the subagent finish, then reply briefly. Do not launch other subagents."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":"I'll launch"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" one"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" general subagent to create the"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125d1722001x7f5Lth7HBxMQu","content":{"type":"text","text":" file."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","title":"task","kind":"think","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"in_progress","kind":"think","title":"Create child.txt","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"in_progress","kind":"think","title":"Create child.txt","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"}}}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_2d1jjspyyy65tjo00sp7d1tk","status":"failed","kind":"think","title":"task","locations":[],"rawInput":{"description":"Create child.txt","prompt":"Create a file at the absolute path /workspace/project/child.txt using the write tool. The file must contain exactly the text: CHILD-OK\n\nDo not do anything else. After writing the file, read it back to verify its contents, then report back the exact contents and the file path. Do not touch any other files.","subagent_type":"general"},"content":[{"type":"content","content":{"type":"text","text":"Task cancelled"}}],"rawOutput":{"error":"Task cancelled","metadata":{"parentSessionId":"session-1","sessionId":"session-2","model":{"modelID":"deepseek-v4.1-flash","providerID":"opencode-go"}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":0,"size":1000000,"cost":{"amount":0.00016167,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled","usage":{"inputTokens":0,"outputTokens":0,"totalTokens":0},"_meta":{}}}} diff --git a/src/main/acp/fixtures/opencode-v1-tool.jsonl b/src/main/acp/fixtures/opencode-v1-tool.jsonl new file mode 100644 index 00000000000..4fec143af03 --- /dev/null +++ b/src/main/acp/fixtures/opencode-v1-tool.jsonl @@ -0,0 +1,40 @@ +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"session-1","prompt":[{"type":"text","text":"Read README.md using the read tool. Then run the bash tool with exactly: printf \"TOOL-OK\\n\". Reply briefly with the two markers. Do not inspect any other paths."}]}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"init","description":"create/update AGENTS.md"}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" me read README.md and"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" then"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" run the bash command. Per"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" instructions, read first"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":", then bash."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" Can I batch"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"? The bash"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" says"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" run with exactly"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" printf \""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"TOOL-OK\\n\"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" Let"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" me do the"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" read first, then bash"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":".\n\nActually I can do both in"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" parallel since independent"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":". But the instruction says \""}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"Then run"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"\". Let me just"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" do them in"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" one"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":" message"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_thought_chunk","messageId":"prt_1125cb136001S5aCvasDYu18AM","content":{"type":"text","text":"."}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","title":"read","kind":"read","status":"pending","locations":[],"rawInput":{}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","status":"in_progress","kind":"read","title":"read","locations":[{"path":"/workspace/project/README.md"}],"rawInput":{"filePath":"/workspace/project/README.md"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","title":"bash","kind":"execute","status":"pending","locations":[{"path":"/workspace/project"}],"rawInput":{"cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_9bqxuxnngg6oum1tifcyejhj","status":"completed","title":"/workspace/project/README.md","content":[{"type":"content","content":{"type":"text","text":"Protocol capture scratch project. Read marker: CAPTURE-OK."}}],"rawOutput":{"output":"/workspace/project/README.md\nfile\n\n1: Protocol capture scratch project. Read marker: CAPTURE-OK.\n\n(End of file - total 1 lines)\n","metadata":{"preview":"Protocol capture scratch project. Read marker: CAPTURE-OK.","truncated":false,"loaded":[],"display":{"type":"file","path":"/workspace/project/README.md","text":"Protocol capture scratch project. Read marker: CAPTURE-OK.","lineStart":1,"lineEnd":1,"totalLines":1,"truncated":false}}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","title":"printf \"TOOL-OK\\n\"","kind":"execute","status":"pending","locations":[],"rawInput":{"command":"printf \"TOOL-OK\\n\""}},"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},{"optionId":"always","kind":"allow_always","name":"Always allow"},{"optionId":"reject","kind":"reject_once","name":"Reject"}]}}} +{"direction":"out","process":"p1","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"once"}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"in_progress","kind":"execute","title":"printf \"TOOL-OK\\n\"","locations":[{"path":"/workspace/project"}],"rawInput":{"command":"printf \"TOOL-OK\\n\"","cwd":"/workspace/project"},"content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}]}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_01_9xtun202cqouz4q8t7ss7a8v","status":"completed","title":"printf \"TOOL-OK\\n\"","content":[{"type":"content","content":{"type":"text","text":"TOOL-OK\n"}}],"rawOutput":{"output":"TOOL-OK\n","metadata":{"output":"TOOL-OK\n","exit":0,"truncated":false}}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125cb49c001tmofdzWc1JiS6i","content":{"type":"text","text":"CAPTURE-OK"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg_1125cb49c001tmofdzWc1JiS6i","content":{"type":"text","text":"\nTOOL-OK"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"usage_update","used":10669,"size":1000000,"cost":{"amount":0.00148533,"currency":"USD"}}}}} +{"direction":"in","process":"p1","message":{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":301,"outputTokens":11,"totalTokens":10680,"cachedReadTokens":10368},"_meta":{}}}} diff --git a/src/main/agent-cli-version-probe.test.ts b/src/main/agent-cli-version-probe.test.ts new file mode 100644 index 00000000000..33d9537dd4f --- /dev/null +++ b/src/main/agent-cli-version-probe.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { isStableCliVersionOnLine, probeAgentCliVersion } from './agent-cli-version-probe' + +const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess })) + +const INPUT = { program: '/opt/bin/agent', cwd: '/repo', env: { PATH: '/opt/bin' } } +const anyVersion = (): boolean => true + +function prints(stdout: string, result: Record = {}): void { + runProcess.mockResolvedValue({ + code: 0, + signal: null, + stdout, + stderr: '', + timedOut: false, + ...result + }) +} + +describe('probeAgentCliVersion', () => { + beforeEach(() => runProcess.mockReset()) + + it('asks the program for its version, bounded, where and as the launch would run it', async () => { + prints('1.18.31\n') + const seen: string[] = [] + expect(await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true))).toBe(true) + expect(seen).toEqual(['1.18.31']) + expect(runProcess).toHaveBeenCalledWith({ + ...INPUT, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + }) + + it('reads a version printed with a name or a v prefix', async () => { + prints('opencode v2.0.21\n') + const seen: string[] = [] + await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true)) + expect(seen).toEqual(['2.0.21']) + }) + + it.each([ + ['a non-zero exit', { code: 1 }], + ['a timeout', { timedOut: true }], + ['oversized output', { outputTruncated: true }] + ])('cannot tell after %s', async (_label, result) => { + prints('1.18.31\n', result) + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + }) + + it('cannot tell when the output names no version, or the program does not start', async () => { + prints('usage: agent [options]\n') + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + runProcess.mockImplementationOnce(() => { + throw new Error('spawn ENOENT') + }) + expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + }) +}) + +describe('probeAgentCliVersion refusal', () => { + beforeEach(() => runProcess.mockReset()) + + it('says why it refused, with what the binary printed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + prints('', { code: 1, stderr: 'Error: EACCES mkdir /x\n' }) + await probeAgentCliVersion(INPUT, anyVersion) + prints('2.0.21\n') + await probeAgentCliVersion(INPUT, () => false) + expect(warn.mock.calls.map(([line]) => line)).toEqual([ + '[agent-cli-version] /opt/bin/agent --version: exited 1: Error: EACCES mkdir /x', + '[agent-cli-version] /opt/bin/agent --version: 2.0.21 is not a supported release' + ]) + warn.mockRestore() + }) +}) + +describe('isStableCliVersionOnLine', () => { + const line = { major: 1, floor: '1.18.31' } + + it.each([ + ['1.18.31', true], + ['1.19.2', true], + ['1.18.30', false], + ['2.0.21', false], + ['1.18.31-beta.1', false], + ['11.0.0', false] + ])('%s -> %s', (version, supported) => { + expect(isStableCliVersionOnLine(version, line)).toBe(supported) + }) +}) diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts new file mode 100644 index 00000000000..b5f2a312a78 --- /dev/null +++ b/src/main/agent-cli-version-probe.ts @@ -0,0 +1,66 @@ +import { + hasReachedAppVersion, + isPrereleaseAppVersion, + parseCliVersion +} from '../shared/app-version' +import { runProcess, type ProcessSpec } from '../shared/child-process/run-process' + +/** One line naming why a probe said no; the binary's own words, bounded. */ +function warnRefused(program: string, why: string, output = ''): void { + const said = output.trim().replace(/\s+/g, ' ').slice(0, 200) + console.warn(`[agent-cli-version] ${program} --version: ${why}${said ? `: ${said}` : ''}`) +} + +/** Runs ` --version` on this host and asks `supports` about the version it prints. False + * when it cannot tell: a failed spawn, a non-zero exit, a timeout, or output with no version. */ +export async function probeAgentCliVersion( + input: Pick, + supports: (version: string) => boolean +): Promise { + let result + try { + result = await runProcess({ + ...input, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + } catch (error) { + warnRefused(input.program, `did not start (${String(error)})`) + return false + } + const output = `${result.stdout}\n${result.stderr}` + if (result.timedOut || result.outputTruncated || result.code !== 0) { + const why = result.timedOut + ? 'timed out' + : result.outputTruncated + ? 'printed too much' + : `exited ${result.code ?? result.signal}` + warnRefused(input.program, why, output) + return false + } + const version = parseCliVersion(result.stdout) + if (version === null) { + warnRefused(input.program, 'printed no version', output) + return false + } + if (!supports(version)) { + warnRefused(input.program, `${version} is not a supported release`) + return false + } + console.info(`[agent-cli-version] ${input.program} --version: ${version} is supported`) + return true +} + +/** A stable release on `major`'s line, at or after `floor`. */ +export function isStableCliVersionOnLine( + version: string, + line: { major: number; floor: string } +): boolean { + return ( + version.startsWith(`${line.major}.`) && + !isPrereleaseAppVersion(version) && + hasReachedAppVersion(version, line.floor) + ) +} diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 3b5794a0ba7..642e5991414 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -55,7 +55,8 @@ import { resolveAgentLaunchModeOnHost, type AgentLaunchModeReceipt, type AgentLaunchModeVocabulary, - DEFAULT_LAUNCH_VOCABULARY + DEFAULT_LAUNCH_VOCABULARY, + warnStructuredLaunchDowngrade } from './agent-launch-mode' import { AgentLaunchStructuredSessionRefusedError, @@ -212,6 +213,7 @@ function published( execution: AgentLaunchExecution, surface: AgentLaunchPublishedSurface ): AgentLaunchPublishedSurface { + warnStructuredLaunchDowngrade(execution.intent.agent, surface.receipt) execution.onSurfacePublished?.(surface) return surface } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 2a96c4f5808..be3837f97b6 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -256,6 +256,16 @@ function downgraded( } } +/** One main-log line when a launch the user's default asked to be a chat opened a terminal. */ +export function warnStructuredLaunchDowngrade( + agent: string, + receipt: AgentLaunchModeReceipt +): void { + if (receipt.mode === 'terminal' && receipt.preferred === 'structured') { + console.warn(`[agent-launch] ${agent} opened a terminal: ${receipt.reason}`) + } +} + /** The store can be missing on a runtime that never opened one; that reads as no preference. */ export function readAgentLaunchModeSettings( runtime: Pick diff --git a/src/main/claude/claude-at-rest-commands.ts b/src/main/claude/claude-at-rest-commands.ts index 02bf2fc17a0..47d4198a4fa 100644 --- a/src/main/claude/claude-at-rest-commands.ts +++ b/src/main/claude/claude-at-rest-commands.ts @@ -1,5 +1,9 @@ import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + isLegacyAgentSessionAccountHome, + requireLegacyAgentSessionAccountHome +} from '../../shared/agent-session-account-home' import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire' import { structuredSlashCommands } from '../../shared/structured-agent-session-composer' import { discoverSkills } from '../skills/discovery' @@ -45,7 +49,11 @@ export class ClaudeAtRestCommandCatalog { read = (record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined => { // Another host's folders are only readable there, so this host never answers for them. - if (record.provider !== 'claude' || !supportsClaudeStructuredLocation(record.location)) { + if ( + record.provider !== 'claude' || + !supportsClaudeStructuredLocation(record.location) || + !isLegacyAgentSessionAccountHome(record.accountHome) + ) { return undefined } const key = JSON.stringify([record.location.workspaceId, record.accountHome.path]) @@ -115,7 +123,7 @@ export class ClaudeAtRestCommandCatalog { const cwd = agentSessionPinnedLaunchDirectory(record) ?? (await this.deps.resolveWorkspacePath(record.location.workspaceId)) - const account = record.accountHome.path + const account = requireLegacyAgentSessionAccountHome(record.accountHome).path const [custom, discovered] = await Promise.all([ scanClaudeCommandFolders([join(cwd, '.claude', 'commands'), join(account, 'commands')]), (this.deps.discover ?? discoverSkills)({ diff --git a/src/main/claude/claude-structured-agent-definition.ts b/src/main/claude/claude-structured-agent-definition.ts index 991d681c988..447ecb79a3b 100644 --- a/src/main/claude/claude-structured-agent-definition.ts +++ b/src/main/claude/claude-structured-agent-definition.ts @@ -1,9 +1,9 @@ -import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CLAUDE_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isClaudeStructuredOptionKey } from './claude-structured-options' import { claudeFallbackModelOptions } from './claude-structured-session-options' -export const CLAUDE_STRUCTURED_AGENT: StructuredAgentDefinition = { +export const CLAUDE_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'claude', handleTransport: CLAUDE_STRUCTURED_HANDLE_NAMESPACE.transport, accountHomeVariable: 'CLAUDE_CONFIG_DIR', diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 508d698beeb..85bdc54a2c2 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,4 +1,5 @@ import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -231,9 +232,10 @@ export function createClaudeStructuredLaunchResolver( `claude structured sessions run on the local host, not ${record.location.executionHostId}` ) } + const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) const pinned = CLAUDE_STRUCTURED_AGENT.accountHomeVariable - if (record.accountHome.variable !== pinned) { - throw new Error(`claude sessions pin ${pinned}, not ${record.accountHome.variable}`) + if (accountHome.variable !== pinned) { + throw new Error(`claude sessions pin ${pinned}, not ${accountHome.variable}`) } // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. @@ -289,7 +291,7 @@ export function createClaudeStructuredLaunchResolver( hasTranscript: deps.hasTranscript })) // Why: without a router the home is fixed, so check it before the recheck that must stay last. - const resumedWithoutRouter = router ? undefined : await resumes(record.accountHome.path) + const resumedWithoutRouter = router ? undefined : await resumes(accountHome.path) // Last: it rechecks the account switch, which may have begun during any await above. const { command, env } = await resolveClaudeStructuredInvocation( deps, @@ -308,7 +310,7 @@ export function createClaudeStructuredLaunchResolver( ), sources ) - const launchHome = await resolveClaudeStructuredLaunchHome(router, env, record.accountHome.path) + const launchHome = await resolveClaudeStructuredLaunchHome(router, env, accountHome.path) const resumesTranscript = resumedWithoutRouter ?? (await resumes(launchHome)) return { pathToClaudeCodeExecutable: command, diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e79f3cab5d2..6f8bc901989 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -131,7 +132,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) => resolveClaudeProviderHistoryWindow({ identity: input.identity, - accountHomePath: input.accountHome.path, + accountHomePath: requireLegacyAgentSessionAccountHome(input.accountHome).path, hasLiveSession: this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId) }) diff --git a/src/main/codex/codex-structured-agent-definition.ts b/src/main/codex/codex-structured-agent-definition.ts index 317acdbd3a3..1f06c603000 100644 --- a/src/main/codex/codex-structured-agent-definition.ts +++ b/src/main/codex/codex-structured-agent-definition.ts @@ -1,8 +1,8 @@ -import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { CODEX_STRUCTURED_HANDLE_NAMESPACE } from '../../shared/agent-session-provider-handle-encoding' import { isCodexTurnOptionKey } from './codex-structured-turn-start' -export const CODEX_STRUCTURED_AGENT: StructuredAgentDefinition = { +export const CODEX_STRUCTURED_AGENT: DirectoryAccountAgentDefinition = { agent: 'codex', handleTransport: CODEX_STRUCTURED_HANDLE_NAMESPACE.transport, accountHomeVariable: 'CODEX_HOME', diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 3cc28a0a4ae..8016c938ae8 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -7,6 +7,7 @@ // for, which is how a resume becomes a fork wearing a resume's name. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' @@ -70,7 +71,8 @@ export function createCodexStructuredLaunchResolver( if (!record) { throw new Error(`no durable agent-session record for ${identity.sessionId}`) } - const { location, accountHome } = record + const { location } = record + const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome) if (record.provider !== 'codex') { throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) } diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index bc96800a5aa..dc8394e4e01 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -52,6 +52,27 @@ function openCodeSource(sessionTable = 'session'): void { } describe('managed data accounts', () => { + it('resolves a pinned OpenCode profile by ID after selection changes', async () => { + openCodeSource() + const first = (await service.add('opencode', source, 'First')).activeAccountId + if (!first) { + throw new Error('Expected selected first profile') + } + const original = service.environmentForAccount('opencode', first) + const second = (await service.add('opencode', source, 'Second')).activeAccountId + if (!second) { + throw new Error('Expected selected second profile') + } + expect(service.launchEnvironment('opencode')).toEqual( + service.environmentForAccount('opencode', second) + ) + expect(service.environmentForAccount('opencode', first)).toEqual(original) + await service.remove('opencode', first) + expect(() => service.environmentForAccount('opencode', first)).toThrow( + 'Managed account not found.' + ) + }) + it.each(['before write', 'after write', 'unrestricted'])( 'preserves credentials and original metadata when removal persistence fails %s', async (failure) => { diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 749fb023876..7335a72f269 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -152,14 +152,16 @@ export class ManagedDataAccountService { if (!state.activeAccountId) { return {} } - return this.profileEnvironment(provider, state.activeAccountId) + return this.environmentForAccount(provider, state.activeAccountId) } transcriptEnvironments(provider: ManagedDataAccountProvider): Record[] { const state = this.list(provider) const selected = state.accounts.filter((account) => account.id === state.activeAccountId) const others = state.accounts.filter((account) => account.id !== state.activeAccountId) - return [...selected, ...others].map((account) => this.profileEnvironment(provider, account.id)) + return [...selected, ...others].map((account) => + this.environmentForAccount(provider, account.id) + ) } captureOriginalEnvironment( @@ -217,7 +219,7 @@ export class ManagedDataAccountService { this.inlineAuthBaselines.clear() } - private profileEnvironment( + environmentForAccount( provider: ManagedDataAccountProvider, accountId: string ): Record { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts index d59f0d124c3..fcd20b48931 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-fingerprint.ts @@ -4,7 +4,10 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from './agent-model-catalog-store' -import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stored-agent' +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../../shared/agent-session-account-home' /** * Everything that changes which models a listing can answer with: the agent, @@ -14,22 +17,22 @@ import type { AgentSessionStoredAgent } from '../../../shared/agent-session-stor */ export type AgentModelCatalogIdentity = { agent: string - accountHomeVariable: string - accountHomePath: string /** Null on the native host; WSL distros each carry their own CLI. */ wslDistro: string | null -} +} & ( + | { accountHomeVariable: string; accountHomePath: string; accountHome?: never } + | { accountHome: AgentSessionAccountHome; accountHomeVariable?: never; accountHomePath?: never } +) export function agentModelCatalogFingerprint(identity: AgentModelCatalogIdentity): string { + const account = identity.accountHome + const parts = account + ? isLegacyAgentSessionAccountHome(account) + ? [account.variable, account.path] + : [account.kind, account.locator] + : [identity.accountHomeVariable, identity.accountHomePath] return createHash('sha256') - .update( - JSON.stringify([ - identity.agent, - identity.accountHomeVariable, - identity.accountHomePath, - identity.wslDistro ?? '' - ]) - ) + .update(JSON.stringify([identity.agent, ...parts, identity.wslDistro ?? ''])) .digest('hex') } @@ -40,8 +43,7 @@ export function agentModelCatalogIdentityForRecord( ): AgentModelCatalogIdentity { return { agent: record.provider, - accountHomeVariable: record.accountHome.variable, - accountHomePath: record.accountHome.path, + accountHome: record.accountHome, wslDistro: record.location.wslDistro } } @@ -56,7 +58,7 @@ export function agentModelCatalogFingerprintForRecord( * Native only: both structured adapters refuse non-native locations at launch. */ export function agentModelCatalogSessionAccess( store: AgentModelCatalogStore | undefined, - agent: Pick, + agent: { agent: string; accountHomeVariable: string }, accountHomePath: string | null ): AgentModelCatalogSessionAccess | undefined { if (!store || !accountHomePath) { diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 3cba2c9e6a6..b56dc291d44 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -3,6 +3,7 @@ import type { AgentSessionAccountHome, AgentSessionRecord } from '../../../shared/agent-session-record' +import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -102,7 +103,10 @@ export function createAgentModelCatalogService( if (scoped) { fingerprint = agentModelCatalogFingerprintForRecord(scoped) // Probes spawn natively; a WSL-pinned record has no host-side lister. - accountHomePath = scoped.location.wslDistro === null ? scoped.accountHome.path : null + accountHomePath = + scoped.location.wslDistro === null && isLegacyAgentSessionAccountHome(scoped.accountHome) + ? scoped.accountHome.path + : null } else { let resolved: AgentSessionAccountHome try { @@ -112,11 +116,10 @@ export function createAgentModelCatalogService( } fingerprint = agentModelCatalogFingerprint({ agent: params.agent, - accountHomeVariable: resolved.variable, - accountHomePath: resolved.path, + accountHome: resolved, wslDistro: null }) - accountHomePath = resolved.path + accountHomePath = isLegacyAgentSessionAccountHome(resolved) ? resolved.path : null } let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index f1484bf5da8..d4f7e543df5 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -77,7 +77,11 @@ function unseenHistory( } return { ...history, - items: history.items.filter((item) => !committed.has(agentJournalItemKey(item.identity))) + // An item with no identity of its own cannot be one the journal committed under it; the + // adapter bounds its window to after the journal's last accepted send instead. + items: history.items.filter( + (item) => item.identity === undefined || !committed.has(agentJournalItemKey(item.identity)) + ) } } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts index 4647ba1e0e3..91522f76efe 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -30,7 +30,9 @@ export type ProviderHistoryItem = { /** Fingerprint of the submitted payload, when the caller can compute one from * provider content. Used only to break an otherwise unique tie. */ payloadFingerprint: string | null - identity: AgentJournalItemIdentity + /** Absent when the provider keeps no identity of its own for the item: a submission it matches + * keeps Orca's (`orca:`), which is the identity its row already has. */ + identity?: AgentJournalItemIdentity } export type ProviderHistoryWindow = { @@ -90,6 +92,7 @@ export function reconcileSubmissions(input: { // A submission that already adopted a key re-matches on that key, not on the // provider's raw id — the raw id renumbers, the identity-derived key does not. Boolean(submission.providerItemId) && + item.identity !== undefined && submission.providerItemId === agentJournalItemKey(item.identity) ) claimBy( @@ -169,7 +172,7 @@ function resolveOne( clientMessageId: submission.clientMessageId, outcome: 'accepted', providerItemId: item.providerItemId, - identity: item.identity + identity: item.identity ?? { provider: 'orca', clientMessageId: submission.clientMessageId } } } if (ambiguous.has(submission.clientMessageId)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts index da783ac0749..5a1e43ce222 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-definition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-definition.ts @@ -21,3 +21,8 @@ export type StructuredAgentDefinition = AgentSessionStoredAgent & { effortDefaultsToModel: boolean } } + +/** An agent whose account is one config directory, as Claude's and Codex's are. */ +export type DirectoryAccountAgentDefinition = StructuredAgentDefinition & { + accountHomeVariable: string +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts index 15f4e6523f0..58510a7231b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts @@ -1,5 +1,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' type LaunchEnvResolver = ( provider: AgentSessionRecord['provider'] @@ -19,7 +20,9 @@ export async function pinnedAgentSessionLaunchEnv( return { launchEnv: { ...(await resolver(params.provider)), - [params.accountHome.variable]: params.accountHome.path + ...(isLegacyAgentSessionAccountHome(params.accountHome) + ? { [params.accountHome.variable]: params.accountHome.path } + : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index a726affd84d..9215c490844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' +import { agentPinsAccountHome } from '../../../shared/agent-session-account-home' export function adapterSupportsCreate( adapter: StructuredAgentSessionAdapter, @@ -31,7 +32,7 @@ export function adapterSupportsCreateIfDeclared( } /** Whether this build can start the agent of `session`: the agent is registered here, its account - * variable is that agent's own (it becomes the child's environment), and every handle it holds is + * is the kind that agent pins (it becomes the child's environment), and every handle it holds is * in the transport the agent's adapter speaks. A record failing this stays readable (tab, history); * only starting its agent is refused, by the one launch admission every agent passes through. */ export function agentDrivesSession( @@ -41,7 +42,7 @@ export function agentDrivesSession( const definition = agents.definition(session.provider) return ( definition !== null && - session.accountHome.variable === definition.accountHomeVariable && + agentPinsAccountHome(definition, session.accountHome) && session.providerHandleChain.every( ({ handle }) => handle.transport === definition.handleTransport && handle.agent === definition.agent diff --git a/src/main/native-chat/structured-agent-command-resolution.test.ts b/src/main/native-chat/structured-agent-command-resolution.test.ts index a2fc166a1a3..c8be0c644fc 100644 --- a/src/main/native-chat/structured-agent-command-resolution.test.ts +++ b/src/main/native-chat/structured-agent-command-resolution.test.ts @@ -36,6 +36,21 @@ describe('structured agent executable resolution', () => { } ) + it('reads the Command setting of any agent, and its stock binary by the name it is given', () => { + const { command } = executable() + expect( + resolveStructuredAgentCommand('opencode', { agentCmdOverrides: { opencode: `"${command}"` } }) + ).toBe(command) + expect( + resolveStructuredAgentCommand( + 'opencode', + { agentCmdOverrides: { claude: `"${command}"` } }, + { pathEnv: '/nowhere' }, + { command: 'opencode', resolve: (name) => `/stock/${name}` } + ) + ).toBe('/stock/opencode') + }) + it('resolves a basename through the configured PATH and a home-relative path through the host home', () => { const { directory, command } = executable() const basenameCommand = join(directory, 'agent-custom') diff --git a/src/main/native-chat/structured-agent-command-resolution.ts b/src/main/native-chat/structured-agent-command-resolution.ts index 9cc1f28bd01..828fd031760 100644 --- a/src/main/native-chat/structured-agent-command-resolution.ts +++ b/src/main/native-chat/structured-agent-command-resolution.ts @@ -10,7 +10,10 @@ import { } from '../../shared/tui-agent-launch-command-override' import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' -type CommandSettings = Partial> +export type StructuredAgentCommandSettings = Partial< + Pick +> +type CommandSettings = StructuredAgentCommandSettings type CommandOptions = NonNullable[1]> function resolveOverride( @@ -35,14 +38,16 @@ function spawnableOn(platform: NodeJS.Platform, command: string): boolean { } /** Re-read the existing setting for every session acquisition and catalog probe. A set Command - * that names no runnable program refuses the start rather than quietly running the stock CLI. */ + * that names no runnable program refuses the start rather than quietly running the stock CLI. + * Without one, `stock` names the agent's own binary (the agent id by default). */ export function resolveStructuredAgentCommand( - agent: 'claude' | 'codex', + agent: TuiAgent, settings: CommandSettings, - options: CommandOptions = {} + options: CommandOptions = {}, + stock: { command?: string; resolve?: typeof resolveCliCommand } = {} ): string { if (!hasExplicitTuiLaunchCommand(settings, agent)) { - return resolveCliCommand(agent, options) + return (stock.resolve ?? resolveCliCommand)(stock.command ?? agent, options) } const command = resolveOverride(agent, settings, options) if (!command || !spawnableOn(options.platform ?? process.platform, command)) { diff --git a/src/main/native-chat/structured-agent-session-create-support.ts b/src/main/native-chat/structured-agent-session-create-support.ts index e15e03a1135..dc2b40ac1e6 100644 --- a/src/main/native-chat/structured-agent-session-create-support.ts +++ b/src/main/native-chat/structured-agent-session-create-support.ts @@ -47,3 +47,24 @@ export function resolveStructuredAgentSessionCreateSupport(input: { } return { supported: true } } + +/** Which create-support check said no: where the chat would run, the installed agent (its binary + * and version on this host), or Claude's managed-account binding. */ +export type StructuredAgentSessionCreateSupportCheck = + | 'location' + | 'installed-agent' + | 'managed-account' + +/** One main-log line per create-support verdict that sends a launch to the terminal; names the + * check and never a path or environment value. */ +export function warnStructuredAgentSessionCreateUnsupported( + agent: StructuredAgentId, + support: StructuredAgentSessionCreateSupport, + check: StructuredAgentSessionCreateSupportCheck +): void { + if (!support.supported) { + console.warn( + `[structured-create-support] ${agent} unsupported: ${check} check refused (reason ${support.reason ?? 'none'})` + ) + } +} diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index 5f7ca25415d..25fadd541e6 100644 --- a/src/main/opencode-usage/opencode-database-discovery.ts +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -1,8 +1,11 @@ import { stat } from 'node:fs/promises' -import { basename, isAbsolute, join } from 'node:path' +import { basename, join } from 'node:path' import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' -import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' +import { + resolveOpenCodeDataDirectory, + resolveOpenCodeDatabasePath +} from '../opencode/opencode-data-directory' import type { OpenCodeUsageProcessedDatabase } from './types' type OpenCodeDatabaseOverride = { @@ -10,20 +13,14 @@ type OpenCodeDatabaseOverride = { path: string | null } -function getOpenCodeDatabaseOverride( - dataDirectory: string, - environment: NodeJS.ProcessEnv -): OpenCodeDatabaseOverride { +function getOpenCodeDatabaseOverride(environment: NodeJS.ProcessEnv): OpenCodeDatabaseOverride { const raw = environment.OPENCODE_DB?.trim() if (!raw) { return { isConfigured: false, path: null } } - if (raw === ':memory:') { - return { isConfigured: true, path: null } - } return { isConfigured: true, - path: isAbsolute(raw) ? raw : join(dataDirectory, raw) + path: resolveOpenCodeDatabasePath(environment) } } @@ -40,7 +37,7 @@ export async function listOpenCodeDatabases( environment: NodeJS.ProcessEnv = process.env ): Promise { const dataDirectory = resolveOpenCodeDataDirectory(environment) - const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment) + const databaseOverride = getOpenCodeDatabaseOverride(environment) if (databaseOverride.isConfigured) { if (!databaseOverride.path) { return [] diff --git a/src/main/opencode-usage/scanner-wsl-gate.test.ts b/src/main/opencode-usage/scanner-wsl-gate.test.ts index c8bb123c476..b280a8638b2 100644 --- a/src/main/opencode-usage/scanner-wsl-gate.test.ts +++ b/src/main/opencode-usage/scanner-wsl-gate.test.ts @@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../opencode/opencode-data-directory', () => ({ - resolveOpenCodeDataDirectory: mocks.resolveDataDirectory + resolveOpenCodeDataDirectory: mocks.resolveDataDirectory, + resolveOpenCodeDatabasePath: () => UNC_DATABASE })) vi.mock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/main/opencode/opencode-acp-environment.ts b/src/main/opencode/opencode-acp-environment.ts new file mode 100644 index 00000000000..2970ce317ae --- /dev/null +++ b/src/main/opencode/opencode-acp-environment.ts @@ -0,0 +1,69 @@ +import { restoreOrStripOverlayEnv } from '../../shared/agent-overlay-env' +import { getAppEnvironment } from '../../shared/app-environment' +import { isOpenCodeLegacySharedConfigDir } from './legacy-shared-config-dir' + +const ORCA_ENV_PREFIXES = ['ORCA_OPENCODE_', 'ORCA_DATA_ACCOUNT_'] +const ACCOUNT_ENV_KEYS = ['XDG_DATA_HOME', 'XDG_STATE_HOME', 'OPENCODE_DB', 'OPENCODE_AUTH_CONTENT'] + +/** + * A structured chat's OpenCode reports through the chat, never through Orca's terminal status + * plugin. Restores the user's own `OPENCODE_CONFIG_DIR` over the overlay a terminal pane would have + * (dropping it when none was recorded, or when it is the retired shared plugin directory), and drops + * every Orca OpenCode and data-account variable. Runs after the account is applied. Returns + * the keys the child must not inherit. + */ +export function scrubOpenCodeAcpEnvironment( + env: Record, + inherited: NodeJS.ProcessEnv, + userDataPath: string | null = orcaUserDataPath() +): string[] { + const view: Record = {} + for (const [key, value] of Object.entries(inherited)) { + if (value !== undefined) { + view[key] = value + } + } + Object.assign(view, env) + restoreOrStripOverlayEnv( + view, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + {} + ) + const removed: string[] = [] + if ( + view.OPENCODE_CONFIG_DIR === undefined || + (userDataPath !== null && + isOpenCodeLegacySharedConfigDir(view.OPENCODE_CONFIG_DIR, userDataPath)) + ) { + delete env.OPENCODE_CONFIG_DIR + removed.push('OPENCODE_CONFIG_DIR') + } else { + env.OPENCODE_CONFIG_DIR = view.OPENCODE_CONFIG_DIR + } + // The launch environment decides these; one it leaves unset must not come from Orca's overlay. + for (const key of ACCOUNT_ENV_KEYS) { + if (env[key] === undefined) { + removed.push(key) + } + } + for (const key of new Set([...Object.keys(env), ...Object.keys(inherited)])) { + if (ORCA_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) { + delete env[key] + removed.push(key) + } + } + return removed +} + +function orcaUserDataPath(): string | null { + try { + return getAppEnvironment().getPath('userData') + } catch { + return null + } +} diff --git a/src/main/opencode/opencode-acp-stored-messages.test.ts b/src/main/opencode/opencode-acp-stored-messages.test.ts new file mode 100644 index 00000000000..ab7f66416b6 --- /dev/null +++ b/src/main/opencode/opencode-acp-stored-messages.test.ts @@ -0,0 +1,49 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + openCodeStoredUserMessagesReader, + type OpenCodeTranscriptPageReader +} from './opencode-acp-stored-messages' + +describe('OpenCode stored user messages', () => { + it("reads the database under the chat's own home, and only its user messages", async () => { + const readPage = vi.fn(async () => ({ + items: [ + { + rowid: 1, + fingerprint: 'a', + message: { id: 'msg_1', role: 'user', blocks: [], timestamp: 5, source: 'transcript' } + }, + { + rowid: 2, + fingerprint: 'b', + message: { + id: 'msg_2', + role: 'assistant', + blocks: [], + timestamp: 6, + source: 'transcript' + } + } + ], + hasMore: false, + beforeMessageRowId: 1 + })) + const home = join('/', 'child-home') + const read = openCodeStoredUserMessagesReader(readPage) + await expect( + read({ + env: { HOME: home, USERPROFILE: home }, + providerSessionId: 'ses_1', + signal: new AbortController().signal + }) + ).resolves.toEqual([{ id: 'msg_1', blocks: [], createdAt: 5 }]) + expect(readPage).toHaveBeenCalledWith( + expect.objectContaining({ + dbPath: join(home, '.local', 'share', 'opencode', 'opencode.db'), + sessionId: 'ses_1' + }), + expect.anything() + ) + }) +}) diff --git a/src/main/opencode/opencode-acp-stored-messages.ts b/src/main/opencode/opencode-acp-stored-messages.ts new file mode 100644 index 00000000000..57e16219442 --- /dev/null +++ b/src/main/opencode/opencode-acp-stored-messages.ts @@ -0,0 +1,38 @@ +import { homedir } from 'node:os' +import { OPENCODE_TRANSCRIPT_MAX_WINDOW } from '../../shared/opencode-transcript-page-limit' +import type { AcpStoredUserMessagesReader } from '../acp/acp-recovery-history' +import { resolveOpenCodeDatabasePath } from './opencode-data-directory' +import type { readOpenCodeTranscriptPageViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' + +export type OpenCodeTranscriptPageReader = typeof readOpenCodeTranscriptPageViaWorker + +/** The latest user messages of an OpenCode session, read from the database its pinned account + * selects, through the existing bounded reader. */ +export function openCodeStoredUserMessagesReader( + readPage?: OpenCodeTranscriptPageReader +): AcpStoredUserMessagesReader { + return async ({ env, providerSessionId, signal }) => { + // The child's own home, which its account may name, not this process's. + const home = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir() + const dbPath = resolveOpenCodeDatabasePath(env, home) + if (!dbPath) { + return null + } + const read = + readPage ?? + (await import('../ai-vault/session-scanner-opencode-sqlite-worker-spawn')) + .readOpenCodeTranscriptPageViaWorker + const page = await read( + { dbPath, sessionId: providerSessionId, limit: OPENCODE_TRANSCRIPT_MAX_WINDOW }, + signal + ) + if (!page) { + return null + } + return page.items.flatMap(({ message }) => + message.role === 'user' && message.timestamp !== null + ? [{ id: message.id, blocks: message.blocks, createdAt: message.timestamp }] + : [] + ) + } +} diff --git a/src/main/opencode/opencode-data-directory.test.ts b/src/main/opencode/opencode-data-directory.test.ts index e3b9073aa17..df95f0b8ae9 100644 --- a/src/main/opencode/opencode-data-directory.test.ts +++ b/src/main/opencode/opencode-data-directory.test.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { resolveOpenCodeDataDirectory, + resolveOpenCodeDatabasePath, resolveOpenCodeStorageDirectory } from './opencode-data-directory' @@ -43,4 +44,17 @@ describe('resolveOpenCodeDataDirectory', () => { join('/users/test', '.local', 'share', 'opencode', 'storage') ) }) + + it('resolves default, relative, absolute and in-memory database selections', () => { + const environment = { XDG_DATA_HOME: '/pinned' } + expect(resolveOpenCodeDatabasePath(environment)).toBe( + join('/pinned', 'opencode', 'opencode.db') + ) + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: 'custom.db' })).toBe( + join('/pinned', 'opencode', 'custom.db') + ) + const absolute = join(process.cwd(), 'selected.db') + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: absolute })).toBe(absolute) + expect(resolveOpenCodeDatabasePath({ ...environment, OPENCODE_DB: ':memory:' })).toBeNull() + }) }) diff --git a/src/main/opencode/opencode-data-directory.ts b/src/main/opencode/opencode-data-directory.ts index b3db5598cee..5010311f788 100644 --- a/src/main/opencode/opencode-data-directory.ts +++ b/src/main/opencode/opencode-data-directory.ts @@ -1,5 +1,5 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join } from 'node:path' export function resolveOpenCodeDataDirectory( environment: NodeJS.ProcessEnv = process.env, @@ -15,3 +15,17 @@ export function resolveOpenCodeStorageDirectory( ): string { return join(resolveOpenCodeDataDirectory(environment, homeDirectory), 'storage') } + +export function resolveOpenCodeDatabasePath( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory = homedir() +): string | null { + const selection = environment.OPENCODE_DB?.trim() + if (selection === ':memory:') { + return null + } + const dataDirectory = resolveOpenCodeDataDirectory(environment, homeDirectory) + return selection && isAbsolute(selection) + ? selection + : join(dataDirectory, selection || 'opencode.db') +} diff --git a/src/main/opencode/opencode-structured-account-home.test.ts b/src/main/opencode/opencode-structured-account-home.test.ts new file mode 100644 index 00000000000..34300a3fed4 --- /dev/null +++ b/src/main/opencode/opencode-structured-account-home.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedDataAccountsState } from '../../shared/managed-account-types' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { + environmentForStructuredOpenCodeAccountHome, + resolveStructuredOpenCodeAccountHome +} from './opencode-structured-account-home' + +const first = '123e4567-e89b-42d3-a456-426614174000' +const second = '123e4567-e89b-42d3-a456-426614174001' + +function managedAccounts() { + let activeAccountId: string | null = first + let available = new Set([first, second]) + return { + list: (): ManagedDataAccountsState => ({ + accounts: [...available].map((id) => ({ id, label: id, integrations: [], createdAt: 0 })), + activeAccountId + }), + restoreOriginalEnvironment: (environment: Record) => + restoreManagedDataAccountEnvironment(environment), + environmentForAccount: (_provider: 'opencode' | 'devin', id: string) => { + if (!available.has(id)) { + throw new Error('Managed account not found.') + } + return { + XDG_DATA_HOME: `/profiles/${id}/data`, + XDG_STATE_HOME: `/profiles/${id}/state`, + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + } + }, + select: (id: string | null) => { + activeAccountId = id + }, + remove: (id: string) => { + available = new Set([...available].filter((entry) => entry !== id)) + } + } +} + +describe('structured OpenCode account binding', () => { + it('records only that the chat has no managed profile', () => { + const accounts = managedAccounts() + accounts.select(null) + expect(resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts })).toEqual({ + kind: 'opencode', + locator: { kind: 'unmanaged' } + }) + }) + + it('uses the pinned managed profile after selection changes and refuses removal', () => { + const accounts = managedAccounts() + const binding = resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }) + accounts.select(second) + const environment = environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment: { PATH: '/bin' } + }) + expect(environment.XDG_DATA_HOME).toBe(`/profiles/${first}/data`) + expect(environment.OPENCODE_DB).toBe('opencode.db') + expect(resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }).locator).toEqual({ + kind: 'managed', + managedProfileId: second + }) + accounts.remove(first) + expect(() => + environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment: {} + }) + ).toThrow('Managed account not found.') + }) + + it("passes the user's inline credentials, relative paths and database through unchanged", () => { + const accounts = managedAccounts() + accounts.select(null) + const baseEnvironment = { + PATH: '/bin', + HOME: 'relative/home', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: 'relative/state', + OPENCODE_DB: 'custom.db', + OPENCODE_AUTH_CONTENT: '{"provider":{"type":"api","key":"inline"}}' + } + const binding = resolveStructuredOpenCodeAccountHome({ managedAccounts: accounts }) + expect( + environmentForStructuredOpenCodeAccountHome(binding, { + managedAccounts: accounts, + baseEnvironment + }) + ).toEqual(baseEnvironment) + }) + + it("undoes Orca's inherited managed overlay before passing the environment through", () => { + const accounts = managedAccounts() + accounts.select(null) + const environment = environmentForStructuredOpenCodeAccountHome( + { kind: 'opencode', locator: { kind: 'unmanaged' } }, + { + managedAccounts: accounts, + baseEnvironment: { + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode', + ORCA_DATA_ACCOUNT_DATA_HOME: '/old/data', + ORCA_DATA_ACCOUNT_STATE_HOME: '/old/state', + ORCA_DATA_ACCOUNT_ORIGINAL_ENV: JSON.stringify({ + XDG_DATA_HOME: '/original/data', + XDG_STATE_HOME: null, + OPENCODE_DB: 'original.db', + OPENCODE_AUTH_CONTENT: null + }), + XDG_DATA_HOME: '/old/data', + XDG_STATE_HOME: '/old/state', + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '', + PATH: '/bin' + } + } + ) + expect(environment).toEqual({ + XDG_DATA_HOME: '/original/data', + OPENCODE_DB: 'original.db', + PATH: '/bin' + }) + }) +}) diff --git a/src/main/opencode/opencode-structured-account-home.ts b/src/main/opencode/opencode-structured-account-home.ts new file mode 100644 index 00000000000..57279a8aa05 --- /dev/null +++ b/src/main/opencode/opencode-structured-account-home.ts @@ -0,0 +1,95 @@ +import { + isLegacyAgentSessionAccountHome, + type OpenCodeAgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import { + getManagedDataAccountService, + type ManagedDataAccountService +} from '../managed-data-accounts/service' +import type { AcpAccountBinding } from '../acp/acp-account-binding' + +type AccountReader = Pick< + ManagedDataAccountService, + 'list' | 'restoreOriginalEnvironment' | 'environmentForAccount' +> + +function restoredEnvironment( + environment: NodeJS.ProcessEnv, + managedAccounts: Pick +): Record { + const copy = { ...environment } + managedAccounts.restoreOriginalEnvironment(copy) + delete copy.ORCA_DATA_ACCOUNT_PROVIDER + delete copy.ORCA_DATA_ACCOUNT_DATA_HOME + delete copy.ORCA_DATA_ACCOUNT_STATE_HOME + delete copy.ORCA_DATA_ACCOUNT_ORIGINAL_ENV + return copy +} + +function definedEnvironment( + environment: Record +): Record { + const defined: Record = {} + for (const [key, value] of Object.entries(environment)) { + if (value !== undefined) { + defined[key] = value + } + } + return defined +} + +/** Resolve the current selection on the execution host when creating a chat. */ +export function resolveStructuredOpenCodeAccountHome(input: { + managedAccounts: AccountReader +}): OpenCodeAgentSessionAccountHome { + const selected = input.managedAccounts.list('opencode').activeAccountId + if (selected) { + input.managedAccounts.environmentForAccount('opencode', selected) + return { kind: 'opencode', locator: { kind: 'managed', managedProfileId: selected } } + } + return { kind: 'opencode', locator: { kind: 'unmanaged' } } +} + +/** Resolve a saved binding against the current host, then apply it after inherited overlays. */ +export function environmentForStructuredOpenCodeAccountHome( + binding: OpenCodeAgentSessionAccountHome, + input: { + managedAccounts: Pick + baseEnvironment: Record + } +): Record { + const environment = restoredEnvironment(input.baseEnvironment, input.managedAccounts) + const locator = binding.locator + // Unmanaged: the user's own environment reaches OpenCode as-is. + if (locator.kind === 'unmanaged') { + return definedEnvironment(environment) + } + delete environment.XDG_DATA_HOME + delete environment.XDG_STATE_HOME + delete environment.OPENCODE_DB + delete environment.OPENCODE_AUTH_CONTENT + return { + ...definedEnvironment(environment), + ...input.managedAccounts.environmentForAccount('opencode', locator.managedProfileId) + } +} + +/** OpenCode's account over ACP: a managed profile, or the user's own environment. */ +export function openCodeAcpAccountBinding( + managedAccounts: () => AccountReader = getManagedDataAccountService +): AcpAccountBinding { + return { + pin: { accountLocatorKind: 'opencode' }, + resolve: async () => + resolveStructuredOpenCodeAccountHome({ managedAccounts: managedAccounts() }), + environment: (home, env) => { + if (isLegacyAgentSessionAccountHome(home)) { + throw new Error('OpenCode chat requires a pinned data account') + } + return environmentForStructuredOpenCodeAccountHome(home, { + managedAccounts: managedAccounts(), + baseEnvironment: env + }) + } + } +} diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 5fbf939d1af..ff840c755b3 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -47,6 +47,7 @@ import { } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' +import { agentSessionAccountHomesEqual } from '../../shared/agent-session-account-home' export type AgentSessionReserveRequest = { /** Host-resolved floating directory committed with the first owner reservation. */ @@ -186,8 +187,7 @@ export function applyAgentSessionReservation( if ( !agentSessionExecutionLocationsEqual(existing.location, request.location) || existing.provider !== request.provider || - existing.accountHome.variable !== request.accountHome.variable || - existing.accountHome.path !== request.accountHome.path + !agentSessionAccountHomesEqual(existing.accountHome, request.accountHome) ) { // Why: location, provider, and account are the session identity; changing one is a fork. throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 0fcdaae9532..7fd0dec42ad 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -2,7 +2,6 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support' import { resolveCommittedStructuredAgentSessionAdoptionIntent, resolveStructuredAgentSessionAdoptionForCreate @@ -11,6 +10,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' @@ -23,7 +23,7 @@ import { } from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { - agentSessionAccountHome, + isLegacyAgentSessionAccountHome, type AgentSessionAccountHome } from '../../shared/agent-session-account-home' import { @@ -37,24 +37,18 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR worktreeSelector: string, agent: StructuredAgentId ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { - const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) - return resolveStructuredAgentSessionCreateSupport({ + return resolveHostStructuredAgentCreateSupport({ agent, - location, - adapterSupportsCreate: await this.structuredAgentSupportsLocation(agent, location), + worktreeSelector, + location: await this.resolveStructuredAgentSessionLocation(worktreeSelector), + runtime: this, getSettings: () => this.requireStore().getSettings() }) } - /** The agent's own location rule, from its registration: answered without installing the host, - * and false for an agent this runtime does not register. */ - protected async structuredAgentSupportsLocation(agent: StructuredAgentId, location) { - return structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false - } - /** Where a launch of `agent` finds its account, resolved on this host by the agent's own * registration; null for an agent this runtime does not register, whose create is refused. */ - protected structuredAgentAccountHomePathResolver( + protected structuredAgentAccountHomeResolver( agent: StructuredAgentId, worktree: string, purpose: 'launch' | 'read', @@ -71,7 +65,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceTrustSettings: () => this.requireStore().getSettings() } return async ({ launchEnv, location }) => - registration.resolveAccountHomePath( + registration.resolveAccountHome( { launchEnv, location: location ?? null, @@ -160,18 +154,18 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const hostLaunchDirectory = isFloatingWorkspaceSelector(input.worktree) ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : undefined - const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( + const resolveAccountHome = this.structuredAgentAccountHomeResolver( input.agent, input.worktree, 'launch', hostLaunchDirectory ) - if (!resolveAccountHomePath) { + if (!resolveAccountHome) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHome) return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } @@ -183,18 +177,17 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR async resolveStructuredAgentAccountHome( agent: StructuredAgentId ): Promise { - const resolvePath = this.structuredAgentAccountHomePathResolver(agent, '', 'read') - if (!resolvePath) { + const resolveAccountHome = this.structuredAgentAccountHomeResolver(agent, '', 'read') + if (!resolveAccountHome) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - const definition = this.requireRegisteredStructuredAgent(agent) const launchEnv = resolveTuiAgentLaunchEnv( agent, this.requireStore().getSettings().agentDefaultEnv ) - return agentSessionAccountHome(definition, await resolvePath({ launchEnv, location: null })) + return resolveAccountHome({ launchEnv, location: null }) } protected async resolveStructuredAgentSessionIntent( @@ -205,7 +198,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR callerKey?: string resumeFrom?: { providerSessionId: string } }, - resolveAccountHomePath: (context: { + resolveAccountHome: (context: { launchEnv: NodeJS.ProcessEnv location: { executionHostId: string @@ -213,7 +206,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR workspaceId: string workspaceKind: 'folder' | 'git-worktree' } - }) => string | Promise + }) => AgentSessionAccountHome | Promise ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) // Adopting a conversation reads the agent's own transcript, which only Claude and Codex have @@ -238,21 +231,27 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR if (committedReplay) { return committedReplay } - const selectedAccountHomePath = await resolveAccountHomePath({ launchEnv, location }) + const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) + if (input.resumeFrom && !isLegacyAgentSessionAccountHome(selectedAccountHome)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under // the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name. - const adoption = input.resumeFrom - ? await resolveStructuredAgentSessionAdoptionForCreate({ - host, - settings, - agent: input.agent, - providerSessionId: input.resumeFrom.providerSessionId, - selfSessionId: input.envelope.sessionId, - selectedAccountHomePath - }) - : null + const adoption = + input.resumeFrom && isLegacyAgentSessionAccountHome(selectedAccountHome) + ? await resolveStructuredAgentSessionAdoptionForCreate({ + host, + settings, + agent: input.agent, + providerSessionId: input.resumeFrom.providerSessionId, + selfSessionId: input.envelope.sessionId, + selectedAccountHomePath: selectedAccountHome.path + }) + : null return { envelope: { sessionId: input.envelope.sessionId, @@ -263,10 +262,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR location, provider: input.agent, agent: input.agent, - accountHome: agentSessionAccountHome( - definition, - adoption ? adoption.accountHomePath : selectedAccountHomePath - ), + accountHome: + adoption && isLegacyAgentSessionAccountHome(selectedAccountHome) + ? { variable: selectedAccountHome.variable, path: adoption.accountHomePath } + : selectedAccountHome, ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 17ea276bfbb..30c97c425ec 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -255,6 +255,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, this.requireStore().getSettings().agentDefaultEnv) : {}, + resolveAgentCommandSettings: () => this.requireStore().getSettings(), // Same gate and same settings as agentSession.createSupport, re-read on every acquisition. getClaudeManagedAccountGateSettings: () => this.requireStore().getSettings(), resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), diff --git a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts index 6e313e111af..00f602c9bea 100644 --- a/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-registration-seam.test.ts @@ -63,14 +63,17 @@ describe('the structured agent registration list', () => { it("resolves an account home through the agent's registration, with the read purpose", async () => { const claude = structuredAgentRuntimeRegistration('claude')! - vi.spyOn(claude, 'resolveAccountHomePath').mockResolvedValue('/accounts/claude') + vi.spyOn(claude, 'resolveAccountHome').mockResolvedValue({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/accounts/claude' + }) const { runtime, installHost } = runtimeAt() expect(await runtime.resolveStructuredAgentAccountHome('claude')).toEqual({ variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' }) - expect(claude.resolveAccountHomePath).toHaveBeenCalledWith( + expect(claude.resolveAccountHome).toHaveBeenCalledWith( expect.objectContaining({ purpose: 'read', location: null, workspacePath: null }), expect.anything() ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts new file mode 100644 index 00000000000..20db04b9aee --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' +import type { RpcContext } from '../core' +import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' + +const [method] = STRUCTURED_AGENT_SESSION_AGENTS_METHODS + +const ensureStructuredAgentSessionHost = vi.fn(async () => { + throw new Error('the journal would not open') +}) + +function context(overrides: Partial): RpcContext { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only the caller's capabilities; the runtime stub proves it never installs the host. + return { runtime: { ensureStructuredAgentSessionHost }, ...overrides } as unknown as RpcContext +} + +describe('agentSession.agents', () => { + it("lists this build's registered agents without installing the host", async () => { + const ctx = context({}) + + const result = await method.handler({}, ctx) + + expect(result.agents.map(({ agent }) => agent)).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) + ) + expect(result.agents.map(({ agent }) => agent)).toContain('opencode') + expect(ensureStructuredAgentSessionHost).not.toHaveBeenCalled() + }) + + it('still refuses a remote client that cannot read structured sessions', async () => { + await expect( + method.handler({}, context({ clientKind: 'mobile', clientCapabilities: [] })) + ).rejects.toMatchObject({ refusal: { code: 'structured_agent_session_unsupported' } }) + await expect( + method.handler( + {}, + context({ + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }) + ) + ).resolves.toMatchObject({ agents: expect.any(Array) }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts index 6d03767d79f..b66fc6d5156 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -6,18 +6,25 @@ import type { AgentSessionAgentsResult } from '../../../../shared/agent-session-registered-agents' import { AGENT_SESSION_AGENTS_METHOD } from '../../../../shared/agent-session-registered-agents' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' import { defineMethod } from '../core' -import { requireInstalledStructuredHost } from './structured-agent-session-gate' +import { requireStructuredCapability } from './structured-agent-session-gate' import { AgentsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ defineMethod({ name: AGENT_SESSION_AGENTS_METHOD, params: AgentsParams, - handler: async (_params, ctx): Promise => ({ - agents: (await requireInstalledStructuredHost(ctx)) - .agentDefinitions() - .map(({ agent, capabilities }) => ({ agent, capabilities: { ...capabilities } })) - }) + // Read from the registrations the host is built from, as createSupport is: the answer is + // fixed for this build, so it never waits on, or fails with, installing the host. + handler: async (_params, ctx): Promise => { + requireStructuredCapability(ctx) + return { + agents: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => ({ + agent: definition.agent, + capabilities: { ...definition.capabilities } + })) + } + } }) ] diff --git a/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts new file mode 100644 index 00000000000..625d3f62fd6 --- /dev/null +++ b/src/main/runtime/structured-agent-create-support-opencode-rig.test.ts @@ -0,0 +1,131 @@ +import { chmod, mkdir, 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 { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' +import { resolveHostStructuredAgentCreateSupport } from './structured-agent-launch-support' + +// Live QA run 5 on #25845: the app launched with HOME and every XDG directory pointed into the rig, +// and OpenCode's Command and per-agent PATH both naming a private 1.18.31. The host's whole create +// check, with a real `--version` against stand-in scripts, must admit that OpenCode. + +const { loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { loginShell } +}) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +let root: string +let privateOpencode: string + +/** A stand-in `opencode` that answers `--version` with `version`; never a real agent CLI. */ +async function fakeOpencode(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'opencode') + await writeFile(file, `#!/bin/sh\necho ${version}\n`) + await chmod(file, 0o755) + return file +} + +function rigSettings(command: string, extraEnv: Record = {}) { + return { + nativeChatInheritShellEnvironment: true, + nativeChatShellEnvironmentVariables: [], + agentCmdOverrides: { opencode: command }, + agentDefaultEnv: { + opencode: { PATH: `${join(root, 'oc-prefix', 'bin')}:/usr/bin:/bin`, ...extraEnv } + } + } +} + +const managedAccounts = { + list: () => ({ accounts: [], activeAccountId: null }), + restoreOriginalEnvironment: restoreManagedDataAccountEnvironment, + environmentForAccount: () => ({}) +} + +/** OpenCode's account binding, as create resolves it: the account a new chat would pin. */ +function pinnedAccount(launchEnv: Record) { + return openCodeAcpAccountBinding(() => managedAccounts).resolve({ launchEnv }) +} + +/** `agentSession.createSupport` on this host for a local git worktree under the rig's settings. */ +function createSupport(settings: ReturnType) { + return resolveHostStructuredAgentCreateSupport({ + agent: 'opencode', + worktreeSelector: 'id:workspace-1', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + runtime: { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: async () => ({ worktree: { path: join(root, 'proj') } }) + }, + getSettings: () => ({ claudeManagedAccounts: [], activeClaudeManagedAccountId: null }) + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-opencode-rig-')) + await mkdir(join(root, 'proj')) + const homebrew = await fakeOpencode(join(root, 'homebrew'), '2.0.21') + privateOpencode = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') + const home = join(root, 'home-r7') + loginShell.env = { + PATH: `${join(homebrew, '..')}:/usr/bin:/bin`, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + XDG_STATE_HOME: join(home, '.local', 'state'), + XDG_CACHE_HOME: join(home, '.cache') + } +}) +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('OpenCode create support under the QA rig', () => { + it('admits the private 1.18.31 the Command setting names, with every XDG directory set', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}) + + expect(await createSupport(rigSettings(privateOpencode))).toEqual({ supported: true }) + expect(info).toHaveBeenCalledWith( + `[agent-cli-version] ${privateOpencode} --version: 1.18.31 is supported` + ) + }) + + it('names the check that refused a 2.x Command in the main log', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const homebrew = join(root, 'homebrew', 'opencode') + + expect(await createSupport(rigSettings(homebrew))).toEqual({ + supported: false, + reason: 'agent' + }) + expect(warn).toHaveBeenCalledWith( + '[structured-create-support] opencode unsupported: installed-agent check refused (reason agent)' + ) + }) + + it('admits inline credentials and relative folders, recording no folders for the chat', async () => { + vi.spyOn(console, 'info').mockImplementation(() => {}) + const settings = rigSettings(privateOpencode, { + OPENCODE_AUTH_CONTENT: '{}', + XDG_DATA_HOME: 'relative/data', + XDG_STATE_HOME: 'relative/state' + }) + + expect(await createSupport(settings)).toEqual({ supported: true }) + await expect(pinnedAccount(settings.agentDefaultEnv.opencode)).resolves.toEqual({ + kind: 'opencode', + locator: { kind: 'unmanaged' } + }) + }) +}) diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts new file mode 100644 index 00000000000..cf408c94f91 --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -0,0 +1,135 @@ +import { chmod, mkdir, 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 type { GlobalSettings } from '../../shared/global-settings-types' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' + +const { loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { loginShell } +}) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +type Settings = Pick< + GlobalSettings, + 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' +> & + Partial> + +let root: string +let shellBin: string +let privateBin: string + +/** A stand-in `opencode` that answers `--version` with `version`; never a real agent CLI. */ +async function fakeOpencode(dir: string, version: string): Promise { + await mkdir(dir, { recursive: true }) + const file = join(dir, 'opencode') + await writeFile(file, `#!/bin/sh\necho ${version}\n`) + await chmod(file, 0o755) + return dir +} + +function runtimeWith(settings: Settings) { + return { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: async () => ({ worktree: { path: root } }) + } +} + +function launchWith(settings: Settings) { + return createAcpStructuredLaunchResolver(acpLaunchSpecFor('opencode')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv?.opencode ?? {}, + resolveCommandSettings: () => settings, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null + } + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-opencode-probe-')) + shellBin = await fakeOpencode(join(root, 'homebrew'), '2.0.21') + privateBin = await fakeOpencode(join(root, 'oc-prefix', 'bin'), '1.18.31') + // Orca's resolved login-shell PATH, with a 2.x `opencode` first. + loginShell.env = { PATH: `${shellBin}:/usr/bin:/bin`, HOME: root } +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const base = { nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [] } + +// Real `--version` runs against stand-in scripts: the whole create check, not a mocked probe. +describe.skipIf(process.platform === 'win32')( + 'OpenCode create check and launch, real probe', + () => { + it('asks the OpenCode the per-agent PATH names, not the one first on the shell PATH', async () => { + const settings = { + ...base, + agentDefaultEnv: { opencode: { PATH: `${privateBin}:/usr/bin:/bin` } } + } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) + }) + + it('keeps 2.x first on PATH on the terminal chat when nothing points elsewhere', async () => { + const settings = { ...base, agentDefaultEnv: {} } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + false + ) + }) + + it('checks and launches the binary the Command setting names', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { opencode: join(privateBin, 'opencode') } + } + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) + }) + + it('refuses the launch with the setting named when the Command is not runnable', async () => { + const settings = { + ...base, + agentDefaultEnv: {}, + agentCmdOverrides: { opencode: join(root, 'missing', 'opencode') } + } + // Admitted, so the chat states why rather than silently opening a terminal. + expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( + true + ) + await expect(launchWith(settings)).rejects.toMatchObject({ + name: 'AgentSessionPreSpawnError', + reason: 'agentCommandNotRunnable' + }) + }) + } +) diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts new file mode 100644 index 00000000000..5151b93848b --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -0,0 +1,110 @@ +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 { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' + +const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { + const loginShell: { env: Record } = { env: {} } + return { probeAgentCliVersion: vi.fn(), loginShell } +}) +vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ + ...(await importOriginal()), + probeAgentCliVersion +})) +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => loginShell.env +})) + +let bin: string +const asked: { program: string; cwd: string; env: Record }[] = [] +function installedVersion(version: string): void { + probeAgentCliVersion.mockImplementation(async (input, supports) => { + asked.push(input) + return supports(version) + }) +} + +const settings = { + agentDefaultEnv: { opencode: { OPENCODE_EXTRA: '1' } }, + nativeChatInheritShellEnvironment: true, + nativeChatShellEnvironmentVariables: [] +} +const runtime = { + requireStore: () => ({ getSettings: () => settings }), + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repo/worktree' } })) +} + +beforeEach(async () => { + asked.length = 0 + bin = await mkdtemp(join(tmpdir(), 'orca-opencode-bin-')) + const opencode = join(bin, 'opencode') + await writeFile(opencode, '#!/bin/sh\n') + await chmod(opencode, 0o755) + loginShell.env = { PATH: bin, HOME: '/home/user' } +}) +afterEach(async () => { + probeAgentCliVersion.mockReset() + runtime.resolveRuntimeFileTarget.mockClear() + await rm(bin, { recursive: true, force: true }) +}) + +describe('structuredAgentSupportsLaunch', () => { + it('asks nothing for an agent whose location alone decides', async () => { + expect(await structuredAgentSupportsLaunch('grok', 'id:workspace-1', runtime)).toBe(true) + expect(probeAgentCliVersion).not.toHaveBeenCalled() + expect(runtime.resolveRuntimeFileTarget).not.toHaveBeenCalled() + }) + + it('refuses an installed OpenCode 2.x and allows 1.x, in the workspace with the launch env', async () => { + installedVersion('2.0.21') + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + installedVersion('1.18.31') + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(true) + expect(asked[0]).toMatchObject({ + cwd: '/repo/worktree', + env: { PATH: bin, OPENCODE_EXTRA: '1', OPENCODE_CLIENT: 'acp' } + }) + }) + + it('refuses when the version cannot be read', async () => { + probeAgentCliVersion.mockResolvedValue(false) + expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'asks about the same binary the launch then spawns', + async () => { + installedVersion('1.18.31') + await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime) + const launch = await createAcpStructuredLaunchResolver(acpLaunchSpecFor('opencode')!, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'opencode', + providerHandleChain: [], + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + }) + }, + readJournal: () => null, + resolveWorkspacePath: async () => '/repo/worktree', + resolveEnvironment: async () => loginShell.env, + resolveLaunchEnv: () => settings.agentDefaultEnv.opencode, + inheritedEnv: {} + })({ + identity: { + sessionId: 'session-alpha-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'opencode', + providerHandle: null + } + }) + expect(launch.command).toBe(join(bin, 'opencode')) + expect(asked.map(({ program }) => program)).toEqual([launch.command, launch.command]) + } + ) +}) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts new file mode 100644 index 00000000000..4831970a414 --- /dev/null +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -0,0 +1,93 @@ +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + resolveStructuredAgentSessionCreateSupport, + warnStructuredAgentSessionCreateUnsupported, + type StructuredAgentSessionCreateSupport +} from '../native-chat/structured-agent-session-create-support' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' +import { structuredAgentBaseEnvironment } from './structured-agent-shell-environment' + +type LaunchEnvironmentSettings = Pick< + GlobalSettings, + 'agentDefaultEnv' | 'nativeChatInheritShellEnvironment' | 'nativeChatShellEnvironmentVariables' +> & + Partial> + +/** The environment every agent's launch on this host starts from, for a check made before the + * session host is built. */ +async function resolveHostStructuredAgentBaseEnvironment( + settings: LaunchEnvironmentSettings +): Promise> { + return structuredAgentBaseEnvironment({ + shellEnv: await resolveLoginShellEnvironment(), + policy: nativeChatShellEnvironmentPolicy(settings) + }) +} + +/** What the check reads from the runtime asking it. */ +type LaunchSupportRuntime = { + requireStore(): { getSettings(): LaunchEnvironmentSettings } + resolveRuntimeFileTarget(selector: string): Promise<{ worktree: { path: string } }> +} + +/** The agent's own check of what is installed on this host, with the environment and Command + * setting its launch starts from, in the workspace it would launch in; true for an agent whose + * location alone decides. */ +export async function structuredAgentSupportsLaunch( + agent: string, + worktreeSelector: string, + runtime: LaunchSupportRuntime +): Promise { + const supportsLaunch = structuredAgentRuntimeRegistration(agent)?.supportsLaunch + if (!supportsLaunch) { + return true + } + const settings = runtime.requireStore().getSettings() + const env = { + ...(await resolveHostStructuredAgentBaseEnvironment(settings)), + ...(isTuiAgent(agent) ? resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv) : {}) + } + const cwd = (await runtime.resolveRuntimeFileTarget(worktreeSelector)).worktree.path + return supportsLaunch({ cwd, env, commandSettings: settings }) +} + +/** `agentSession.createSupport` on this host: every refusal it can know before spawning. The + * agent's location rule and installed-agent check from its registration, without installing the + * host, then Claude's managed-account gate. A refusal logs which check said no. */ +export async function resolveHostStructuredAgentCreateSupport(input: { + agent: StructuredAgentId + worktreeSelector: string + location: AgentSessionExecutionLocation + runtime: LaunchSupportRuntime + getSettings: () => ClaudeManagedAccountGateSettings +}): Promise { + const { agent, location } = input + const supportsLocation = + structuredAgentRuntimeRegistration(agent)?.supportsLocation(location) ?? false + const supportsLaunch = + supportsLocation && + (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) + const support = resolveStructuredAgentSessionCreateSupport({ + agent, + location, + adapterSupportsCreate: supportsLaunch, + getSettings: input.getSettings + }) + warnStructuredAgentSessionCreateUnsupported( + agent, + support, + !supportsLocation + ? 'location' + : !supportsLaunch + ? 'installed-agent' + : 'managed-account' + ) + return support +} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 5dfc2f7404d..0c3604fa733 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -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']) + ).toEqual(['codex', 'claude', 'grok', 'opencode']) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', @@ -25,6 +25,24 @@ describe('ACP agents in the runtime registrations', () => { }) }) + it('registers OpenCode on the ACP lane pinning its tagged account, with image prompts', () => { + const definition = structuredAgentRuntimeRegistration('opencode')?.definition + expect(definition).toMatchObject({ + agent: 'opencode', + handleTransport: 'acp', + accountLocatorKind: 'opencode', + capabilities: { imagePrompts: true, steering: 'queue', approvalEnforcement: 'orca' } + }) + expect(definition?.accountHomeVariable).toBeUndefined() + }) + + it('leaves OpenCode 2 on its terminal-backed chat, by agent and by installed version', () => { + expect(structuredAgentRuntimeRegistration('opencode2')).toBeNull() + expect(structuredAgentRuntimeRegistration('opencode')?.supportsLaunch).toBeTypeOf('function') + // Grok runs whatever is installed: its create asks nothing of the binary. + expect(structuredAgentRuntimeRegistration('grok')?.supportsLaunch).toBeUndefined() + }) + it('takes model and effort picks at rest, and keeps no model list of its own', () => { const resting = structuredAgentRuntimeRegistration('grok')!.definition.restingOptions expect(['model', 'effort'].map(resting.acceptsKey)).toEqual([true, true]) @@ -33,7 +51,7 @@ describe('ACP agents in the runtime registrations', () => { }) it('finds the account home on this runtime from the launch env, else the default', async () => { - const { resolveAccountHomePath } = structuredAgentRuntimeRegistration('grok')! + const { resolveAccountHome } = structuredAgentRuntimeRegistration('grok')! // Grok's resolver asks the runtime for nothing: its home is the launch env's, else the default. const unused = (): never => { throw new Error('Grok resolves its account home without the runtime') @@ -44,13 +62,18 @@ describe('ACP agents in the runtime registrations', () => { readCodexLaunchHome: unused, workspaceTrustSettings: unused } - const resolve = (launchEnv: NodeJS.ProcessEnv) => - resolveAccountHomePath( + const resolve = async (launchEnv: NodeJS.ProcessEnv) => + resolveAccountHome( { launchEnv, location: null, purpose: 'read', workspacePath: null }, services ) - await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toBe('/data/grok') - await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatch(/\.grok$/) + await expect(resolve({ GROK_HOME: '/data/grok' })).resolves.toEqual({ + variable: 'GROK_HOME', + path: '/data/grok' + }) + await expect(resolve({ GROK_HOME: 'relative/grok' })).resolves.toMatchObject({ + path: expect.stringMatching(/\.grok$/) + }) }) it('runs Grok only where this runtime supervises the child itself', () => { diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 9134b19c7e2..a3bb1543f55 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -6,13 +6,19 @@ import { supportsClaudeStructuredLocation } from '../claude/claude-structured-lo import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { + agentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' import { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent +import { + isAgentSessionPreSpawnError, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -32,14 +38,17 @@ import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtim import { resolveStructuredClaudeAccountHomePath, resolveStructuredCodexAccountHomePath, - resolveStructuredEnvAccountHomePath, type StructuredClaudeAccountHomeDeps, type StructuredCodexAccountHomeDeps } from './structured-agent-account-home' import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' import { createAcpAgentConnection } from '../acp/acp-agent-connection' -import { createAcpStructuredLaunchResolver } from '../acp/acp-structured-launch-resolution' +import { + acpLaunchVersionSupported, + createAcpStructuredLaunchResolver, + resolveAcpLaunchCommand +} from '../acp/acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' /** What an agent's adapter is built from: the open store and the runtime around it. */ @@ -86,11 +95,18 @@ export type StructuredAgentRuntimeRegistration = { createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter /** Whether this agent's chats can run at `location`; answered without building the host. */ supportsLocation: (location: AgentSessionExecutionLocation) => boolean - /** The account home a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ - resolveAccountHomePath: ( + /** Whether the agent installed on this host runs a structured chat, asked at create with the + * environment a launch starts from; absent when the location alone decides. */ + supportsLaunch?: (input: { + cwd: string + env: Record + commandSettings: StructuredAgentCommandSettings + }) => Promise + /** The account a chat of this agent pins; see `StructuredAgentAccountHomeRequest`. */ + resolveAccountHome: ( request: StructuredAgentAccountHomeRequest, services: StructuredAgentAccountHomeServices - ) => Promise + ) => Promise } function nativeChatVisualsFor(deps: StructuredAgentSessionRuntimeDeps): { @@ -177,22 +193,41 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio return { definition: acpStructuredAgentDefinition(spec), supportsLocation: (location) => supportsSupervisedProviderChildLocation(location), - resolveAccountHomePath: async ({ launchEnv }) => - resolveStructuredEnvAccountHomePath({ - launchEnv, - variable: spec.accountHomeVariable, - defaultPath: spec.defaultAccountHome - }), + ...(spec.supportsVersion + ? { + supportsLaunch: async ({ cwd, env, commandSettings }) => { + const launchEnv = { ...env, ...spec.env } + let command: string + try { + command = resolveAcpLaunchCommand(spec, launchEnv, { 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 acpLaunchVersionSupported(spec, { command, cwd, env: launchEnv }) + } + } + : {}), + resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }), createAdapter: (context) => { const { deps, store, followUps } = context + const readJournal = (sessionId: string) => + replayJournal(context.journalDatabase.db, sessionId) return new AcpStructuredSessionAdapter({ spec, + readJournal, resolveLaunch: createAcpStructuredLaunchResolver(spec, { store, - readJournal: (sessionId) => replayJournal(context.journalDatabase.db, sessionId), + readJournal, resolveWorkspacePath: deps.resolveWorkspacePath, resolveEnvironment: context.environment.resolveBaseEnvironment, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}), ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}) }), connect: (launch, options) => createAcpAgentConnection(launch, options), @@ -235,18 +270,25 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, supportsLocation: (location) => supportsCodexStructuredLocation(location), - resolveAccountHomePath: resolveCodexAccountHomePath + resolveAccountHome: async (request, services) => + agentSessionAccountHome( + CODEX_STRUCTURED_AGENT, + await resolveCodexAccountHomePath(request, services) + ) }, { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, supportsLocation: supportsClaudeStructuredLocation, - resolveAccountHomePath: async ({ launchEnv, location }, services) => - resolveStructuredClaudeAccountHomePath({ - launchEnv, - wslDistro: location?.wslDistro ?? null, - getClaudeConfigDirectory: services.getClaudeConfigDirectory - }) + resolveAccountHome: async ({ launchEnv, location }, services) => + agentSessionAccountHome( + CLAUDE_STRUCTURED_AGENT, + resolveStructuredClaudeAccountHomePath({ + launchEnv, + wslDistro: location?.wslDistro ?? null, + getClaudeConfigDirectory: services.getClaudeConfigDirectory + }) + ) }, ...ACP_LAUNCH_SPECS.map(acpRegistration) ] diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 00cd43ab018..8fcc2637d46 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -22,6 +22,7 @@ import { } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { @@ -126,6 +127,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay, for agents with no lane-specific resolver. */ resolveAgentLaunchEnv?: (agent: string) => Record + /** The settings a per-agent Command override is read from, for the same agents. */ + resolveAgentCommandSettings?: () => StructuredAgentCommandSettings /** Raw settings getter; the reader that fails closed around it is built here, in checked code. */ getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings resolveEnvironment?: () => Promise diff --git a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts index db5024bbb89..0a8c0bf005d 100644 --- a/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts +++ b/src/renderer/src/lib/agent-launch-route-registered-agents.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' +import type { GlobalSettings } from '../../../shared/global-settings-types' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { TuiAgent } from '../../../shared/tui-agent' @@ -68,8 +69,11 @@ function pairedStatus(runtimeId: string): RuntimeEnvironmentStatus { } } +type RouteSettings = Partial & + Partial> + function store( - overrides: { settings?: Partial; runtimeId?: string } = {} + overrides: { settings?: RouteSettings; runtimeId?: string } = {} ): AgentLaunchRouteStore { const state = { settings: { ...SETTINGS, ...overrides.settings }, @@ -134,3 +138,58 @@ describe('structured launch of a host-registered agent', () => { ) }) }) + +describe('OpenCode with its Command and environment set (live QA run 5)', () => { + const LISTS_OPENCODE = { + agents: [...LISTS_GROK.agents, { agent: 'opencode', capabilities: {} }] + } + // The rig's settings: a private binary named twice, which must not decide the surface. + const withOverrides = (): AgentLaunchRouteStore => + store({ + settings: { + agentCmdOverrides: { opencode: '/rig/oc-prefix/bin/opencode' }, + agentDefaultEnv: { opencode: { PATH: '/rig/oc-prefix/bin:/usr/bin:/bin' } } + } + }) + let warn: MockInstance + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warn.mockRestore() + }) + + it('opens a chat once the local host listed OpenCode', async () => { + mocks.callRuntimeRpc.mockResolvedValue(LISTS_OPENCODE) + await loadHostStructuredAgents('local', REGISTERED, null) + + expect(routeFor(withOverrides(), 'opencode')).toBe('structured-native-chat') + }) + + it('says why it opened the terminal chat and asks the host again after a failed startup read', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('host not ready')) + await loadHostStructuredAgents('local', REGISTERED, null) + mocks.callRuntimeRpc.mockResolvedValue(LISTS_OPENCODE) + + // What run 5 saw: the terminal-backed "OpenCode" chat, and no version check on the host. + expect(routeFor(withOverrides(), 'opencode')).toBe('legacy-native-chat') + expect(warn).toHaveBeenCalledWith( + '[agent-launch-route] opencode opens legacy-native-chat: agent-without-structured-session ' + + '(host local, host agents not learned, host capabilities known)' + ) + await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(2)) + // The next launch reads the list the re-ask learned. + await vi.waitFor(() => + expect(routeFor(withOverrides(), 'opencode')).toBe('structured-native-chat') + ) + }) + + it('names the settings default when new agent tabs do not open as chats', () => { + expect(routeFor(store({ settings: { openAgentTabsInChatByDefault: false } }), 'opencode')).toBe( + 'terminal-tui' + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('opencode opens terminal-tui: new-tabs-default-to-terminal') + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index eaeda98dcbc..93bc37ecb52 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -2,7 +2,8 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { prefersStructuredNativeChatByDefault, - resolveStructuredNativeChatSupport + resolveStructuredNativeChatSupport, + type StructuredNativeChatBlocker } from '../../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../../shared/tui-agent' import type { WorkspaceLaunchKind } from '../../../shared/workspace-launch-kind' @@ -70,15 +71,38 @@ export function structuredAgentLaunchSupported( ): boolean { return ( input.settings?.experimentalStructuredNativeChat === true && - resolveStructuredNativeChatSupport({ - agent: input.agent, - executionHostId: input.executionHostId, - hostCapabilities: input.hostCapabilities, - ...(input.clientCapabilities ? { clientCapabilities: input.clientCapabilities } : {}), - workspaceKind: input.workspaceKind, - projectRuntime: input.projectRuntime, - startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot, - ...(input.hostStructuredAgents ? { hostStructuredAgents: input.hostStructuredAgents } : {}) - }).supported + structuredAgentLaunchSupport(input).supported ) } + +function structuredAgentLaunchSupport(input: Omit) { + return resolveStructuredNativeChatSupport({ + agent: input.agent, + executionHostId: input.executionHostId, + hostCapabilities: input.hostCapabilities, + ...(input.clientCapabilities ? { clientCapabilities: input.clientCapabilities } : {}), + workspaceKind: input.workspaceKind, + projectRuntime: input.projectRuntime, + startsOutsideWorkspaceRoot: input.startsOutsideWorkspaceRoot, + ...(input.hostStructuredAgents ? { hostStructuredAgents: input.hostStructuredAgents } : {}) + }) +} + +/** Why a launch with structured chat turned on did not route to it; null when it did, or when + * structured chat is off. Diagnostics only: the route itself is `resolveAgentLaunchRoute`. */ +export function structuredAgentLaunchDowngrade( + input: AgentLaunchRoutingInput, + route: AgentLaunchRoute +): StructuredNativeChatBlocker | 'new-tabs-default-to-terminal' | null { + if ( + route === 'structured-native-chat' || + input.settings?.experimentalStructuredNativeChat !== true + ) { + return null + } + if (!prefersStructuredNativeChatByDefault(input.settings)) { + return 'new-tabs-default-to-terminal' + } + const support = structuredAgentLaunchSupport(input) + return support.supported ? null : support.blocker +} diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts index b2088bdbe3b..7d7ce15a221 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -13,7 +13,8 @@ vi.mock('@/lib/agent-launch-route-input', () => ({ })) vi.mock('@/lib/agent-launch-routing', () => ({ resolveAgentLaunchRoute: mocks.resolveAgentLaunchRoute, - structuredAgentLaunchSupported: mocks.structuredAgentLaunchSupported + structuredAgentLaunchSupported: mocks.structuredAgentLaunchSupported, + structuredAgentLaunchDowngrade: () => null })) vi.mock('@/lib/structured-agent-launch-settlement', () => ({ beginStructuredAgentLaunchSettlement: mocks.beginStructuredAgentLaunchSettlement diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index bbe8ae59650..25a247ca4e3 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -1,6 +1,7 @@ import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { TuiAgent } from '../../../shared/tui-agent' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { StructuredAgentSessionOwnerUnresolvedError } from '@/lib/launch-structured-agent-session' @@ -11,6 +12,7 @@ import { } from '@/lib/agent-launch-route-input' import { resolveAgentLaunchRoute, + structuredAgentLaunchDowngrade, structuredAgentLaunchSupported, type AgentLaunchRoute, type AgentLaunchRoutingInput @@ -24,6 +26,8 @@ import { } from '@/lib/structured-agent-launch-settlement' import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { relearnHostStructuredAgents } from '@/runtime/host-structured-agents' +import { awaitLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { /** The user action this launch serves, minted where that action is handled. */ @@ -171,6 +175,99 @@ export function resolveAgentSessionLaunchRoute( return resolveAgentLaunchRoute(buildAgentLaunchRouteInput(store, request)) } +/** Says in the console why a launch with structured chat on opens a terminal, and asks again a + * host whose agent list was never learned, so the next launch can open its chat. */ +function reportStructuredLaunchDowngrade( + store: AgentLaunchRouteStore, + input: AgentLaunchRoutingInput, + route: AgentLaunchRoute +): void { + const downgrade = structuredAgentLaunchDowngrade(input, route) + if (!downgrade) { + return + } + console.warn( + `[agent-launch-route] ${input.agent} opens ${route}: ${downgrade} (host ${input.executionHostId}, ` + + `host agents ${input.hostStructuredAgents ? 'listed' : 'not learned'}, ` + + `host capabilities ${input.hostCapabilities ? 'known' : 'unknown'})` + ) + if (downgrade === 'agent-without-structured-session' && !input.hostStructuredAgents) { + // Bookkeeping: a failed re-ask never touches this launch. + relearnHostStructuredAgents( + input.executionHostId, + input.hostCapabilities, + store.runtimeStatusByEnvironmentId + ).catch((error: unknown) => { + console.warn('[agent-launch-route] could not ask the host for its agents again', error) + }) + } +} + +/** What a launch's chat route is waiting to hear from its host, if anything: the host's agent + * list, or this computer's runtime capabilities while startup is still answering. */ +function structuredRouteAwaits( + input: AgentLaunchRoutingInput +): 'host-agents' | 'local-capabilities' | null { + const downgrade = structuredAgentLaunchDowngrade(input, resolveAgentLaunchRoute(input)) + if ( + downgrade === 'agent-without-structured-session' && + !input.hostStructuredAgents && + // A host that does not publish its agents has no list to wait for. + input.hostCapabilities?.includes( + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ) !== false + ) { + return 'host-agents' + } + return downgrade === 'runtime-capability-unknown' && + parseExecutionHostId(input.executionHostId)?.kind === 'local' + ? 'local-capabilities' + : null +} + +/** When a launch's chat route waits only on its host's answer (the agent list, or this + * computer's runtime capabilities during a slow startup), asks for it and resolves once it came + * or after `waitMs`, whichever is first; null when the route waits on neither. The caller then + * decides the route again. */ +export function awaitStructuredRouteHostAnswer( + store: AgentLaunchRouteStore, + request: AgentLaunchRouteArgs, + waitMs: number +): Promise | null { + const input = buildAgentLaunchRouteInput(store, request) + const awaits = structuredRouteAwaits(input) + if (!awaits) { + return null + } + let stopCapabilitiesWait = (): void => {} + const ask = async (): Promise => { + let capabilities = input.hostCapabilities + // Either route may be missing this computer's capabilities: wait for the probe that lands + // them, since one that failed at startup answers null at once. + if (capabilities === null && parseExecutionHostId(input.executionHostId)?.kind === 'local') { + const wait = awaitLocalRuntimeCapabilities() + stopCapabilitiesWait = wait.stop + capabilities = await wait.known + } + await relearnHostStructuredAgents( + input.executionHostId, + capabilities, + store.runtimeStatusByEnvironmentId + ) + } + let timer: ReturnType | undefined + const answered = ask().catch((error: unknown) => { + console.warn('[agent-launch-route] could not ask the host', error) + }) + const deadline = new Promise((resolve) => { + timer = setTimeout(resolve, waitMs) + }) + return Promise.race([answered, deadline]).finally(() => { + clearTimeout(timer) + stopCapabilitiesWait() + }) +} + /** The one place a launch route is decided. Delivery mode is fixed here too, so the settle loop * later receives exactly the prompt and mode the route was decided on. */ export function planAgentSessionLaunch( @@ -179,6 +276,7 @@ export function planAgentSessionLaunch( ): AgentSessionLaunchPlan { const input = buildAgentLaunchRouteInput(store, request) const route = resolveAgentLaunchRoute(input) + reportStructuredLaunchDowngrade(store, input, route) const executionHostId = route === 'structured-native-chat' ? parseExecutionHostId(input.executionHostId)?.id : undefined return adoptAgentSessionLaunchVerdict({ diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts new file mode 100644 index 00000000000..9e23b70db3c --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../shared/protocol-version' + +const REGISTERED: RuntimeCapability[] = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +] +const SETTINGS = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true +} + +const mocks = vi.hoisted(() => { + const state: { current: Record } = { current: {} } + return { callRuntimeRpc: vi.fn(), state } +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state.current } })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: vi.fn() })) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: vi.fn(), + getLocalRepoProjectExecutionRuntimeContext: vi.fn() +})) +vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => false })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({})) + +import { resetHostStructuredAgentsForTests } from '@/runtime/host-structured-agents' +import { + refreshLocalRuntimeCapabilities, + setLocalRuntimeCapabilitiesForTests +} from '@/runtime/local-runtime-capabilities' +import type { LaunchAgentInNewTabArgs } from './launch-agent-in-new-tab' +import type { AgentLaunchRouteArgs } from './agent-launch-route-input' +import { + HOST_ANSWER_WAIT_MS, + launchOnceHostAnswered, + routeNewTabLaunch +} from './launch-agent-in-new-tab-host-agents' + +const LISTS_OPENCODE = { + agents: ['claude', 'codex', 'opencode'].map((agent) => ({ agent, capabilities: {} })) +} +const request: AgentLaunchRouteArgs = { + agent: 'opencode', + workspace: { kind: 'git-worktree', repoId: 'repo-1' } +} +const args: LaunchAgentInNewTabArgs = { + requestId: 'request-1', + agent: 'opencode', + worktreeId: 'wt-1' +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait path only hands the startup plan back to its caller; nothing here reads its fields. +const startupPlan = { launchCommand: 'opencode' } as never + +function storeWithSettings(settings: Record) { + mocks.state.current = { + settings, + runtimeStatusByEnvironmentId: new Map(), + worktreesByRepo: {}, + folderWorkspaces: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route builder reads only the settings, host statuses and workspace rows set here; a missing member reads as absent. + return mocks.state.current as never +} + +/** Stands in for the launcher re-entered with the plan decided after the wait. */ +function fakeRelaunch() { + return vi.fn((relaunched: LaunchAgentInNewTabArgs) => ({ + surface: { kind: 'host-published' as const }, + startupPlan, + pasteDraftAfterLaunch: false, + structuredSettlement: Promise.resolve( + relaunched.agentSessionLaunchPlan?.route === 'structured-native-chat' + ? { kind: 'structured' as const, sessionId: 'session-1' } + : { kind: 'cancelled' as const, sessionId: null } + ) + })) +} + +beforeEach(() => { + resetHostStructuredAgentsForTests() + mocks.callRuntimeRpc.mockReset().mockResolvedValue(LISTS_OPENCODE) + setLocalRuntimeCapabilitiesForTests(REGISTERED) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('a new tab whose chat route waits on the host agent list', () => { + it('opens the chat on the first launch once the host lists the agent', async () => { + const store = storeWithSettings(SETTINGS) + const route = routeNewTabLaunch(store, args, request) + if (!('awaited' in route)) { + throw new Error('expected the launch to wait for the host agent list') + } + const relaunch = fakeRelaunch() + + const result = launchOnceHostAnswered(route, args, startupPlan, relaunch) + + expect(result?.surface).toEqual({ kind: 'host-published' }) + expect(relaunch).not.toHaveBeenCalled() + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'structured', + sessionId: 'session-1' + }) + expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ + requestId: undefined, + agentSessionLaunchPlan: { route: 'structured-native-chat', requestId: 'request-1' } + }) + }) + + it('decides without the list once the bounded wait runs out', async () => { + vi.useFakeTimers() + mocks.callRuntimeRpc.mockReturnValue(new Promise(() => {})) + const store = storeWithSettings(SETTINGS) + const route = routeNewTabLaunch(store, args, request) + if (!('awaited' in route)) { + throw new Error('expected the launch to wait for the host agent list') + } + const relaunch = vi.fn((_relaunched: LaunchAgentInNewTabArgs) => ({ + surface: { kind: 'local-terminal' as const, tabId: 'tab-1' }, + startupPlan, + pasteDraftAfterLaunch: false + })) + + const result = launchOnceHostAnswered(route, args, startupPlan, relaunch) + await vi.advanceTimersByTimeAsync(HOST_ANSWER_WAIT_MS - 1) + expect(relaunch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'terminal' }) + expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ + agentSessionLaunchPlan: { route: 'legacy-native-chat' } + }) + }) + + it('does not wait once the list is learned, or when structured chat is off', async () => { + const store = storeWithSettings(SETTINGS) + const first = routeNewTabLaunch(store, args, request) + if ('awaited' in first) { + await first.awaited + } + + expect(routeNewTabLaunch(store, args, request)).toMatchObject({ + plan: { route: 'structured-native-chat' } + }) + expect( + routeNewTabLaunch( + storeWithSettings({ ...SETTINGS, experimentalStructuredNativeChat: false }), + args, + request + ) + ).toMatchObject({ plan: { route: 'legacy-native-chat' } }) + }) +}) + +describe("a new tab during startup, before this computer's runtime answered", () => { + // No window bridge here, so the startup probe fails and answers null at once: the rig's case. + function startupProbeFailed(): void { + setLocalRuntimeCapabilitiesForTests(null) + } + + /** A later probe (any reader's) lands the capabilities. */ + async function capabilitiesLand(): Promise { + vi.stubGlobal('window', { + api: { runtime: { getStatus: async () => ({ capabilities: REGISTERED }) } } + }) + await refreshLocalRuntimeCapabilities() + } + + function awaitingRoute(agent: 'claude' | 'opencode') { + startupProbeFailed() + const route = routeNewTabLaunch( + storeWithSettings(SETTINGS), + { ...args, agent }, + { ...request, agent } + ) + if (!('awaited' in route)) { + throw new Error('expected the launch to wait for the runtime capabilities') + } + return route + } + + it.each(['claude', 'opencode'] as const)( + 'opens the %s chat when the capabilities arrive within the wait, after a failed probe', + async (agent) => { + const route = awaitingRoute(agent) + const relaunch = fakeRelaunch() + + const result = launchOnceHostAnswered(route, { ...args, agent }, startupPlan, relaunch) + // The failed probe has answered; the launch is still waiting for one that lands. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(relaunch).not.toHaveBeenCalled() + await capabilitiesLand() + + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'structured', + sessionId: 'session-1' + }) + expect(relaunch.mock.calls[0]?.[0]).toMatchObject({ + agentSessionLaunchPlan: { route: 'structured-native-chat' } + }) + } + ) + + it('opens the terminal chat after the cap when they never arrive, and says why', async () => { + vi.useFakeTimers() + const route = awaitingRoute('claude') + const relaunch = fakeRelaunch() + + const result = launchOnceHostAnswered( + route, + { ...args, agent: 'claude' }, + startupPlan, + relaunch + ) + await vi.advanceTimersByTimeAsync(HOST_ANSWER_WAIT_MS - 1) + expect(relaunch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + await expect(result?.structuredSettlement).resolves.toEqual({ + kind: 'cancelled', + sessionId: null + }) + expect(relaunch.mock.calls[0]?.[0].agentSessionLaunchPlan?.route).not.toBe( + 'structured-native-chat' + ) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('claude opens legacy-native-chat: runtime-capability-unknown') + ) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts new file mode 100644 index 00000000000..b78f34856d4 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts @@ -0,0 +1,91 @@ +import { useAppStore } from '@/store' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { + awaitStructuredRouteHostAnswer, + planAgentSessionLaunch, + type AgentSessionLaunchPlan, + type AgentSessionLaunchRequest +} from '@/lib/agent-session-launch-plan' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' +import type { + LaunchAgentInNewTabArgs, + LaunchAgentInNewTabResult +} from '@/lib/launch-agent-in-new-tab' + +/** How long a new tab waits for its host's answer before deciding without it; the same bound a + * local chat's admission waits. */ +export const HOST_ANSWER_WAIT_MS = 3_000 + +const DELIVERED: StructuredPromptDeliveryResult = { delivered: true, failureNotified: false } +const NOT_DELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: false } + +type AwaitingHostAnswer = { awaited: Promise; replan: () => AgentSessionLaunchPlan } + +/** The new tab's route: the caller's own plan, a plan decided now, or, when the chat route waits + * only on the host's agent list or this computer's runtime capabilities, the wait for them and + * the plan decided after. */ +export function routeNewTabLaunch( + store: Parameters[0], + args: LaunchAgentInNewTabArgs, + request: Omit +): { plan: AgentSessionLaunchPlan | undefined } | AwaitingHostAnswer { + const { requestId } = args + if (requestId === undefined) { + return { plan: args.agentSessionLaunchPlan } + } + const awaited = awaitStructuredRouteHostAnswer(store, request, HOST_ANSWER_WAIT_MS) + return awaited + ? { + awaited, + replan: () => planAgentSessionLaunch(useAppStore.getState(), { requestId, ...request }) + } + : { plan: planAgentSessionLaunch(store, { requestId, ...request }) } +} + +/** + * Opens nothing until the host answered or the wait ran out, then launches on the route decided + * with what is known: the chat, or the terminal. The first launch after startup is not sent to the + * terminal by an answer that was still loading. + */ +export function launchOnceHostAnswered( + route: AwaitingHostAnswer, + args: LaunchAgentInNewTabArgs, + startupPlan: AgentStartupPlan, + relaunch: (args: LaunchAgentInNewTabArgs) => LaunchAgentInNewTabResult +): LaunchAgentInNewTabResult { + if (args.beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { + return null + } + const opened = route.awaited.then(() => + relaunch({ + ...args, + beforeSurfaceOpen: undefined, + requestId: undefined, + agentSessionLaunchPlan: route.replan() + }) + ) + const structuredSettlement = opened.then( + (result): Promise | StructuredAgentLaunchSettlement => + !result + ? { kind: 'cancelled', sessionId: null } + : (result.structuredSettlement ?? { kind: 'terminal' }), + (error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error }) + ) + const deliversPrompt = + Boolean(args.prompt?.trim()) && (args.promptDelivery ?? 'auto-submit') !== 'draft' + return { + surface: { kind: 'host-published' }, + startupPlan, + pasteDraftAfterLaunch: false, + structuredSettlement, + ...(deliversPrompt + ? { + promptDeliveryResult: opened.then( + (result) => (result ? (result.promptDeliveryResult ?? DELIVERED) : NOT_DELIVERED), + () => NOT_DELIVERED + ) + } + : {}) + } +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts index d44efdc9351..a2c203ac25a 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts @@ -2,6 +2,7 @@ // launch-agent-in-new-tab.test.ts to keep both files within the lines budget. import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() @@ -87,6 +88,8 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ describe('launchAgentInNewTab terminal tab activation', () => { beforeEach(() => { vi.clearAllMocks() + // The local runtime has answered (without structured support), so no launch waits on it. + setLocalRuntimeCapabilitiesForTests([]) store.settings = placementSettings() mockCreateTab.mockReturnValue({ id: 'tab-1' }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index f9c41da3ef0..e9ad2fba373 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' @@ -154,6 +155,8 @@ const COMMAND_CODE_CLICK = { describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() + // The local runtime has answered (without structured support), so no launch waits on it. + setLocalRuntimeCapabilitiesForTests([]) mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 270d7b4670b..97e5b1071e9 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -28,10 +28,11 @@ import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-s import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { - planAgentSessionLaunch, - type AgentSessionLaunchPlan -} from '@/lib/agent-session-launch-plan' + launchOnceHostAnswered, + routeNewTabLaunch +} from '@/lib/launch-agent-in-new-tab-host-agents' import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' /** The user action this launch serves: minted where that action is handled, or carried by the @@ -183,20 +184,20 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent // Why first: a structured chat is created on whichever runtime owns the workspace, a paired // server included, so only a non-structured route falls through to the host-published terminal. - const plan = - args.requestId === undefined - ? args.agentSessionLaunchPlan - : planAgentSessionLaunch(store, { - requestId: args.requestId, - agent, - workspace: { kind: workspaceKind, worktreeId }, - prompt: trimmedPrompt, - promptDelivery: viewModePromptDelivery, - tuiCustomization: { cwd: initialCwd }, - initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered, - ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) - }) + const route = routeNewTabLaunch(store, args, { + agent, + workspace: { kind: workspaceKind, worktreeId }, + prompt: trimmedPrompt, + promptDelivery: viewModePromptDelivery, + tuiCustomization: { cwd: initialCwd }, + initialSessionOptions: startupPlan.sessionOptions, + onPromptDelivered, + ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) + }) + if ('awaited' in route) { + return launchOnceHostAnswered(route, args, startupPlan, launchAgentInNewTabInternal) + } + const { plan } = route if (plan?.route === 'structured-native-chat') { const structured = launchStructuredAgentFromNewTab({ plan, diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index a40a49c6b3b..2d909918b44 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -161,7 +161,10 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session' })) vi.mock('@/runtime/local-runtime-capabilities', () => ({ - readLocalRuntimeCapabilitiesOrUnknown: () => hostCapabilities + readLocalRuntimeCapabilitiesOrUnknown: () => hostCapabilities, + ensureLocalRuntimeCapabilities: async () => hostCapabilities, + // A launch made before the runtime answered hears back that it supports no structured chat. + awaitLocalRuntimeCapabilities: () => ({ known: Promise.resolve([]), stop: () => {} }) })) vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: () => @@ -391,8 +394,12 @@ describe('structured chat adoption guard on the launch path', () => { hostCapabilities = capabilities const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ requestId: 'request-5', agent: 'claude', worktreeId: 'wt-1' }) - launchAgentInNewTab({ requestId: 'request-6', agent: 'codex', worktreeId: 'wt-1' }) + const launches = [ + launchAgentInNewTab({ requestId: 'request-5', agent: 'claude', worktreeId: 'wt-1' }), + launchAgentInNewTab({ requestId: 'request-6', agent: 'codex', worktreeId: 'wt-1' }) + ] + // With no answer yet, each launch waits (bounded) for the runtime before opening its terminal. + await Promise.all(launches.map((launch) => launch?.structuredSettlement)) expect(mockCreateStructuredCodexSessionLaunchIntent).not.toHaveBeenCalled() expect(mockCreateTab).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/runtime/host-structured-agents-sync.ts b/src/renderer/src/runtime/host-structured-agents-sync.ts index f755b065db9..79db61d7e5a 100644 --- a/src/renderer/src/runtime/host-structured-agents-sync.ts +++ b/src/renderer/src/runtime/host-structured-agents-sync.ts @@ -3,12 +3,19 @@ import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shar import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { loadHostStructuredAgents, retainHostStructuredAgents } from './host-structured-agents' -import { ensureLocalRuntimeCapabilities } from './local-runtime-capabilities' +import { + loadHostStructuredAgents, + readHostStructuredAgentsForRuntime, + retainHostStructuredAgents +} from './host-structured-agents' +import { + ensureLocalRuntimeCapabilities, + subscribeLocalRuntimeCapabilitiesKnown +} from './local-runtime-capabilities' import { localStructuredChatsInUse } from './local-structured-chats' -// Why gated: asking a host for its agents installs its structured host, which a profile that never -// uses structured chat must not pay for; only with chats in use can a launch route to one. +// Why gated: a host older than this build installs its structured host to answer, which a profile +// that never uses structured chat must not pay for; only with chats in use can a launch route to one. function structuredChatSettingOn(state: Pick): boolean { return state.settings?.experimentalStructuredNativeChat === true } @@ -38,14 +45,21 @@ function syncPairedHosts(statuses: AppState['runtimeStatusByEnvironmentId']): vo } /** Reads each host's registered agents once its status says it publishes them and structured chat - * is in use, and again when a paired host's runtime changes. Returns the unsubscribe. */ + * is in use, and again when a paired host's runtime changes. The local host is read when the chat + * setting turns on and whenever its capabilities land, so a read made before they were known is + * made again. Returns the unsubscribe. */ export function installHostStructuredAgentsSync(): () => void { const initial = useAppStore.getState() void syncLocalHost(initial.settings) if (structuredChatSettingOn(initial)) { syncPairedHosts(initial.runtimeStatusByEnvironmentId) } - return useAppStore.subscribe((state, previousState) => { + const stopLocal = subscribeLocalRuntimeCapabilitiesKnown(() => { + if (!readHostStructuredAgentsForRuntime(LOCAL_EXECUTION_HOST_ID, null)) { + void syncLocalHost(useAppStore.getState().settings) + } + }) + const stopStore = useAppStore.subscribe((state, previousState) => { const on = structuredChatSettingOn(state) const turnedOn = on && !structuredChatSettingOn(previousState) if (turnedOn) { @@ -59,6 +73,10 @@ export function installHostStructuredAgentsSync(): () => void { syncPairedHosts(state.runtimeStatusByEnvironmentId) } }) + return () => { + stopLocal() + stopStore() + } } export function useHostStructuredAgentsSync(): void { diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts index 41166e2c0e9..63ec2c2dd88 100644 --- a/src/renderer/src/runtime/host-structured-agents.test.ts +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => { return { callRuntimeRpc: vi.fn(), ensureLocalRuntimeCapabilities: vi.fn(), + capabilitiesKnown: new Set<(capabilities: readonly string[]) => void>(), state, listeners: new Set<(state: StoreState, previous: StoreState) => void>() } @@ -27,7 +28,11 @@ const mocks = vi.hoisted(() => { vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('./local-runtime-capabilities', () => ({ - ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities + ensureLocalRuntimeCapabilities: mocks.ensureLocalRuntimeCapabilities, + subscribeLocalRuntimeCapabilitiesKnown: (listener: (capabilities: readonly string[]) => void) => { + mocks.capabilitiesKnown.add(listener) + return () => mocks.capabilitiesKnown.delete(listener) + } })) vi.mock('./local-structured-chats', () => ({ localStructuredChatsInUse: async (settings: StoreState['settings']) => @@ -95,6 +100,7 @@ beforeEach(() => { resetHostStructuredAgentsForTests() mocks.state = { runtimeStatusByEnvironmentId: new Map(), settings: ON } mocks.listeners.clear() + mocks.capabilitiesKnown.clear() mocks.callRuntimeRpc.mockReset().mockResolvedValue(GROK_LIST) mocks.ensureLocalRuntimeCapabilities.mockReset().mockResolvedValue(REGISTERED) }) @@ -176,6 +182,19 @@ describe('host structured agents', () => { await vi.waitFor(() => expect(agentIds('runtime:env-1')).toEqual(['claude', 'grok'])) }) + it("reads the local host's agents once its capabilities land, when startup asked too early", async () => { + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue(null) + uninstall = installHostStructuredAgentsSync() + await Promise.resolve() + await Promise.resolve() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + mocks.ensureLocalRuntimeCapabilities.mockResolvedValue(REGISTERED) + mocks.capabilitiesKnown.forEach((listener) => listener(REGISTERED)) + + await vi.waitFor(() => expect(agentIds('local')).toEqual(['claude', 'grok'])) + }) + it('leaves a host unlearned when its reply is not an agent list', async () => { mocks.callRuntimeRpc.mockResolvedValue({ unexpected: true }) uninstall = installHostStructuredAgentsSync() diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts index 4196adff033..cd290e66a5d 100644 --- a/src/renderer/src/runtime/host-structured-agents.ts +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -7,6 +7,7 @@ import { parseExecutionHostId } from '../../../shared/execution-host' import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import { ensureLocalRuntimeCapabilities } from './local-runtime-capabilities' import { callRuntimeRpc } from './runtime-rpc-client' /** @@ -117,6 +118,25 @@ export function loadHostStructuredAgents( return read } +/** Asks a host again when a launch found its agents unlearned. The startup read runs once per + * runtime, so without this a read that failed, or was skipped while the local runtime's + * capabilities were unknown, kept that host's agents off the chat route for the whole session. */ +export async function relearnHostStructuredAgents( + executionHostId: string, + hostCapabilities: readonly string[] | null | undefined, + statuses: HostStructuredAgentsStatuses +): Promise { + const runtimeId = currentRuntimeId(executionHostId, statuses) + if (runtimeId === undefined || readHostStructuredAgentsForRuntime(executionHostId, runtimeId)) { + return + } + const capabilities = + runtimeId === null && !hostCapabilities + ? await ensureLocalRuntimeCapabilities() + : hostCapabilities + await loadHostStructuredAgents(executionHostId, capabilities, runtimeId) +} + /** Drops hosts no longer paired; a stale runtime is already ignored by the reader. */ export function retainHostStructuredAgents(executionHostIds: ReadonlySet): void { let removed = false diff --git a/src/renderer/src/runtime/local-runtime-capabilities.ts b/src/renderer/src/runtime/local-runtime-capabilities.ts index f4c83880503..39ff41d84ff 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.ts @@ -4,6 +4,18 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' // different answers, and a caller that routes on them must be able to tell them apart. let localRuntimeCapabilities: readonly RuntimeCapability[] | null = null let refreshPromise: Promise | null = null +const knownListeners = new Set<(capabilities: readonly RuntimeCapability[]) => void>() + +/** Called each time a probe lands an answer, so work that needs the capabilities runs when they + * become known instead of being skipped while they were not. Returns the unsubscribe. */ +export function subscribeLocalRuntimeCapabilitiesKnown( + listener: (capabilities: readonly RuntimeCapability[]) => void +): () => void { + knownListeners.add(listener) + return () => { + knownListeners.delete(listener) + } +} export function readLocalRuntimeCapabilities(): readonly RuntimeCapability[] { return localRuntimeCapabilities ?? [] @@ -34,6 +46,27 @@ export async function ensureLocalRuntimeCapabilities(): Promise< return localRuntimeCapabilities } +/** Resolves with the capabilities once a probe lands them, probing once now; a failed probe does + * not end the wait, since the next probe anyone makes still answers it. `stop` drops the wait. */ +export function awaitLocalRuntimeCapabilities(): { + known: Promise + stop: () => void +} { + let stop = (): void => {} + const known = new Promise((resolve) => { + if (localRuntimeCapabilities !== null) { + resolve(localRuntimeCapabilities) + return + } + stop = subscribeLocalRuntimeCapabilitiesKnown((capabilities) => { + stop() + resolve(capabilities) + }) + void refreshLocalRuntimeCapabilities() + }) + return { known, stop: () => stop() } +} + /** `refreshLocalRuntimeCapabilities` is not `async`, so a missing or broken preload bridge would * throw synchronously out of it instead of settling into the unknown state its catch owns. */ function startLocalRuntimeCapabilityProbe(): ReturnType { @@ -47,8 +80,10 @@ function startLocalRuntimeCapabilityProbe(): ReturnType { refreshPromise ??= startLocalRuntimeCapabilityProbe() .then((status) => { - localRuntimeCapabilities = [...(status.capabilities ?? [])] - return localRuntimeCapabilities + const capabilities = [...(status.capabilities ?? [])] + localRuntimeCapabilities = capabilities + knownListeners.forEach((listener) => listener(capabilities)) + return capabilities }) .catch(() => { // Stays unknown rather than becoming an empty (== unsupported) list: a failed probe diff --git a/src/shared/agent-session-account-home.test.ts b/src/shared/agent-session-account-home.test.ts index 1986ae112d3..bb3778c80e6 100644 --- a/src/shared/agent-session-account-home.test.ts +++ b/src/shared/agent-session-account-home.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { agentSessionAccountHome } from './agent-session-account-home' +import { + agentSessionAccountHome, + agentSessionAccountHomesEqual +} from './agent-session-account-home' import { isPersistedAgentSessionRecord } from './agent-session-record' import { agentSessionRecordFixture } from './agent-session-record.test-fixture' import { encodeAgentSessionRecord } from './agent-session-record-stored-form' @@ -25,4 +28,36 @@ describe('agent session account home', () => { expect(withVariable(malformed)).toBe(false) } }) + + it('validates tagged OpenCode locators, keeping ones a newer build added fields to', () => { + const record = encodeAgentSessionRecord(agentSessionRecordFixture()) + const managed = { + kind: 'opencode', + locator: { kind: 'managed', managedProfileId: '123e4567-e89b-42d3-a456-426614174000' } + } as const + const unmanaged = { kind: 'opencode', locator: { kind: 'unmanaged' } } as const + expect(isPersistedAgentSessionRecord({ ...record, accountHome: managed })).toBe(true) + expect(isPersistedAgentSessionRecord({ ...record, accountHome: unmanaged })).toBe(true) + expect(agentSessionAccountHomesEqual(managed, unmanaged)).toBe(false) + expect(agentSessionAccountHomesEqual(managed, { ...managed })).toBe(true) + expect(agentSessionAccountHomesEqual(unmanaged, { ...unmanaged })).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { ...managed, path: '/fake/home' } + }) + ).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { ...unmanaged, locator: { ...unmanaged.locator, channel: 'beta' } } + }) + ).toBe(true) + expect( + isPersistedAgentSessionRecord({ + ...record, + accountHome: { ...unmanaged, locator: { kind: 'detached' } } + }) + ).toBe(false) + }) }) diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts index 1f899edad82..21698ef182a 100644 --- a/src/shared/agent-session-account-home.ts +++ b/src/shared/agent-session-account-home.ts @@ -1,45 +1,132 @@ -/** - * The account an agent session is pinned to: the agent's config directory, and the environment - * variable that points the agent at it. - * - * The variable is the agent's own (Claude reads `CLAUDE_CONFIG_DIR`, Codex `CODEX_HOME`), declared - * by its definition, so a record stores it beside the path. Stored values are exactly what older - * builds wrote and read. - */ +/** Durable account binding: existing single-directory records or OpenCode's data context. */ import type { AgentSessionStoredAgent } from './agent-session-stored-agent' /** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ -export type AgentSessionAccountHome = { +export type LegacyAgentSessionAccountHome = { /** Environment variable naming the agent's config directory. */ variable: string /** Host-resolved absolute path in the execution host's own path syntax. */ path: string } +export type OpenCodeAccountLocator = + | { kind: 'managed'; managedProfileId: string } + // The user's own environment, read at every launch. + | { kind: 'unmanaged' } + +export type OpenCodeAgentSessionAccountHome = { + kind: 'opencode' + locator: OpenCodeAccountLocator +} + +export type AgentSessionAccountHome = + | LegacyAgentSessionAccountHome + | OpenCodeAgentSessionAccountHome + +export function isLegacyAgentSessionAccountHome( + home: AgentSessionAccountHome +): home is LegacyAgentSessionAccountHome { + return !('kind' in home) +} + +export function requireLegacyAgentSessionAccountHome( + home: AgentSessionAccountHome +): LegacyAgentSessionAccountHome { + if (!isLegacyAgentSessionAccountHome(home)) { + throw new Error('Expected a single-directory agent account binding.') + } + return home +} + +export function agentSessionAccountHomesEqual( + left: AgentSessionAccountHome, + right: AgentSessionAccountHome +): boolean { + if (isLegacyAgentSessionAccountHome(left)) { + return ( + isLegacyAgentSessionAccountHome(right) && + left.variable === right.variable && + left.path === right.path + ) + } + if (isLegacyAgentSessionAccountHome(right)) { + return false + } + if (left.locator.kind === 'managed') { + return ( + right.locator.kind === 'managed' && + left.locator.managedProfileId === right.locator.managedProfileId + ) + } + return right.locator.kind === 'unmanaged' +} + /** The account home of `agent` at `path`. */ export function agentSessionAccountHome( - agent: Pick, + agent: { accountHomeVariable: string }, path: string -): AgentSessionAccountHome { +): LegacyAgentSessionAccountHome { return { variable: agent.accountHomeVariable, path } } +/** Whether `home` is the kind of account `agent` pins: its own directory variable, or its locator + * kind. It becomes the child's environment, so a record pinning another agent's kind never starts. */ +export function agentPinsAccountHome( + agent: AgentSessionStoredAgent, + home: AgentSessionAccountHome +): boolean { + return isLegacyAgentSessionAccountHome(home) + ? agent.accountHomeVariable !== undefined && home.variable === agent.accountHomeVariable + : agent.accountLocatorKind === home.kind +} + +function isBoundedAccountString(value: unknown, max: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= max && + !value.includes('\u0000') + ) +} + const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ export const MAX_PATH_LENGTH = 4096 +const PROFILE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i /** Shape only: whether the variable is the one the record's agent pins is a launch-time question - * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */ + * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. Fields a + * newer build adds are ignored, so its records stay readable here. */ export function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { - if (typeof value !== 'object' || value === null || !('variable' in value) || !('path' in value)) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false } - const { variable, path } = value - return ( - typeof variable === 'string' && - ENVIRONMENT_VARIABLE_NAME.test(variable) && - typeof path === 'string' && - path.length > 0 && - path.length <= MAX_PATH_LENGTH - ) + if (!('kind' in value)) { + return ( + 'variable' in value && + typeof value.variable === 'string' && + ENVIRONMENT_VARIABLE_NAME.test(value.variable) && + 'path' in value && + isBoundedAccountString(value.path, MAX_PATH_LENGTH) + ) + } + if ( + value.kind !== 'opencode' || + !('locator' in value) || + typeof value.locator !== 'object' || + value.locator === null || + Array.isArray(value.locator) || + !('kind' in value.locator) + ) { + return false + } + const locator = value.locator + if (locator.kind === 'managed') { + return ( + 'managedProfileId' in locator && + typeof locator.managedProfileId === 'string' && + PROFILE_ID.test(locator.managedProfileId) + ) + } + return locator.kind === 'unmanaged' } diff --git a/src/shared/agent-session-stored-agent.ts b/src/shared/agent-session-stored-agent.ts index a500a604de7..1e18d960c85 100644 --- a/src/shared/agent-session-stored-agent.ts +++ b/src/shared/agent-session-stored-agent.ts @@ -8,6 +8,16 @@ export type AgentSessionStoredAgent = { agent: string /** The protocol whose id space this agent's provider handles live in. */ handleTransport: AgentSessionProviderTransport - /** Environment variable naming the agent's config directory, pinned as the record's account home. */ - accountHomeVariable: string -} +} & ( + | { + /** Environment variable naming the agent's config directory, pinned as the record's account home. */ + accountHomeVariable: string + accountLocatorKind?: never + } + | { + /** The agent's account is more than one directory: its records pin a tagged locator of this + * kind instead (`AgentSessionAccountHome`). */ + accountLocatorKind: 'opencode' + accountHomeVariable?: never + } +) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index b4eeb14f301..f24ea722450 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -1,3 +1,5 @@ +import { expect } from 'vitest' + export const SESSION = 'session-alpha' export const WORKSPACE = 'workspace-1' export const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' @@ -181,10 +183,12 @@ export const STRUCTURED_CALLS: { // Teardown runs through the runtime's subscription registry rather than the // host, so its reply is the only signal that the gate opened. { method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } }, - // The host's registered agents, each with its declared capability record. + // The host's registered agents, each with its declared capability record. Builds before + // #25845 read them from the installed host; later ones from the registrations that host is + // built from, so they answer without installing it. Either way Codex is listed. { method: 'agentSession.agents', - hostMethod: 'agentDefinitions', - result: { agents: [{ agent: 'codex', capabilities: { compact: true } }] } + hostMethod: null, + result: { agents: expect.arrayContaining([expect.objectContaining({ agent: 'codex' })]) } } ]