From 573ff0bca3a7c4f4fdfb431a5c853c2e63d1d04d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:05:35 -0700 Subject: [PATCH] Save a new chat's first message during create (#26332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): the host owns the send queue; the window keeps no saved outbox The desktop kept each structured chat's unsent messages in localStorage and sent them in order, so one message whose fate was unknown froze every later send, Retry dropped it silently, failed sends could not be discarded, and an offline chat could send hours later. The host already records every message and owns the queue; the window now only sends. - One in-memory sender for composer, launch prompts and messages sent from outside the chat. One send in flight per chat; a transport failure resends the same id for up to 30 s; a refusal that proves nothing was recorded puts the text back in the composer with the reason; a send that went out and was never answered shows an in-doubt line with Send again and holds nothing up. - The host's "unknown" rows get the same in-doubt line and Send again, from the journal, in every window. - A message an older build left in localStorage is never sent: the host's conversation outline decides what goes back to the composer, and the copy is deleted once that is saved. * fix(native-chat): send through the structured chat RPC wrapper, with its timeouts The sender called the runtime RPC directly, skipping the per-method timeouts every other structured chat call gets. Only a remote host's request skips the compatibility check the sender already ran. * fix(native-chat): an unconfirmed send goes back to the composer, with no new row line The common pattern draws nothing extra on a message whose delivery is in doubt once its turn is over, and puts a failed send's text back in the composer with the reason. So a send nobody answered in time, or one a host answers in a way that proves nothing, goes back to the composer worded as unconfirmed, and a host "unknown" row shows nothing extra. Removes the in-doubt phase, Send again, and its strings. A host's made-up row for an id its journal lost now reads as unconfirmed, not as recorded, so that message comes back instead of vanishing. * test(native-chat): pin that nothing resends a send given back as unconfirmed * fix(native-chat): sends survive a tab close, never resend after a Stop, and keep remote images - A normal tab close lets sends on their way settle; what the host never took goes back to the conversation's draft. Only a cancelled launch or a worktree purge drops them. - After any Stop, a send already on its way is never sent again under its id: a doubtful answer, or a resend that was due, hands its text back worded as unconfirmed. - A returned image keeps the SSH connection it lives on; the connection never goes to the host. - An older build's saved message the host recorded and then rejected is left to the host's own row, never handed back. * fix(native-chat): a Stop or a tab close never resends a send already out, and a kept card is the card's A Stop that landed while a same-id resend was being readied (its timer fired, its request not out yet) let that resend go out after the Stop. The sender now tracks whether a request is awaiting its answer: a Stop hands back every send between attempts as unconfirmed, lets one whose request is out settle from its answer, and never issues a request after it. A normal tab close withdraws the same way instead of doing nothing, so nothing more goes out and nothing is dropped. A send the host rejected but kept as a card (keptAsQueuedMessageId) is the card's, from its reply or the journal, and never goes back to the composer. Adds the freeze tests: a send whose fate is unknown holds later sends no longer than its deadline, and one the host can neither confirm nor deny releases the next at once. * fix(native-chat): read a resend's turned-away call or reused id as unproven Ports the send-answer proof contract. A call the host turned away before running it (method_not_found, invalid_argument, unauthorized) proves only that this request wrote nothing, so it reads as never sent on a first attempt only; on a resend an earlier attempt may have landed, and it goes again under the same id. A resent id the host says was already used for other content (messageIdReused) proves nothing either, like an expired or conflicting id. Pins the rest of the contract: any row the host returns is its own, a thrown error is no answer whatever its code, and an older build's refused, held or outlived-Stop copy is handed back once and never sent. * refactor(native-chat): type the structured composer's send with its attachment type Keeps NativeChatStructuredSession.tsx within the file length limit. * refactor(native-chat): drop the kept-card guards the sender never needed A kept send's row is a rejection that was never a Stop's, so the sender already reads it as recorded, from its reply or the journal. The test that pins it stays; the two extra checks only covered a kept row that is also a withdrawal, which the host never writes. * test(native-chat): route launch tests' sends through the client wrapper the sender calls The sender sends through callStructuredAgentSession, but these launch tests replaced that module with a factory that answered nothing (and still named a probe that no longer exists), so every launch prompt resent until its 30 s deadline and the tests timed out. Each factory now hands sends to the runtime RPC mock the tests already answer, and expectations of a local send no longer ask for the remote-only compatibility option. * fix(native-chat): hand a message back without importing the composer's attachment hook The worktree purge reaches the launch prompt, which hands text back, and the attachment hook's imports reach the store. A test that builds the real store behind a mocked one then waited on itself and hung. Handing back now writes images to the draft store directly, as the hook's helper did, and a test pins that each returned image keeps its SSH connection. * fix(native-chat): one send per chat, with no line of sends behind it A chat with a send out took further messages into an in-memory line and sent them one by one. A message typed behind one in doubt then hit its own 30 s deadline and came back as not sent without ever going out. Now, as the common pattern does, a chat takes one send at a time: while it is out, Send is disabled and Enter leaves the text in the box. The sender refuses a second send instead of lining it up, so the 30 s deadline always runs from the send itself. A Stop or a tab close stops the one send: settled from its answer if its request is out, handed back as unconfirmed between attempts, or silently if it never went out. Notes sent from outside the chat while its send is out stay with their sender (not ready); a launch prompt that meets the person's own first message waits in the composer instead of being lost. * fix(native-chat): give a Stop-withdrawn note back to its chat once its notes were cleared Notes sent from outside a chat clear once their message is recorded. A message the host recorded as pending and a Stop then withdrew came back only to its sender, which had already let go of it, so the text was lost. The chat's draft now takes it, as an earlier build's outbox did. * test(native-chat): type the composer-actions probe without a cast * chore(native-chat): drop outbox wording left in comments and an empty locale group * fix(native-chat): pace failed checks, keep the host's reason, and hold the chat for its launch prompt - A send whose checks failed before its request went out (an unreachable or incompatible host, an unreadable history) was tried again at once, about a thousand times a second for 30 s. Attempts are now paced by the attempts made, whether or not their request went out. - A host that refuses every resend by throwing (native chat turned off, a journal that won't open) gave back only "couldn't confirm". The host's reason now comes first, still without claiming not sent. - A launch's prompt now holds the chat's one send from the click, drawn as sending, so a message typed while the chat starts can't overtake it; it goes out once the chat exists, and a cancelled launch frees it. - Notes whose send nobody could confirm say so instead of "did not accept", and notes launched into a new chat let go of their text once that chat's composer holds it. - An open chat keeps drawing a recorded send until its row arrives, so a reply that beats the history no longer makes the message flicker out. * fix(native-chat): hold a chat's sends until it has started, and send a failed chat's message with its restart A message sent to a chat still starting went out at once to a host that had no record of the chat yet, read for a fence it could not get, and came back as not sent. A message sent to a chat whose start failed restarted it but no longer went with the restart. While a chat starts, Send stays off and Enter leaves the text in the box, as with a send already out. A text message sent to a chat whose start failed restarts it and goes as the restart's first message, through the same staged-prompt path a launch prompt takes: it holds the chat's one send slot, drawn as sending, until the chat exists, and comes back to the composer if the restart fails again. Notes wait while a chat starts and ride a failed chat's restart, keeping their text until it is sent. * chore(native-chat): test the queue request, rejection words and card hand-offs; drop an unused clear - Pin which sends ask the host to queue, the moved rejection wording, and that a queue send whose card was handed off and then refused or withdrawn, or whose replay names a withdrawn card, is never handed back. - The send-at-most-once gate now says what the desktop promises after a reload: it never sends the id again, and hands the text back. - The legacy read names when it goes, and drops the notice clear nothing called. * fix(native-chat): keep a sent launch prompt's entry, and give back at once what can't go out - Cleaning up a launch prompt released its send slot even after the prompt had gone out, which deleted the entry the sender keeps once the host records it. A launch prompt recorded as pending and then withdrawn by a Stop was lost from both the chat and the box, and an open chat dropped the new chat's first message before its row arrived. The slot now gives back only a reservation that was never sent. - A send stopped before its request went out by something trying again won't clear (this client and the server can't talk, or the host refused the history read) kept Send off for 30 s and then said Orca couldn't reach the agent. It now comes back at once with its own cause: not sent, since nothing went out, or unconfirmed if an earlier attempt did. Transport errors keep the paced retries. * fix(native-chat): notes keep their own text through a new agent's launch Notes sent to a New agent whose start failed came back to the notes and also sat in the new chat's composer, so sending both delivered the text twice. The notes keep their text until it goes out (they hold it from the click), so the launch now says so and no composer gets a copy, on a failed start or a refused prompt alike. The tests that asserted the copy in the composer pinned the old double ownership and now assert the notes are its only owner. Notes that rode a failed chat's restart and ended unconfirmed now say Orca couldn't confirm them, as notes sent directly do, instead of that the agent did not accept them. * chore(native-chat): the send-once gate says what the desktop keeps across a reload The desktop keeps a send's id in memory only: a send still unsettled at a reload or crash is not resent and not handed back. The coverage notes no longer credit the renderer tests with durable identity across a remount. * fix(native-chat): say once why notes sent to a new agent did not go Since notes keep their own text through a new agent's launch, a prompt the host refused, nobody could confirm, or that found the chat's send taken came back to the notes with nothing said anywhere: the chat shows no notice for text its caller keeps. The notes menu now reports it once, with the toast a send to an existing chat already uses: not accepted, couldn't confirm, or not ready. A start that failed still says so in the new chat instead. * fix(native-chat): retry a history refusal the host says clears, and name it at the deadline A send stopped before its request went out came back at once for any refusal of the history read, including ones the host names as clearing (its journal briefly unavailable, a chat detached while the host quits), so the automatic retry was lost. Only a version mismatch and refusals that won't clear come back at once now; the rest go again on the paced schedule, and if the deadline still finds nothing sent, the words are the host's refusal rather than Orca couldn't reach the agent. * feat(native-chat): a send makes one request, and nothing ever sends it again The desktop resent a message under its own id for up to 30 s when the answer was lost. A send now makes exactly one request, as the common pattern's clients do: - An answer that is lost, dropped or proves nothing hands the text back at once with "couldn't confirm… check the chat". - A failure before the request goes out (the environment check, the history read for the fence, a refused connection) means nothing went out: the text comes back at once with its own reason, or as not sent. - The 30 s cap stays on the one request, so a host that never answers can't hold Send. Nothing ever resends, so nothing can go out after a Stop: a Stop only takes back a send still in its pre-send checks. The resend state goes with it (tries, generation, awaiting, stopped, the resend timer, the send-answers-proof probe, and the first-attempt/resend split in the evidence), and the send-once gate says the desktop never resends. * fix(native-chat): notes keep the chat's line, and a send held behind a rewind says it was not sent - Only a send whose text belongs to the chat's composer clears the chat's line. Notes sent from outside the chat no longer wipe a "couldn't confirm... Check the chat" line that explains text already back in the box. - A send the host turns away behind a rewind it could not confirm went back as "not sent" but said "couldn't confirm what happened. Check the chat". It now gives the rewind's reason and says the message was not sent. - Reliability gate names the single-request test and records a fresh evidence run; the sender test drops its leftover resend mocks and a duplicate Stop test. * fix(native-chat): a send ends even when putting its text back fails If writing the returned text into the chat's draft threw, the send never settled: it stayed "sending", the chat refused every later send until a reload. The send now always ends after a hand-back, the failure is logged, and the chat's line adds "Couldn't save your message." * fix(native-chat): a message typed during /clear stays in the box and follows the chat A /clear moves the chat to a new conversation, and its host refuses any send while it runs. A message sent then went out, came back refused into the old conversation's draft with its line, and vanished when the chat moved on: the box and the line now belong to the new conversation. - A /clear holds the chat's one send slot while it runs: Enter does nothing, Send shows busy and the text stays in the box, as for a send that is out. Notes sent from outside get "busy". - Once it moves the chat, the old conversation keeps taking no send until the view leaves it, and what is left of its draft moves into the new conversation's draft, after anything there: when the composer's /clear settles, and again when the view moves. * fix(native-chat): no "Send message?" or queue clear while the chat's send is out While a send is out (or a /clear runs) the chat takes no message, yet Enter over a held queue still opened "Send message?", and Clear queue deleted every card before its message was refused. Enter now does nothing there and the text stays; Clear queue re-checks and deletes nothing if a send went out after the question opened. * refactor(native-chat): take the /clear draft carry out of this change Moving the old conversation's draft into the one a /clear replaces it with fixes a bug main has too (text left in the box during a /clear stays with the old conversation), so it goes in its own change. Kept here: a /clear holds the chat's send slot while it runs, and the conversation it moved away from takes no send until the view leaves it. * fix(native-chat): a /clear's hold on sends always ends - A view that unmounted while a /clear that moves the chat was out left the old conversation holding its sends until a reload: the late reply kept the hold for a view that was gone. The reply now releases it. - The local call for a conversation command has no deadline of its own, so a /clear that never answers kept Send off for good. The hold now also ends at the command's deadline (195 s, the one the remote call already uses), whichever comes first. * test(native-chat): opening a chat an older build left stuck; hand back its copy in send order Pins, through the real chat hook, sends, legacy recovery and draft store, what opening such a chat does: the queued messages behind a message the host recorded in doubt come back to the composer once, in order, with the "not sent" or "couldn't confirm" wording; the chat is free to send under its read's fence; nothing happens while the chat has no fence here. The legacy reader now hands entries back in the order they were sent (queuedAt), as the older build's reader did, instead of array order. * fix(native-chat): a send this window turned away for a re-paired server comes back as not sent A managed server's update rotates its pairing, and this window's main process then answers the next call itself, before forwarding anything, with runtime_environment_changed. The send read that thrown answer as proving nothing, so the person was told Orca couldn't confirm a message that never left. It is now handed back as not sent, with the reason. Every other thrown answer still reads as unconfirmed once the request may have gone out. * Save a new chat's first message during create * Align opening-message types with the user-message wire * Keep chat publication and cleanup outside store initialization * Complete the create refusal fixture ledger * test(native-chat): prove a created first message waits for the agent's start and is handed over once * refactor(native-chat): keep the host and session hook under the line limit after the main sync * test(native-chat): a chat created with its first message and no pick still teaches its agent's default After the main sync, a created chat's agent starts from the delivery loop rather than the create call. Grok (through the real ACP adapter and model catalog) and Claude (through the real adapter) each still report the model their own config resolved to once the chat reads its options. --- .../vitest-sqlite-runtime-boundary.test.ts | 3 + .../scripts/vitest-sqlite-runtime-files.mjs | 8 + .../structured-create-wire-types.test.ts | 37 ++ ...structured-host-configured-default.test.ts | 33 +- .../desktop-renderer-runtime-capabilities.ts | 2 + ...structured-agent-session-adopted-import.ts | 14 +- ...ured-agent-session-create-adoption.test.ts | 250 +++++++++ ...tured-agent-session-create-test-fixture.ts | 54 ++ .../structured-agent-session-create.test.ts | 433 +++++++++++++++ .../structured-agent-session-create.ts | 285 ++++++++++ ...structured-agent-session-host-test-data.ts | 7 +- .../structured-agent-session-host.ts | 13 +- .../structured-agent-session-options-read.ts | 17 + ...red-agent-session-provider-started.test.ts | 38 +- .../agent-session-founding-admission.ts | 61 ++ .../runtime/agent-session-founding-store.ts | 49 ++ .../runtime/agent-session-record-store.ts | 20 +- .../agent-session-reservation-admission.ts | 24 +- ...structured-agent-session-create-handler.ts | 100 ++++ ...tured-agent-session-create-message.test.ts | 259 +++++++++ .../structured-agent-session-create.ts | 102 ++-- ...ed-agent-session-precommit-refusal.test.ts | 9 +- ...ructured-agent-session-rpc.test-fixture.ts | 3 +- .../rpc/methods/structured-agent-session.ts | 93 +--- ...ed-session-notices.create-message.test.tsx | 43 ++ .../native-chat-structured-session-notices.ts | 13 +- ...red-agent-session-delivery-notices.test.ts | 3 +- ...structured-agent-session-message-sender.ts | 238 ++++---- .../structured-agent-session-pending-sends.ts | 4 +- ...tured-agent-session-read-owner-registry.ts | 14 + .../structured-agent-session-read-owner.ts | 8 + .../structured-agent-session-send-runtime.ts | 150 +++++ .../structured-agent-session-stop-control.ts | 23 +- ...uctured-agent-session-provisional.test.tsx | 6 +- .../use-structured-agent-session.ts | 7 +- .../lib/launch-structured-agent-session.ts | 123 +--- ...ructured-agent-session-create-hydration.ts | 18 + ...ctured-agent-session-launch-create-call.ts | 153 +++++ ...gent-session-launch-create-message.test.ts | 523 ++++++++++++++++++ ...red-agent-session-launch-create-message.ts | 60 ++ ...red-agent-session-launch-create-support.ts | 17 + ...structured-agent-session-launch-message.ts | 2 + ...structured-agent-session-launch-options.ts | 11 + ...d-agent-session-launch-outcome-tracking.ts | 23 +- ...ctured-agent-session-launch-persistence.ts | 102 +--- ...red-agent-session-launch-prompt-outcome.ts | 53 ++ ...d-agent-session-launch-prompt-stop.test.ts | 6 +- ...ctured-agent-session-launch-prompt.test.ts | 4 +- .../structured-agent-session-launch-prompt.ts | 119 +++- ...session-launch-publication-confirmation.ts | 33 ++ ...ctured-agent-session-launch-publication.ts | 22 + ...ctured-agent-session-launch-record.test.ts | 35 ++ .../structured-agent-session-launch-record.ts | 125 +++++ ...tructured-agent-session-launch-recovery.ts | 32 +- .../structured-agent-session-launch-reload.ts | 7 +- .../lib/structured-agent-session-launch.ts | 60 +- ...ctured-agent-session-legacy-launch-stop.ts | 11 + .../structured-agent-session-client.ts | 7 + ...tured-agent-session-tab-retirement.test.ts | 34 ++ ...structured-agent-session-tab-retirement.ts | 2 + .../worktree-sleep-intent-lifecycle.test.ts | 27 + .../teardown/worktree-purge-state.ts | 9 + .../agent-session-create-capabilities.ts | 15 + src/shared/agent-session-wire.ts | 2 + ...tron-remote-runtime-client-capabilities.ts | 2 + src/shared/protocol-version.ts | 8 +- ...uctured-agent-session-agent-params.test.ts | 51 +- ...structured-agent-session-message-params.ts | 37 ++ .../structured-agent-session-params.ts | 91 ++- .../structured-agent-session-create.test.ts | 57 +- src/shared/structured-agent-session-create.ts | 16 +- .../structured-agent-session-mutation.ts | 7 +- .../structured-agent-session-send-mutation.ts | 4 +- ...ss-version-agent-session-wire.unit.test.ts | 5 +- .../structured-agent-session-host-fixture.ts | 6 +- ...uctured-agent-session-surface-execution.ts | 20 +- 76 files changed, 3708 insertions(+), 654 deletions(-) create mode 100644 mobile/src/session/structured-create-wire-types.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-create.ts create mode 100644 src/main/runtime/agent-session-founding-admission.ts create mode 100644 src/main/runtime/agent-session-founding-store.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts create mode 100644 src/renderer/src/lib/structured-agent-session-create-hydration.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-create-call.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-create-message.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-create-support.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-record.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-record.ts create mode 100644 src/renderer/src/lib/structured-agent-session-legacy-launch-stop.ts create mode 100644 src/shared/agent-session-create-capabilities.ts create mode 100644 src/shared/rpc-contract/structured-agent-session-message-params.ts diff --git a/config/scripts/vitest-sqlite-runtime-boundary.test.ts b/config/scripts/vitest-sqlite-runtime-boundary.test.ts index 2f5c7c59077..4c86e8a57b8 100644 --- a/config/scripts/vitest-sqlite-runtime-boundary.test.ts +++ b/config/scripts/vitest-sqlite-runtime-boundary.test.ts @@ -8,6 +8,7 @@ import { NODE_RUNTIME_INCLUDE } from './vitest-node-runtime-files.mjs' const sqliteHarnesses = new Set([ 'persistence-test-harness', 'agent-session-record-store-test-harness', + 'structured-agent-session-rest-test-rig', 'journal-host-database-test-support' ]) const sqliteModules = new Set([ @@ -53,6 +54,8 @@ function importsSqliteRuntime(file: string, source: string): boolean { } it.each([ + ['import { createRestTestRig } from "./structured-agent-session-rest-test-rig"', true], + ['import type { RestTestRig } from "./structured-agent-session-rest-test-rig"', false], ['import Database, { type Options } from "./sync-database"', true], ['import {} from "./sync-database"', true], ['import "./sync-database"', true], diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 85a6f048eb5..6f5ca5acd13 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -88,10 +88,13 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts', @@ -104,6 +107,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', @@ -135,6 +139,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', @@ -149,6 +154,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', @@ -209,8 +215,10 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts', 'src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts', 'src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts', 'src/main/runtime/runtime-orchestration-sender-names.test.ts', diff --git a/mobile/src/session/structured-create-wire-types.test.ts b/mobile/src/session/structured-create-wire-types.test.ts new file mode 100644 index 00000000000..6fc2aba1c29 --- /dev/null +++ b/mobile/src/session/structured-create-wire-types.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { z } from 'zod' +import { + structuredAgentSessionCreateParams, + type StructuredAgentSessionFirstMessage +} from '../../../src/shared/structured-agent-session-create' +import { CreateIntentParams } from '../../../src/shared/rpc-contract/structured-agent-session-params' + +describe('the shared create builder used by mobile', () => { + it('remains assignable to the strict create request without an opening message', () => { + const params = structuredAgentSessionCreateParams({ + sessionId: 'codex_11111111_2222_3333_4444_555555555555', + worktree: 'id:workspace-1', + agent: 'codex', + randomUuid: () => '11111111-2222-3333-4444-555555555555' + }) + expectTypeOf(params).toExtend>() + expect(CreateIntentParams.safeParse(params).success).toBe(true) + expect(params).not.toHaveProperty('firstMessage') + }) + + it('accepts only user-authored opening messages at the type and wire boundaries', () => { + type AssistantBody = { + kind: 'message' + role: 'assistant' + blocks: { type: 'text'; text: string }[] + } + expectTypeOf().not.toExtend() + expectTypeOf().toEqualTypeOf<'user'>() + expect( + CreateIntentParams.shape.firstMessage.safeParse({ + clientMessageId: 'opening-message', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'forged' }] } + }).success + ).toBe(false) + }) +}) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index eeb5ccc4664..1099746e82e 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -11,7 +11,10 @@ import { structuredAgentSessionOptionSnapshot } from '../../shared/structured-agent-session-options' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { @@ -24,6 +27,8 @@ import { AcpScriptedAgent } from './acp-scripted-agent.test-support' import { AcpSessionRuntime } from './acp-session-runtime' import { GROK, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support' import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { createTestParams } from '../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create' import { attachParams, launch, openHostRig } from './acp-structured-host.test-support' const GROK_HOME = { variable: 'GROK_HOME', path: '/grok' } @@ -95,8 +100,9 @@ function firstFrame(answer: AgentSessionModelCatalogResult) { return { model: select('model'), effort: select('effort') } } -/** A Grok chat opened with no model pick, whose own session runs `runs`. */ -async function openGrokHost(runs: string) { +/** A Grok chat opened with no model pick, whose own session runs `runs`; `create` founds it + * with its first message, so only delivery starts the agent. */ +async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMessage) { const records: { store: AgentSessionRecordStore | null } = { store: null } const discovery = GROK.modelDiscovery const catalog = createAgentModelCatalogService({ @@ -125,7 +131,10 @@ async function openGrokHost(runs: string) { deps: { resolveLaunch: launch(() => false) } }) records.store = rig.store - expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + const opening = create + ? rig.host.create(CALLER, createTestParams(create, attachParams()), { firstMessage: create }) + : rig.host.attach(CALLER, attachParams()) + expect(await opening).toMatchObject({ ok: true }) return { ...rig, catalog } } @@ -150,4 +159,20 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { }) expect(firstFrame(answer)).toEqual({ model: 'grok-4.6', effort: 'high' }) }) + + it('learns 4.6 from a chat created with its first message and no pick', async () => { + const { catalog, host, rig } = await openGrokHost('grok-4.6', { + clientMessageId: 'opening', + body: hostTestMessage('hello') + }) + await catalog.read({ agent: 'grok', waitForListing: true }) + // Delivery started the agent and handed it the opening message; nothing picked a model. + const prompt = await rig.frame('session/prompt') + rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) + + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) + expect(answer).toMatchObject({ listingNamesConfiguredModel: true }) + expect(firstFrame(answer)).toEqual({ model: 'grok-4.6', effort: 'high' }) + }) }) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 4a5e64296a0..d44fda04208 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -12,6 +12,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../shared/agent-session-create-capabilities' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../shared/agent-session-optional-model-capability' import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, @@ -34,6 +35,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shar * copies, and a capability added to one and missed on the other is invisible until a user hits it. */ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index e86e30d8f5d..ebc91a4c00b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -69,8 +69,20 @@ export async function importAdoptedTranscript( params: AgentSessionAttachParams, attached: AttachedJournal, record: AgentSessionRecord, - prepared: JournalReplacementItem[] | null + prepared: JournalReplacementItem[] | null, + options: { uncommittedCreate?: true } = {} ): Promise { + if (options.uncommittedCreate) { + // A rolled-back create can leave imported history, but no conversation owns it yet. + if (prepared || attached.journal.cursor().sequence > 1) { + await attached.journal.replaceEpochItems( + params.adopt ? 'legacy_import' : 'session_created', + record.lease.runtimeFence, + prepared ?? [] + ) + } + return + } // The journal is the conversation's, which outlives a failed import; nothing here closes it. await applyAdoptedTranscript(params, attached, record, prepared) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts new file mode 100644 index 00000000000..0d87155f514 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -0,0 +1,250 @@ +import { writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createRestTestRig, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { + createTestParams, + stopCreatedChat, + CREATE_TEST_CALLER as CALLER +} from './structured-agent-session-create-test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestOperationId, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +let rig: RestTestRig +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) +}) +afterEach(async () => { + await rig.dispose() + vi.restoreAllMocks() +}) + +async function adoptedParams(history = 'adopted history', threadId = THREAD) { + const transcriptPath = join(rig.root, 'rollout.jsonl') + await writeFile( + transcriptPath, + [ + JSON.stringify({ + type: 'session_meta', + payload: { id: threadId, timestamp: '2026-09-06T18:00:00.000Z', cwd: rig.root } + }), + JSON.stringify({ + type: 'response_item', + timestamp: '2026-09-06T18:00:01.000Z', + payload: { type: 'message', role: 'user', content: history } + }) + ].join('\n') + ) + return createTestParams(undefined, { + adopt: { providerHandle: { kind: 'codex', threadId }, transcriptPath } + }) +} + +function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachParams { + return createTestParams(undefined, { + ...params, + envelope: { + ...params.envelope, + sessionId: 'session-second', + clientOperationId: hostTestOperationId() + } + }) +} + +it('imports adoption at rest and resumes its original provider identity after its source disappears', async () => { + const params = await adoptedParams() + const history = vi.fn(async () => null) + rig.host.deps.adapter.providerHistoryWindow = history + expect(await rig.host.create(CALLER, params)).toMatchObject({ + ok: true, + value: { + page: { + items: expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ blocks: [{ type: 'text', text: 'adopted history' }] }) + }) + ]) + } + } + }) + expect(rig.store.getRecord(SESSION)?.providerHandleChain).toMatchObject([ + { origin: 'adopted', mintedAtFence: 1, handle: { nativeId: THREAD } } + ]) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(history).not.toHaveBeenCalled() + await rm(join(rig.root, 'rollout.jsonl')) + expect( + await rig.host.create(CALLER, { + ...params, + accountHome: { variable: 'CODEX_HOME', path: '/drifted/account' }, + adopt: { providerHandle: { kind: 'codex', threadId: THREAD } } + }) + ).toMatchObject({ ok: true, replayed: true }) + expect(await rig.host.send(CALLER, restTestSend('new work'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ + providerHandle: expect.objectContaining({ nativeId: THREAD }) + }) + }) + ) + expect(history).toHaveBeenCalledOnce() + expect(history.mock.invocationCallOrder[0]).toBeLessThan( + rig.adapter.acquire.mock.invocationCallOrder[0] + ) + expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome) +}) + +it('refuses concurrent adoption of a provider conversation by another session', async () => { + const params = await adoptedParams() + const results = await Promise.all([ + rig.host.create(CALLER, params), + rig.host.create(CALLER, anotherSession(params)) + ]) + expect(results.filter((result) => result.ok)).toHaveLength(1) + expect(results.filter((result) => !result.ok)).toMatchObject([ + { + refusal: { code: 'agent_session_conflict', details: { reason: 'conversationHeldElsewhere' } } + } + ]) + expect(rig.store.listRecords()).toHaveLength(1) + expect(rig.store.listOperationRows()).toHaveLength(1) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('refuses concurrent founding under a tab another chat already owns', async () => { + const params = createTestParams(undefined, { surfaceTabId: 'reserved-chat-tab' }) + const results = await Promise.all([ + rig.host.create(CALLER, params), + rig.host.create(CALLER, anotherSession(params)) + ]) + expect(results.filter((result) => result.ok)).toHaveLength(1) + expect(results.filter((result) => !result.ok)).toMatchObject([ + { refusal: { code: 'agent_session_conflict', details: { reason: 'tabIdTaken' } } } + ]) + expect(rig.store.listVisibleSessionIds()).toHaveLength(1) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('validates adoption history before founding anything', async () => { + const params = await adoptedParams() + await writeFile(join(rig.root, 'rollout.jsonl'), 'not a transcript\n') + expect(await rig.host.create(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_identity_required' } + }) + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) +}) + +it('does not found a record whose account namespace its agent cannot drive', async () => { + const params = createTestParams(undefined, { + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/wrong/account' } + }) + expect(await rig.host.create(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) +}) + +it.each([ + { executionHostId: 'local', wslDistro: null }, + { executionHostId: 'ssh:host-1', wslDistro: null }, + { executionHostId: 'local', wslDistro: 'Ubuntu' }, + { executionHostId: 'runtime:paired-host', wslDistro: null } +] as const)( + 'preserves folder execution identity at rest: $executionHostId $wslDistro', + async (location) => { + const params = createTestParams(undefined, { + location: { ...location, workspaceId: 'folder-1', workspaceKind: 'folder' } + }) + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION)?.location).toEqual(params.location) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } +) + +it('pins the host-resolved directory of a floating chat before any acquire', async () => { + const params = createTestParams(undefined, { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + } + }) + expect(await rig.host.create(CALLER, params, { hostLaunchDirectory: rig.root })).toMatchObject({ + ok: true + }) + expect(rig.store.getRecord(SESSION)?.launchDirectory).toBe(rig.root) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it.each(['adopted', 'blank'] as const)( + 'retries a rolled-back create with only the new %s history', + async (nextSource) => { + const first = { clientMessageId: hostTestOperationId(), body: hostTestMessage('first attempt') } + const adopted = await adoptedParams('old imported history') + const db = openTestJournalHostDatabase(rig.root).db + db.exec( + `CREATE TRIGGER refuse_first BEFORE INSERT ON journal_rows WHEN NEW.row_json LIKE '%"kind":"submission"%' BEGIN SELECT RAISE(ABORT, 'simulated first write failure'); END` + ) + expect( + await rig.host.create(CALLER, createTestParams(first, adopted), { firstMessage: first }) + ).toMatchObject({ ok: false }) + expect(rig.store.getRecord(SESSION)).toBeNull() + expect(rig.store.listOperationRows()).toEqual([]) + db.exec('DROP TRIGGER refuse_first') + const next = { clientMessageId: hostTestOperationId(), body: hostTestMessage('next attempt') } + const nextParams = + nextSource === 'adopted' + ? await adoptedParams('new imported history', '019fd532-7c11-7a90-b6de-4e1a2c3d5f61') + : createTestParams() + let stopping: ReturnType | undefined + const result = await rig.host.create( + CALLER, + createTestParams(next, { + ...nextParams, + envelope: { ...nextParams.envelope, clientOperationId: hostTestOperationId() } + }), + { + firstMessage: next, + publishTab: async () => { + stopping = stopCreatedChat(rig.host) + } + } + ) + expect(result).toMatchObject({ ok: true }) + if (!result.ok) { + throw new Error('retry create failed') + } + const history = (text: string) => + expect.objectContaining({ + body: expect.objectContaining({ blocks: [{ type: 'text', text }] }) + }) + expect(result.value.page.items).not.toEqual( + expect.arrayContaining([history('old imported history')]) + ) + if (nextSource === 'adopted') { + expect(result.value.page.items).toEqual( + expect.arrayContaining([history('new imported history')]) + ) + } + expect(await stopping).toMatchObject({ ok: true }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts new file mode 100644 index 00000000000..ab375470e0e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts @@ -0,0 +1,54 @@ +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { + hostTestAttachParams, + hostTestOperationId, + HOST_TEST_SESSION +} from './structured-agent-session-host-test-data' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export const CREATE_TEST_CALLER = { callerKey: 'client-1' } + +export function createTestParams( + firstMessage?: StructuredAgentSessionFirstMessage, + overrides: Partial = {} +): AgentSessionAttachParams { + const params = hostTestAttachParams(null, { providerHandle: undefined, ...overrides }) + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { + worktree: `id:${params.location.workspaceId}`, + agent: params.agent, + firstMessage, + options: params.options, + tabId: params.surfaceTabId + } + }) + } + } +} + +export function stopCreatedChat( + host: StructuredAgentSessionHost, + sessionId = HOST_TEST_SESSION, + expectedRuntimeFence: number | null = null +) { + const envelope: AgentSessionMutationEnvelope = { + sessionId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId, + fields: {} + }) + } + return host.cancel(CREATE_TEST_CALLER, { envelope }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts new file mode 100644 index 00000000000..c3f2a485598 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts @@ -0,0 +1,433 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createRestTestRig, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { + CREATE_TEST_CALLER as CALLER, + createTestParams, + stopCreatedChat +} from './structured-agent-session-create-test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { + openTestJournalHostDatabase, + loadTestJournal +} from '../agent-session-journal/journal-host-database-test-support' +import { readPersistedTestAgentSessionStore } from '../../runtime/agent-session-record-store-test-harness' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../../shared/native-chat-stopped-before-start' + +let rig: RestTestRig +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) +}) +afterEach(async () => { + await rig.dispose() + vi.restoreAllMocks() +}) + +function firstMessage() { + return { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening message') } +} + +function abortableAcquisition() { + const entered = Promise.withResolvers() + rig.adapter.acquire.mockImplementationOnce( + ({ signal }) => + new Promise((_resolve, reject) => { + entered.resolve() + const abort = () => reject(new AgentSessionPreSpawnError(signal?.reason)) + if (signal?.aborted) { + abort() + } else { + signal?.addEventListener('abort', abort, { once: true }) + } + }) + ) + return entered.promise +} + +it('commits the record, tab, operation and first message together and returns while acquire is unresolved', async () => { + const first = firstMessage() + const params = createTestParams(first, { surfaceTabId: 'chat-create-tab' }) + const entered = abortableAcquisition() + const observed: unknown[] = [] + rig.store.onFirstRecord(() => + observed.push({ + record: rig.store.getRecord(SESSION), + tab: rig.store.getSessionTabId(SESSION), + operation: rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId), + submission: loadTestJournal(rig.root, SESSION)?.state.submissions.get(first.clientMessageId) + }) + ) + const publishTab = vi.fn(async () => { + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null + }) + }) + const result = await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) + expect(result).toMatchObject({ + ok: true, + fence: 1, + value: { + tabId: 'chat-create-tab', + firstMessage: { clientMessageId: first.clientMessageId, dispatchState: 'pending' }, + page: { + submissions: [{ clientMessageId: first.clientMessageId, dispatchState: 'pending' }], + items: expect.arrayContaining([expect.objectContaining({ body: first.body })]) + } + } + }) + expect(observed).toMatchObject([ + { + tab: 'chat-create-tab', + operation: { outcome: { status: 'succeeded' } }, + submission: { clientMessageId: first.clientMessageId }, + record: { lease: { claimStatus: 'released' } } + } + ]) + expect(publishTab).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await entered + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) +}) + +it('creates a blank chat at rest without acquiring and persists its launch arguments', async () => { + const resolveLaunchArgs = vi.fn(() => ['--test-flag']) + const resolveLaunchEnv = vi.fn(() => ({ SAFE_FLAG: 'yes' })) + await rig.dispose() + rig = await createRestTestRig({ + resolveLaunchArgs, + resolveLaunchEnv, + idleSweep: { intervalMs: 60_000 } + }) + expect(await rig.host.create(CALLER, createTestParams())).toMatchObject({ + ok: true, + value: { page: { submissions: [] } } + }) + expect(rig.store.getRecord(SESSION)).toMatchObject({ + launchArgs: ['--test-flag'], + lease: { + claimStatus: 'released', + runtimeFence: 1, + ownerProcess: null, + reservedSpawnToken: null + } + }) + expect(resolveLaunchEnv).toHaveBeenCalledOnce() + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('keeps delivery behind runtime tab publication even after the message is committed', async () => { + const first = firstMessage() + const held = Promise.withResolvers() + const release = Promise.withResolvers() + const entered = abortableAcquisition() + let answered = false + const creating = rig.host + .create(CALLER, createTestParams(first), { + firstMessage: first, + publishTab: async () => { + held.resolve() + await release.promise + } + }) + .then((result) => { + answered = true + return result + }) + await held.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rig.store.getRecord(SESSION)).not.toBeNull() + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(answered).toBe(false) + release.resolve() + expect(await creating).toMatchObject({ ok: true }) + await entered + await stopCreatedChat(rig.host) +}) + +it('an immediate Stop cancels the queued first message before acquire and replay never redelivers it', async () => { + const first = firstMessage() + const params = createTestParams(first) + let stopping: ReturnType | undefined + expect( + await rig.host.create(CALLER, params, { + firstMessage: first, + publishTab: async () => { + stopping = stopCreatedChat(rig.host) + } + }) + ).toMatchObject({ ok: true }) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ + ok: true, + replayed: true, + value: { + page: { + submissions: [{ clientMessageId: first.clientMessageId, rejection: { kind: 'cancelled' } }] + } + } + }) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.stopMarks.latest() + ).toMatchObject({ event: { reason: 'user-stop' } }) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() +}) + +it('holds the created first message until the child proves its start, then hands it over once', async () => { + const first = firstMessage() + const spawn = rig.adapter.acquire.getMockImplementation()! + let generation = '' + rig.adapter.acquire.mockImplementationOnce(async (input) => { + const child = await spawn(input) + generation = child.acquisitionGeneration ?? '' + return { ...child, providerChildPhase: 'starting' as const } + }) + expect( + await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + const held = (await rig.host.journalSnapshot(SESSION)).submissions + expect(held).toEqual([expect.objectContaining({ clientMessageId: first.clientMessageId })]) + expect(held[0].handedOverAt).toBeUndefined() + + await rig.host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: generation, + reportedOptions: { model: 'gpt-live' }, + restoreSkippedOptions: [], + optionRevision: rig.host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + }) + + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + expect(rig.adapter.dispatch).toHaveBeenCalledWith(expect.objectContaining({ body: first.body })) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rig.adapter.dispatch).toHaveBeenCalledOnce() +}) + +it('Stop while the child proves its start withdraws the created first message once, unsent', async () => { + const first = firstMessage() + const spawn = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementationOnce(async (input) => ({ + ...(await spawn(input)), + providerChildPhase: 'starting' as const + })) + expect( + await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + + expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) + + const submissions = (await rig.host.journalSnapshot(SESSION)).submissions + expect(submissions).toEqual([ + expect.objectContaining({ + clientMessageId: first.clientMessageId, + dispatchState: 'rejected', + rejection: expect.objectContaining({ kind: 'cancelled' }) + }) + ]) + expect(submissions[0].handedOverAt).toBeUndefined() + expect(rig.adapter.dispatch).not.toHaveBeenCalled() +}) + +it('Stop aborts a handshake outside the lane and the next send sends only its own text', async () => { + const first = firstMessage() + const params = createTestParams(first) + const entered = abortableAcquisition() + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ ok: true }) + await entered + expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ + ok: true, + replayed: true + }) + const next = restTestSend('next message', rig.store.getRecord(SESSION)?.lease.runtimeFence) + expect(await rig.host.send(CALLER, next)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + expect(rig.adapter.dispatch).toHaveBeenCalledWith(expect.objectContaining({ body: next.body })) + const snapshot = await rig.host.journalSnapshot(SESSION) + expect(snapshot.submissions).toHaveLength(2) + expect( + snapshot.submissions.find(({ clientMessageId }) => clientMessageId === first.clientMessageId) + ?.rejection + ).toMatchObject({ kind: 'cancelled' }) +}) + +it('replays the stopped first message even after its row falls outside the hydration page', async () => { + const first = firstMessage() + const params = createTestParams(first) + let stopping: ReturnType | undefined + expect( + await rig.host.create(CALLER, params, { + firstMessage: first, + publishTab: async () => { + stopping = stopCreatedChat(rig.host) + } + }) + ).toMatchObject({ ok: true }) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('created chat has no journal') + } + for (let ordinal = 1; ordinal <= AGENT_SESSION_HISTORY_MAX_LIMIT + 1; ordinal += 1) { + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'later-history', ordinal }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `later ${ordinal}` }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + const append = vi.spyOn(journal, 'appendSubmission') + const replay = await rig.host.create(CALLER, params, { firstMessage: first }) + if (!replay.ok) { + throw new Error(`create replay refused: ${replay.refusal.code}`) + } + expect(replay.replayed).toBe(true) + expect(replay.value.page.hasOlder).toBe(true) + expect(replay.value.page.items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ body: first.body })]) + ) + expect(replay.value.page.submissions).toEqual([]) + expect(replay.value.firstMessage).toMatchObject({ + clientMessageId: first.clientMessageId, + dispatchState: 'rejected', + rejection: { kind: 'cancelled' } + }) + expect(append).not.toHaveBeenCalled() + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.dispatch).not.toHaveBeenCalled() +}) + +it('admits Stop with the create-returned fence after acquisition advances it and keeps the stopped row', async () => { + const first = firstMessage() + const entered = abortableAcquisition() + const created = await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + if (!created.ok) { + throw new Error(`create refused: ${created.refusal.code}`) + } + expect(created.fence).toBe(1) + await entered + expect(rig.store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(created.fence) + const stopping = stopCreatedChat(rig.host, SESSION, created.fence) + let stopped: Awaited | undefined + void stopping.then((outcome) => { + stopped = outcome + }) + try { + await vi.waitFor(() => expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } })) + const snapshot = await rig.host.journalSnapshot(SESSION) + expect(snapshot.submissions).toMatchObject([ + { + clientMessageId: first.clientMessageId, + dispatchState: 'rejected', + rejection: { kind: 'cancelled' } + } + ]) + expect( + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { + rejectedInPlace: true + }) + ).toMatchObject([ + { role: 'user', blocks: first.body.blocks }, + { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] } + ]) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.stopMarks.latest() + ).toMatchObject({ event: { reason: 'user-stop' } }) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + } finally { + rig.host.collaboratorsForTests().runtimeState.acquireAborts.abort(SESSION, 'test cleanup') + await stopping + } +}) + +it.each(['journal_rows', 'agent_session_operations'])( + 'rolls back all create state when %s refuses its write', + async (table) => { + const first = firstMessage() + const params = createTestParams(first) + const db = openTestJournalHostDatabase(rig.root).db + const when = table === 'journal_rows' ? `WHEN NEW.row_json LIKE '%"kind":"submission"%'` : '' + db.exec( + `CREATE TRIGGER refuse_create BEFORE INSERT ON ${table} ${when} BEGIN SELECT RAISE(ABORT, 'simulated write failure'); END` + ) + const publishTab = vi.fn(async () => {}) + expect( + await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) + ).toMatchObject({ ok: false }) + expect(rig.store.getRecord(SESSION)).toBeNull() + expect(rig.store.getSessionTabId(SESSION)).toBeNull() + expect(rig.store.listOperationRows()).toEqual([]) + expect((await readPersistedTestAgentSessionStore(rig.root)).records).toEqual({}) + expect(loadTestJournal(rig.root, SESSION)?.state.submissions.size).toBe(0) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(publishTab).not.toHaveBeenCalled() + db.exec('DROP TRIGGER refuse_create') + const entered = abortableAcquisition() + expect( + await rig.host.create(CALLER, params, { firstMessage: first, publishTab }) + ).toMatchObject({ ok: true }) + await entered + await stopCreatedChat(rig.host) + } +) + +it('answers a journal founding failure as a refusal before publishing any create state', async () => { + const db = openTestJournalHostDatabase(rig.root).db + db.exec( + `CREATE TRIGGER refuse_epoch BEFORE INSERT ON journal_rows WHEN NEW.row_json LIKE '%"kind":"epoch"%' BEGIN SELECT RAISE(ABORT, 'simulated journal open failure'); END` + ) + const first = firstMessage() + expect( + await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_journal_unreadable' } }) + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it('retries tab publication from the committed message without acquiring early or appending again', async () => { + const first = firstMessage() + const params = createTestParams(first) + expect( + await rig.host.create(CALLER, params, { + firstMessage: first, + publishTab: async () => { + throw new Error('tab unavailable') + } + }) + ).toMatchObject({ ok: false, refusal: { details: { reason: 'tabUnconfirmed' } } }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + const append = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') + const entered = abortableAcquisition() + expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ + ok: true, + replayed: true + }) + expect(append).not.toHaveBeenCalled() + await entered + await stopCreatedChat(rig.host) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts new file mode 100644 index 00000000000..5f3510d2029 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -0,0 +1,285 @@ +import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { + refuse, + type AgentSessionAttachResult, + type AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { journalOpenRefusalError } from '../agent-session-journal/journal-open-failure' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { reserveRequestFor, type AgentSessionAttachParams } from './structured-agent-session-attach' +import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' +import { + prepareAdoptedTranscript, + importAdoptedTranscript +} from './structured-agent-session-adopted-import' +import { + pinnedAgentSessionLaunchArgs, + pinnedAgentSessionLaunchEnv +} from './structured-agent-session-launch-env' +import { + adapterSupportsCreateIfDeclared, + hostCanStartRecord +} from './structured-agent-session-provider-support' +import { performSend } from './structured-agent-session-turns' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' + +export type StructuredAgentSessionCreateOptions = { + firstMessage?: { clientMessageId: string; body: AgentJournalMessageItem } + hostLaunchDirectory?: string + /** Called after the durable create, before delivery can start the agent. */ + publishTab?: () => Promise +} + +/** Founds a chat and its opening message; only the delivery loop acquires the agent. */ +export function createStructuredAgentSession( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionCreateOptions = {} +): Promise> { + return context.tasks.trackAttach( + context.serialize(params.envelope.sessionId, async () => { + try { + return await foundConversation(context, callerKey, params, options) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { ok: false, refusal: error.refusal } + } + throw error + } + }) + ) +} + +async function foundConversation( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionCreateOptions +): Promise> { + const { deps } = context + const { store } = deps + const sessionId = params.envelope.sessionId + const prior = store.getRecord(sessionId) + if (params.envelope.expectedRuntimeFence !== null || params.agent !== params.provider) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + 'The create request is malformed.' + ) + } + } + if ( + prior + ? !hostCanStartRecord(deps, prior) + : !deps.agents.definition(params.agent) || + !adapterSupportsCreateIfDeclared(deps.adapter, params.location, params.agent) + ) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested chat.' + ) + } + } + const launchDirectory = + !prior && isFloatingWorkspaceId(params.location.workspaceId) + ? (options.hostLaunchDirectory ?? + (await deps.resolveWorkspacePath?.(params.location.workspaceId))) + : undefined + if (!prior && isFloatingWorkspaceId(params.location.workspaceId) && !launchDirectory) { + throw new Error('floating_agent_session_launch_directory_unavailable') + } + const request = reserveRequestFor({ + sessionId, + params, + callerKey, + fingerprint: params.envelope.payloadFingerprint, + now: context.now(), + authority: { + ...(launchDirectory ? { launchDirectory } : {}), + spawnToken: () => { + throw new Error('founding_must_not_reserve_a_spawn') + }, + claimKeyId: deps.claimKeyId, + handoffOperationId: null, + probe: { outcome: 'pid-absent' }, + ...(await pinnedAgentSessionLaunchArgs(deps.resolveLaunchArgs, params)), + ...(await pinnedAgentSessionLaunchEnv(deps.resolveLaunchEnv, params)) + } + }) + const candidate = store.founding.prepare(request) + if (!hostCanStartRecord(deps, candidate.record)) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested structured agent session.' + ) + } + } + const replayed = candidate.disposition === 'replayed' + if (replayed && candidate.operationRow.outcome.status !== 'succeeded') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: params.envelope.clientOperationId, + outcome: candidate.operationRow.outcome, + reconstruct: () => null + }) + return { + ok: false, + refusal: + replay.decision === 'refuse' + ? replay.refusal + : refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + 'The create outcome is unknown.' + ) + } + } + const prepared = replayed + ? { ok: true as const, items: null } + : await prepareAdoptedTranscript(params) + if (!prepared.ok) { + return prepared + } + const existing = context.sessions.get(sessionId) + const opened = existing + ? { session: existing } + : await openStructuredAgentSessionConversationJournal(deps, candidate.record).catch( + (error: unknown) => { + deps.logger.warn('opening the journal of a new chat failed', { + scope: 'create-journal-open', + sessionId, + error + }) + throw journalOpenRefusalError(error) + } + ) + const { session } = opened + const fence = candidate.record.lease.runtimeFence + let committed = replayed + try { + if (!replayed) { + await importAdoptedTranscript( + params, + { journal: session.journal, unconfirmedClientMessageIds: [] }, + candidate.record, + prepared.items, + { uncommittedCreate: true } + ) + const first = options.firstMessage + if (first) { + const sent = await performSend( + { + sessionId, + journal: session.journal, + fence, + adapter: deps.adapter, + agents: deps.agents, + agent: params.agent, + logger: deps.logger, + resolvedBy: callerKey, + persistOptions: async () => {}, + publish: () => {}, + operationReceipt: store.founding.receipt(request), + now: context.now + }, + { + ...first, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body: first.body } + }), + origin: 'client', + source: { kind: 'user' } + } + ) + if (!sent.ok) { + return sent + } + } else { + await store.founding.commit(request) + } + committed = true + } + try { + await options.publishTab?.() + } catch (error) { + deps.logger.warn('publishing the tab of a created chat failed', { + scope: 'create-tab-publication', + sessionId, + error + }) + if (!existing) { + context.sessions.set(sessionId, session) + } + return { + ok: false, + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'tabUnconfirmed' }, + 'The chat may have been created, but its tab could not be confirmed.' + ) + } + } + if (!existing) { + context.sessions.set(sessionId, session) + } + context.publishStatus(sessionId) + const tabId = store.getSessionTabId(sessionId) + const firstMessage = options.firstMessage + ? session.journal.submission(options.firstMessage.clientMessageId) + : undefined + const result = { + ok: true as const, + replayed, + fence, + cursor: session.journal.cursor(), + value: { + sessionId, + fence, + page: readAgentSessionHydrationPage(session.journal, fence), + unconfirmedClientMessageIds: [], + ...(firstMessage ? { firstMessage } : {}), + ...(tabId ? { tabId } : {}) + } + } + context.wakeDelivery?.(sessionId) + return result + } finally { + if (!committed && !existing) { + await session.journal.close() + } + } +} + +/** Host entry points that open a chat: attach an existing one or create a new one. */ +export function structuredAgentSessionEntryDelegates( + context: () => StructuredAgentSessionAttachContext +) { + return { + attach: ( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams, + options?: Parameters[3] + ) => attachStructuredAgentSession(context(), caller.callerKey, params, options), + create: ( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams, + options?: StructuredAgentSessionCreateOptions + ) => createStructuredAgentSession(context(), caller.callerKey, params, options) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index c7e2e039078..ce3a5093888 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -1,7 +1,4 @@ -import type { - AgentJournalMessageItem, - AgentJournalSnapshot -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' @@ -32,7 +29,7 @@ export function hostTestOperationId(): string { return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}` } -export function hostTestMessage(text: string): AgentJournalMessageItem { +export function hostTestMessage(text: string): ReturnType { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 251ccc88e32..072bb865c34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -9,7 +9,6 @@ import { StructuredConversationCommandController } from './structured-conversati import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' @@ -17,7 +16,7 @@ import * as providerSupport from './structured-agent-session-provider-support' import * as reveal from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { structuredAgentSessionEntryDelegates } from './structured-agent-session-create' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' import { @@ -244,13 +243,9 @@ export class StructuredAgentSessionHost { private serialize = this.tasks.serialize.bind(this.tasks) - attach( - caller: StructuredAgentSessionCaller, - params: AgentSessionAttachParams, - options?: Parameters[3] - ): Promise> { - return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params, options) - } + private readonly entry = structuredAgentSessionEntryDelegates(() => this.attachContext()) + attach = this.entry.attach + create = this.entry.create /** Test barrier: every write has landed by its call's return, so no production path needs it. */ flushStreamedEvents = (id: string): Promise => this.runtimeState.flushEventSink(id) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 9b370959be0..7810ace2567 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -24,6 +24,23 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick +/** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ +export function structuredAgentSessionOptionOverridesRefusal( + agents: Pick, + provider: string, + options: Readonly> +) { + const rules = agents.definition(provider)?.restingOptions + const key = Object.keys(options).find((key) => !rules?.acceptsKey(key)) + return key === undefined + ? null + : refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${provider} has no session option named ${key}` + ) +} + /** The at-rest rules of the record's agent, as this runtime registered it; null for any other. */ function restingOptionRules( agents: Pick, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 786c7360e46..a35b13db74e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' @@ -22,11 +22,13 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, hostTestAttachParams, + hostTestMessage, resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { createTestParams } from './structured-agent-session-create-test-fixture' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -54,6 +56,13 @@ beforeEach(async () => { applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, sources: {} + }, + // What a chat's options read lists; startup lists from initialize, which names only Sonnet. + routes: { + list_models: () => [ + { value: 'claude-sonnet', displayName: 'Sonnet' }, + { value: 'claude-opus-9', displayName: 'Opus 9' } + ] } }) adapter = new ClaudeStructuredSessionAdapter({ @@ -189,4 +198,31 @@ describe('a publish-first Claude create whose init is slow', () => { } ]) }) + it('reports the configured default from a chat created with its first message and no pick', async () => { + const first = { clientMessageId: 'opening', body: hostTestMessage('hello') } + await expect( + host.create(CALLER, createTestParams(first, claudeParams()), { firstMessage: first }) + ).resolves.toMatchObject({ ok: true }) + + // Delivery, not create, starts the CLI. Its `started` event carries the account listing; the + // settings read after it (`options-reported`) is what resolves the configured model, so the + // chat's options read from then on names it, since nothing was picked. + await vi.waitFor(() => expect(savedListings).toHaveLength(1)) + expect(savedListings[0]).toEqual({ + sessionId: SESSION, + listing: expect.objectContaining({ + models: [expect.objectContaining({ id: 'claude-sonnet' })] + }) + }) + await claudeStartupSettled(adapter, SESSION) + await Promise.all(lifecycle) + expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + await host.readOptions(SESSION) + expect(savedListings.at(-1)).toEqual({ + sessionId: SESSION, + listing: expect.objectContaining({ + configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) + }) + }) + }) }) diff --git a/src/main/runtime/agent-session-founding-admission.ts b/src/main/runtime/agent-session-founding-admission.ts new file mode 100644 index 00000000000..0d83131c322 --- /dev/null +++ b/src/main/runtime/agent-session-founding-admission.ts @@ -0,0 +1,61 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import { + assertAdoptedConversationUnowned, + assertReservedTabUnheld, + evaluateAgentSessionReserveOperation, + requireAgentSessionRecordForReplay, + validateAgentSessionReservationInput, + type AgentSessionReserveRequest, + type AgentSessionReserveResult +} from './agent-session-reservation-admission' +import type { AgentSessionStoreState } from './agent-session-store-state' +import { setAgentSessionTabVisibility } from './agent-session-tab-table' + +/** The same create admission as a reservation, with no obligation to acquire an owner. */ +export function commitAgentSessionFounding( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest +): AgentSessionReserveResult { + const decision = evaluateAgentSessionReserveOperation(state, request) + if (decision.decision === 'refused') { + throw agentSessionRefusalError(decision.code, decision.details) + } + if (decision.decision === 'replay') { + return { + record: requireAgentSessionRecordForReplay(state, decision.row, request.sessionId), + disposition: 'replayed', + operationRow: decision.row + } + } + validateAgentSessionReservationInput(request) + if (request.expectedFence !== null) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + if (state.unreadableRecords.has(request.sessionId)) { + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + } + if (state.records.has(request.sessionId)) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) + } + assertAdoptedConversationUnowned(state, request) + assertReservedTabUnheld(state, request) + const founded = foundAgentSessionRecord(request, request) + const record = request.adoptedHandleLink + ? { ...founded, providerHandleChain: [request.adoptedHandleLink] } + : founded + const operationRow = { + ...decision.row, + outcome: { status: 'succeeded' as const, sessionId: request.sessionId } + } + state.records.set(request.sessionId, record) + state.operations.set( + agentSessionOperationKey(operationRow.callerKey, operationRow.operationId), + operationRow + ) + setAgentSessionTabVisibility(state, request.sessionId, true, request.surfaceTabId) + return { record, disposition: 'created', operationRow } +} diff --git a/src/main/runtime/agent-session-founding-store.ts b/src/main/runtime/agent-session-founding-store.ts new file mode 100644 index 00000000000..d939ed9a7a2 --- /dev/null +++ b/src/main/runtime/agent-session-founding-store.ts @@ -0,0 +1,49 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' +import { commitAgentSessionFounding } from './agent-session-founding-admission' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import { draftAgentSessionStoreState } from './agent-session-store-draft' +import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' + +/** Founding shares the store's admission and publishes only after the message transaction commits. */ +export function createAgentSessionFoundingStore( + transactions: AgentSessionStoreTransactions, + firstRecordListeners: ReadonlySet<() => void> +) { + return { + prepare: (request: AgentSessionReserveRequest) => + commitAgentSessionFounding(draftAgentSessionStoreState(transactions.state), request), + commit: async (request: AgentSessionReserveRequest) => { + let heldBefore = true + const result = await transactions.transact((draft) => { + heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0 + return commitAgentSessionFounding(draft, request) + }) + if (!heldBefore) { + firstRecordListeners.forEach((listener) => listener()) + } + return result + }, + receipt: (request: AgentSessionReserveRequest): JournalOperationReceipt => { + let heldBefore = true + const receipt = transactions.receipt((draft) => { + heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0 + const result = commitAgentSessionFounding(draft, request) + if (result.disposition === 'replayed') { + throw agentSessionRefusalError('agent_session_operation_unknown', { + reason: 'outcomeUnknown' + }) + } + }) + return { + write: receipt.write, + committed: () => { + receipt.committed() + if (!heldBefore) { + firstRecordListeners.forEach((listener) => listener()) + } + } + } + } + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 1b4dd7bd9d6..9cca024aca1 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -73,6 +73,7 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import { createAgentSessionFoundingStore } from './agent-session-founding-store' import { compareAndSetAgentSessionRecordName, type CompareAndSetConversationName, @@ -93,6 +94,7 @@ export class AgentSessionRecordStore { private readonly transactions: AgentSessionStoreTransactions, readonly hostId: string ) { + this.founding = createAgentSessionFoundingStore(transactions, this.firstRecordListeners) this.conversationReceipts = createAgentSessionConversationReceipts(transactions) } @@ -193,11 +195,12 @@ export class AgentSessionRecordStore { isClaimKeyVerifiable = (keyId: string, now: number): boolean => isAgentSessionClaimKeyVerifiable(this.state, keyId, now) - async reserveOwner(request: AgentSessionReserveRequest): Promise { - return this.transact((draft) => + reserveOwner = (request: AgentSessionReserveRequest): Promise => + this.transact((draft) => commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS) ) - } + + readonly founding: ReturnType commitProcessIdentity = (args: AgentSessionProcessIdentityCommit): Promise => this.mutate(args.sessionId, (record) => commitAgentSessionProcessIdentity({ ...args, record })) @@ -232,10 +235,8 @@ export class AgentSessionRecordStore { args: AgentSessionFailedPostAcquisitionAttachmentSettlement ) => this.transact((draft) => settleFailedAgentSessionPostAcquisitionAttachment(draft, args)) - async renewLease(args: AgentSessionLeaseRenewal): Promise { - const [renewed] = await this.renewLeases([args]) - return renewed - } + renewLease = (args: AgentSessionLeaseRenewal): Promise => + this.renewLeases([args]).then(([renewed]) => renewed) async renewLeases(renewals: readonly AgentSessionLeaseRenewal[]): Promise { return this.transact((draft) => @@ -306,9 +307,8 @@ export class AgentSessionRecordStore { claimAfter: ClaimAfterAdmission ) => this.transact((draft) => admitAndClaimAgentSessionOperationInto(draft, args, claimAfter)) - async recordOperationOutcome(args: AgentSessionOperationSettlement): Promise { - await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) - } + recordOperationOutcome = (args: AgentSessionOperationSettlement): Promise => + this.transact((draft) => settleAgentSessionOperationInto(draft, args)) /** The same settlement, committed by the journal write that makes it true. It changes only the * ledger, so no record listener is owed. */ diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index ff840c755b3..eb956e4b34c 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -143,14 +143,7 @@ export function admitPendingAgentSessionReservationReplay( return record } -export function applyAgentSessionReservation( - state: AgentSessionStoreState, - request: AgentSessionReserveRequest, - leaseTtlMs: number -): { - record: AgentSessionRecord - disposition: Exclude -} { +export function validateAgentSessionReservationInput(request: AgentSessionReserveRequest): void { if (request.launchEnv && !isAgentSessionLaunchEnv(request.launchEnv)) { throw new Error('agent_session_launch_env_invalid') } @@ -160,6 +153,17 @@ export function applyAgentSessionReservation( if (request.options && !isAgentSessionOptions(request.options)) { throw new Error('agent_session_options_invalid') } +} + +export function applyAgentSessionReservation( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest, + leaseTtlMs: number +): { + record: AgentSessionRecord + disposition: Exclude +} { + validateAgentSessionReservationInput(request) const reservation: AgentSessionReservation = { spawnToken: typeof request.spawnToken === 'function' ? request.spawnToken() : request.spawnToken, @@ -228,7 +232,7 @@ export function applyAgentSessionReservation( * compare-and-swap never collides and both would pass. Codex permits two app-servers on one thread * silently, so the cost of missing this is a corrupted conversation rather than an error. */ -function assertAdoptedConversationUnowned( +export function assertAdoptedConversationUnowned( state: AgentSessionStoreState, request: AgentSessionReserveRequest ): void { @@ -257,7 +261,7 @@ function assertAdoptedConversationUnowned( * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never * gets that far leaves nothing in the table to restore or release. */ -function assertReservedTabUnheld( +export function assertReservedTabUnheld( state: AgentSessionStoreState, request: AgentSessionReserveRequest ): void { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts new file mode 100644 index 00000000000..a917eff7eef --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -0,0 +1,100 @@ +import type { z } from 'zod' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import type { RpcContext } from '../core' +import { + ensureStructuredHostInstalled as ensureHostInstalled, + requireStructuredHost as requireHost, + requireStructuredAgentAudience, + structuredCallerFor as callerFor +} from './structured-agent-session-gate' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' +import type { AttachParams, CreateParams } from './structured-agent-session-schemas' +import { + commitStructuredAgentSessionCreate, + prepareStructuredAgentSessionCreateForWorktree, + structuredAgentSessionCreateIntentFingerprint +} from './structured-agent-session-create' +import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' + +/** + * The attach-shaped entries take the location from the client instead of resolving it from a + * worktree, so they never reach the worktree-resolving create-support check. Ask the executing + * host the same question directly: only it knows which agents and locations it runs, and a client + * cannot forge that answer. + */ +async function resolveClientSuppliedAttach(params: z.infer, ctx: RpcContext) { + await ensureHostInstalled(ctx, params.agent) + const host = requireHost(ctx, params.agent) + if (!host.supportsCreate(params.location, params.agent)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + const attachParams: AgentSessionAttachParams = params + return { host, attachParams } +} + +export async function attachClientSuppliedLocation( + params: z.infer, + ctx: RpcContext +): Promise { + const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) + return host.attach(callerFor(ctx), attachParams) +} + +export async function handleStructuredAgentSessionCreate( + params: z.infer, + ctx: RpcContext +) { + requireStructuredAgentAudience(ctx, params.agent) + if (params.envelope.expectedRuntimeFence !== null) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so + // a client can tell "nothing was created" from "the outcome is unknown". + const prepared = await resolveUncommittedStructuredCreate(async () => { + if ('worktree' in params) { + const conflict = agentSessionFingerprintConflict( + params.envelope, + structuredAgentSessionCreateIntentFingerprint(params) + ) + if (conflict) { + return { refusal: conflict } + } + return prepareStructuredAgentSessionCreateForWorktree({ + runtime: ctx.runtime, + ensureHost: async () => { + await ensureHostInstalled(ctx) + return requireHost(ctx) + }, + envelope: params.envelope, + worktree: params.worktree, + agent: params.agent, + caller: callerFor(ctx), + ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), + ...(params.tabId ? { tabId: params.tabId } : {}), + ...(params.options ? { options: params.options } : {}), + ...(params.firstMessage ? { firstMessage: params.firstMessage } : {}), + atRest: Boolean( + params.firstMessage || + ctx.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + ) + }) + } + const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) + return { host, attachParams, tab: null } + }) + if ('refusal' in prepared) { + return { ok: false, refusal: prepared.refusal } + } + return commitStructuredAgentSessionCreate({ + runtime: ctx.runtime, + caller: callerFor(ctx), + prepared, + activate: true + }) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts new file mode 100644 index 00000000000..82307145453 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts @@ -0,0 +1,259 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { structuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' +import { + createRestTestRig, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_NOW as NOW, + hostTestMessage, + hostTestOperationId +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { call, runtimeCalls } from './structured-agent-session-rpc.test-fixture' +import { stopCreatedChat } from '../../../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { AgentSessionPreSpawnError } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' + +const legacy = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} +const capable = { + ...legacy, + clientCapabilities: [ + ...legacy.clientCapabilities, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ] +} +let rig: RestTestRig +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() + vi.restoreAllMocks() +}) + +function params(options?: Readonly>, withMessage = false) { + return structuredAgentSessionCreateParams({ + sessionId: SESSION, + worktree: 'id:workspace-1', + agent: 'codex', + randomUuid: () => '00000000-0000-0000-0000-0000000000fa', + now: NOW, + ...(options ? { options } : {}), + ...(withMessage + ? { + firstMessage: { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening') } + } + : {}) + }) +} + +it('a capable client creates a blank chat without acquire', async () => { + expect(await call('agentSession.create', params(), capable)).toMatchObject({ + ok: true, + result: { ok: true, value: { page: { submissions: [] } } } + }) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce() +}) + +it.each([ + { name: 'legacy', client: legacy }, + { name: 'capable', client: capable } +])( + 'a $name client receives its first-message receipt before handshake and on replay after Stop', + async ({ client }) => { + const input = params(undefined, true) + const entered = Promise.withResolvers() + rig.adapter.acquire.mockImplementationOnce( + ({ signal }) => + new Promise((_resolve, reject) => { + entered.resolve() + signal?.addEventListener( + 'abort', + () => reject(new AgentSessionPreSpawnError(signal.reason)), + { once: true } + ) + }) + ) + expect(await call('agentSession.create', input, client)).toMatchObject({ + ok: true, + result: { + ok: true, + value: { + firstMessage: { + clientMessageId: input.firstMessage?.clientMessageId, + dispatchState: 'pending' + }, + page: { submissions: [{ clientMessageId: input.firstMessage?.clientMessageId }] } + } + } + }) + await entered.promise + await stopCreatedChat(rig.host) + expect(await call('agentSession.create', input, client)).toMatchObject({ + ok: true, + result: { + ok: true, + replayed: true, + value: { + firstMessage: { + clientMessageId: input.firstMessage?.clientMessageId, + dispatchState: 'rejected', + rejection: { kind: 'cancelled' } + } + } + } + }) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + } +) + +it('the old client retains acquired create and waits for acquisition before publishing', async () => { + const acquired = rig.adapter.acquire.getMockImplementation() + if (!acquired) { + throw new Error('test acquire missing') + } + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + rig.adapter.acquire.mockImplementationOnce(async (input) => { + entered.resolve() + await release.promise + return acquired(input) + }) + let answered = false + const creating = call('agentSession.create', params(), legacy).then((result) => { + answered = true + return result + }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await entered.promise + expect(answered).toBe(false) + expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + release.resolve() + expect(await creating).toMatchObject({ ok: true, result: { ok: true } }) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() +}) + +it('replays an acquired create after the client negotiates create-message support', async () => { + const input = params() + expect(await call('agentSession.create', input, legacy)).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(await call('agentSession.create', input, capable)).toMatchObject({ + ok: true, + result: { ok: true, replayed: true, value: { sessionId: SESSION } } + }) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() +}) + +it('keeps an at-rest replay at rest when the client capability list changes', async () => { + const input = params() + expect(await call('agentSession.create', input, capable)).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(await call('agentSession.create', input, legacy)).toMatchObject({ + ok: true, + result: { ok: true, replayed: true } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') +}) + +it.each(['firstMessage', 'options'] as const)( + 'refuses adding %s to an already-acquired create operation', + async (field) => { + expect(await call('agentSession.create', params(), legacy)).toMatchObject({ + ok: true, + result: { ok: true } + }) + const changed = + field === 'firstMessage' ? params(undefined, true) : params({ model: 'held-model' }) + expect(await call('agentSession.create', changed, capable)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } + }) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([]) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + } +) + +it('merges held option overrides with host seeds before the first acquisition', async () => { + const input = params({ model: 'held-model' }) + expect(await call('agentSession.create', input, capable)).toMatchObject({ + ok: true, + result: { ok: true } + }) + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(await rig.host.send({ callerKey: 'client-1' }, restTestSend('first work'))).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect.objectContaining({ options: { model: 'held-model', effort: 'medium' } }) + ) +}) + +it('rejects an explicit option the resting agent would reject before founding', async () => { + expect( + await call('agentSession.create', params({ unknownOption: 'yes' }), capable) + ).toMatchObject({ + ok: true, + result: { ok: false, refusal: { details: { reason: 'optionRejected' } } } + }) + expect(rig.store.getRecord(SESSION)).toBeNull() + expect(rig.store.listOperationRows()).toEqual([]) + expect(rig.adapter.acquire).not.toHaveBeenCalled() +}) + +it.each(['options', 'firstMessage'] as const)( + 'rejects changes to firstMessage or explicit options under a replayed create operation: %s', + async (field) => { + const original = params({ model: 'held-model' }, true) + // Keep delivery held while reusing the create id with different intent. + expect( + await call('agentSession.create', original, capable, { + publishStructuredAgentSessionTab: async () => { + throw new Error('deferred tab') + } + }) + ).toMatchObject({ ok: true, result: { ok: false } }) + expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' }) + const first = original.firstMessage + if (!first) { + throw new Error('fixture first message missing') + } + expect((await rig.host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { clientMessageId: first.clientMessageId } + ]) + const changed = structuredAgentSessionCreateParams({ + sessionId: SESSION, + worktree: original.worktree, + agent: original.agent, + now: NOW, + randomUuid: () => '00000000-0000-0000-0000-0000000000fa', + options: field === 'options' ? { model: 'other-model' } : original.options, + firstMessage: + field === 'firstMessage' ? { ...first, body: hostTestMessage('changed') } : first + }) + expect(await call('agentSession.create', changed, capable)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index d1857652f2d..924450fa19c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -1,18 +1,6 @@ -/** - * Creating a structured session for a worktree: resolve the create intent, attach it under the - * host-computed fingerprint, then publish its tab. - * - * Extracted from `agentSession.create` so orchestration can start a native-born structured worker - * on exactly the same path. `activate` is the only knob the two callers differ on: a chat the user - * asked for takes the surface, a background dispatch must not steal it (the terminal worker path's - * `surfaceOwner: false`). - * - * The prepare/commit split is the pre-commit boundary, not a style choice: nothing before `attach` - * commits a session, so that span answers with a refusal, and nothing after it may be folded back - * in. Both callers run the same two halves, so orchestration gets that guarantee too. - */ +// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path. -import { refuse } from '../../../../shared/agent-session-wire-refusals' +import { refuse, AgentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { AgentSessionAttachResult, @@ -25,7 +13,11 @@ import { } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' -import type { StructuredAgentSessionResumeSource } from '../../../../shared/structured-agent-session-create' +import type { + StructuredAgentSessionResumeSource, + StructuredAgentSessionFirstMessage +} from '../../../../shared/structured-agent-session-create' +import { structuredAgentSessionOptionOverridesRefusal } from '../../../native-chat/agent-session-wire/structured-agent-session-options-read' import type { OrcaRuntimeService } from '../../orca-runtime' import type { StructuredAgentId } from '../../../../shared/agent-session-provider-handle' import { @@ -39,21 +31,19 @@ export type PreparedStructuredAgentSessionCreate = { hostLaunchDirectory?: string /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ tab: { workspaceId: string; agent: StructuredAgentId } | null + /** Absent for older clients and provider-dependent in-process callers. */ + atRest?: { firstMessage?: StructuredAgentSessionFirstMessage } } -/** - * What a client's create intent fingerprints, recomputed host-side. `resumeFrom` is part of the - * intent, not a detail of it: without it a retry of "adopt this conversation" would replay as, or - * conflict with, a blank create. `tabId` is covered so the declared digest spans the payload, but - * replay keys on the attach fingerprint, so a retry naming another tab is answered with the one the - * chat's tab holds. The canonicalizer drops `undefined`, so plain creates keep the digest they had. - */ +/** New creates keep the immutable client intent in the ledger; legacy creates keep attach identity. */ export function structuredAgentSessionCreateIntentFingerprint(params: { envelope: AgentSessionMutationEnvelope worktree: string agent: string resumeFrom?: StructuredAgentSessionResumeSource tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> }): string { return computeAgentSessionPayloadFingerprint({ method: 'agentSession.create', @@ -62,7 +52,9 @@ export function structuredAgentSessionCreateIntentFingerprint(params: { worktree: params.worktree, agent: params.agent, resumeFrom: params.resumeFrom, - tabId: params.tabId + tabId: params.tabId, + firstMessage: params.firstMessage, + options: params.options } }) } @@ -78,13 +70,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { agent: StructuredAgentId caller: StructuredAgentSessionCaller resumeFrom?: StructuredAgentSessionResumeSource - /** Replaces the seed options the host resolves from settings. Orchestration passes the - * `--model`/`--effort` the dispatch asked for; a chat the user opened passes nothing and keeps - * the saved selection. Narrowed by the caller, so `{}` never reaches the reservation. */ + /** Explicit picks override seed keys at rest; acquired in-process launches replace the seed. */ options?: Readonly> /** The tab id the caller reserved for this chat, taken when its tab is published; absent, the tab * gets the id clients derive. Beside `options`, after the fingerprint, likewise. */ tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + atRest?: boolean }): Promise { // Adoption replay may need the record loaded from disk before source discovery can be skipped. let host = args.resumeFrom ? await args.ensureHost() : null @@ -95,12 +87,36 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { callerKey: args.caller.callerKey, ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}) }) - const hostFingerprint = computeAgentSessionPayloadFingerprint({ + const attachFingerprint = computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', sessionId: args.envelope.sessionId, fields: attachFingerprintFields({ ...resolved, envelope: args.envelope }) }) host ??= await args.ensureHost() + const priorOperation = + args.atRest === undefined + ? null + : host.deps.store.getOperationRow(args.caller.callerKey, args.envelope.clientOperationId) + // A capability update cannot change the contract of an already-recorded create. + const legacyReplay = + args.firstMessage === undefined && + args.options === undefined && + priorOperation?.fingerprint === attachFingerprint + const atRest = + args.atRest !== undefined && + (priorOperation?.fingerprint === args.envelope.payloadFingerprint || + (args.atRest === true && !legacyReplay)) + const hostFingerprint = atRest ? args.envelope.payloadFingerprint : attachFingerprint + if (atRest && args.options) { + const refusal = structuredAgentSessionOptionOverridesRefusal( + host.deps.agents, + args.agent, + args.options + ) + if (refusal) { + throw new AgentSessionRefusalError(refusal) + } + } const { agent: _resolvedAgent, provider: _resolvedProvider, @@ -112,15 +128,16 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { ...(hostLaunchDirectory ? { hostLaunchDirectory } : {}), attachParams: { ...resolvedAttach, - // After the fingerprint, deliberately: `attachFingerprintFields` excludes options because - // they are the session's initial state, not its identity, so a retry that re-resolves them - // must replay rather than conflict. - ...(args.options ? { options: args.options } : {}), + // The ledger covers explicit overrides, never defaults that settings can change on replay. + ...(args.options + ? { options: atRest ? { ...resolved.options, ...args.options } : args.options } + : {}), ...(args.tabId ? { surfaceTabId: args.tabId } : {}), provider: resolved.provider, agent: resolved.agent, envelope: { ...args.envelope, payloadFingerprint: hostFingerprint } }, + ...(atRest ? { atRest: args.firstMessage ? { firstMessage: args.firstMessage } : {} } : {}), tab: { workspaceId: resolved.location.workspaceId, agent: resolved.agent @@ -136,6 +153,29 @@ export async function commitStructuredAgentSessionCreate(args: { activate: boolean }): Promise> { const { prepared } = args + const tab = prepared.tab + if (prepared.atRest) { + return prepared.host.create(args.caller, prepared.attachParams, { + ...prepared.atRest, + ...(prepared.hostLaunchDirectory + ? { hostLaunchDirectory: prepared.hostLaunchDirectory } + : {}), + ...(tab + ? { + publishTab: () => + args.runtime.publishStructuredAgentSessionTab({ + workspaceId: tab.workspaceId, + sessionId: prepared.attachParams.envelope.sessionId, + agent: tab.agent, + activate: args.activate, + ...(prepared.attachParams.surfaceTabId + ? { tabId: prepared.attachParams.surfaceTabId } + : {}) + }) + } + : {}) + }) + } const result = prepared.hostLaunchDirectory ? await prepared.host.attach(args.caller, prepared.attachParams, { hostLaunchDirectory: prepared.hostLaunchDirectory diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index e837a7efdb0..6675ab4a1f0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -51,10 +51,13 @@ function hostStub(): StructuredAgentSessionHost { cursor: { epoch: 'epoch-a', sequence: 0 }, value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] } })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create reaches only attach, and the logger a failure past it reports to. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: legacy create reads the empty ledger, calls attach and logs failures. return { attach, - deps: { logger: recordingStructuredAgentSessionLogger().logger } + deps: { + logger: recordingStructuredAgentSessionLogger().logger, + store: { getOperationRow: vi.fn(() => null) } + } } as unknown as StructuredAgentSessionHost } @@ -201,6 +204,7 @@ describe('the boundary the envelope stops at', () => { expect(response).toMatchObject({ ok: false, error: { code: 'runtime_error' } }) expect(refusalOf(response)).toBeNull() + expect(attach).toHaveBeenCalledOnce() }) it('leaves a committed create whose tab could not be published unknown', async () => { @@ -213,6 +217,7 @@ describe('the boundary the envelope stops at', () => { const refusal = refusalOf(response) expect(refusal?.code).toBe('agent_session_operation_unknown') expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(false) + expect(attach).toHaveBeenCalledOnce() }) it('keeps hiding the surface from a client that never advertised it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a24d21e6938..dfda59b17b3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -212,7 +212,8 @@ export function hostStub(): StructuredAgentSessionHost { unsubscribe: vi.fn() }) // Not a call: the logger the host hands a runtime caller that reports for it. - Reflect.set(hostCalls, 'deps', { logger: recordingStructuredAgentSessionLogger().logger }) + const logger = recordingStructuredAgentSessionLogger().logger + Reflect.set(hostCalls, 'deps', { logger, store: { getOperationRow: vi.fn(() => null) } }) return hostCalls as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 4d22e9ff2a2..37b9e62f960 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -5,9 +5,6 @@ // not exist rather than receiving the journal or mutation surface. Session-tab // inventory may expose only a metadata placeholder for an incapable mobile client. -import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' -import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' -import type { z } from 'zod' import { projectBackgroundTaskEvent, projectBackgroundTaskHistory @@ -16,30 +13,25 @@ import { projectTurnItemEvent, projectTurnItemHistory } from './structured-agent-session-turn-item-capability' -import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, - requireStructuredAgentAudience, requireStructuredCleanupHost, requireStructuredCreateSupportAdmission, - requireStructuredHost as requireHost, requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' -import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { - commitStructuredAgentSessionCreate, - prepareStructuredAgentSessionCreateForWorktree, - structuredAgentSessionCreateIntentFingerprint -} from './structured-agent-session-create' + handleStructuredAgentSessionCreate, + attachClientSuppliedLocation +} from './structured-agent-session-create-handler' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS } from './structured-agent-session-prompt-response' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' -import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' import { bindStructuredAgentSessionStream, STRUCTURED_AGENT_SESSION_STATUS_METHODS @@ -67,37 +59,6 @@ import { } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' -/** - * The attach-shaped entries take the location from the client instead of resolving it from a - * worktree, so they never reach the worktree-resolving create-support check. Ask the executing - * host the same question directly: only it knows which agents and locations it runs, and a client - * cannot forge that answer. - */ -async function resolveClientSuppliedAttach(params: z.infer, ctx: RpcContext) { - await ensureHostInstalled(ctx, params.agent) - const host = requireHost(ctx, params.agent) - if (!host.supportsCreate(params.location, params.agent)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) - } - const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params - const attachParams = { - ...attachWithoutAgent, - provider: params.provider as 'claude' | 'codex', - agent: params.agent as 'claude' | 'codex' - } as AgentSessionAttachParams - return { host, attachParams } -} - -async function attachClientSuppliedLocation( - params: z.infer, - ctx: RpcContext -): Promise { - const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) - return host.attach(callerFor(ctx), attachParams) -} - export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', @@ -142,51 +103,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.create', permission: 'workspace', params: CreateParams, - handler: async (params, ctx) => { - requireStructuredAgentAudience(ctx, params.agent) - if (params.envelope.expectedRuntimeFence !== null) { - throw agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'requestMalformed' - }) - } - // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so - // a client can tell "nothing was created" from "the outcome is unknown". - const prepared = await resolveUncommittedStructuredCreate(async () => { - if ('worktree' in params) { - const conflict = agentSessionFingerprintConflict( - params.envelope, - structuredAgentSessionCreateIntentFingerprint(params) - ) - if (conflict) { - return { refusal: conflict } - } - return prepareStructuredAgentSessionCreateForWorktree({ - runtime: ctx.runtime, - ensureHost: async () => { - await ensureHostInstalled(ctx) - return requireHost(ctx) - }, - envelope: params.envelope, - worktree: params.worktree, - agent: params.agent, - caller: callerFor(ctx), - ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), - ...(params.tabId ? { tabId: params.tabId } : {}) - }) - } - const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) - return { host, attachParams, tab: null } - }) - if ('refusal' in prepared) { - return { ok: false, refusal: prepared.refusal } - } - return commitStructuredAgentSessionCreate({ - runtime: ctx.runtime, - caller: callerFor(ctx), - prepared, - activate: true - }) - } + handler: handleStructuredAgentSessionCreate }), defineMethod({ name: 'agentSession.ensure', diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx new file mode 100644 index 00000000000..d7f0a5ac4fc --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.create-message.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { NativeChatComposerNotices } from './NativeChatComposerNotices' +import { structuredSessionNotices } from './native-chat-structured-session-notices' + +afterEach(cleanup) + +it.each(['failed', 'visibility-unknown'] as const)( + 'says the create hand-back failure once and keeps Retry for %s', + (lifecycle) => { + const retry = vi.fn() + const message = 'The message is back in the composer.' + const notices = structuredSessionNotices({ + launch: { lifecycle, failure: null, retry }, + agentLabel: 'Codex', + sessionError: message, + composerError: null + }) + render() + expect(screen.getAllByText(message)).toHaveLength(1) + expect(screen.queryByText('Chat could not be started.')).toBeNull() + expect(screen.queryByText('Chat connection could not be confirmed.')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(retry).toHaveBeenCalledOnce() + } +) + +it('keeps unrelated composer errors beside the hand-back notice', () => { + render( + {} }, + agentLabel: 'Codex', + sessionError: 'The message is back in the composer.', + composerError: { text: 'Attachment could not be added.', onDismiss: () => {} } + })} + /> + ) + expect(screen.getByText('The message is back in the composer.')).toBeTruthy() + expect(screen.getByText('Attachment could not be added.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index c6dcd6abd89..6f28e7b6990 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -101,8 +101,17 @@ export function structuredSessionNotices({ }) return [ ...(availability ? [availability] : []), - ...(launchNotice ? [launchNotice] : []), - ...(sessionError ? [{ key: 'session', kind: 'error' as const, text: sessionError }] : []), + ...(launchNotice && !sessionError ? [launchNotice] : []), + ...(sessionError + ? [ + { + key: 'session', + kind: 'error' as const, + text: sessionError, + ...(launchNotice ? { action: launchNotice.action } : {}) + } + ] + : []), ...(composerError ? [{ key: 'composer-error', kind: 'error' as const, ...composerError }] : []) ] } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 3aacca13263..c47551febc9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { - AgentJournalMessageItem, AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' @@ -19,7 +18,7 @@ import type { StructuredAgentSessionPendingSend } from './structured-agent-sessi const SENDING = 'Sending…' -function body(text: string): AgentJournalMessageItem { +function body(text: string): StructuredAgentSessionPendingSend['body'] { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index 87d96fd97df..b554778544e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -9,23 +9,18 @@ // one request, and whatever its answer, nothing sends it again. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionFirstMessage } from '../../../../shared/structured-agent-session-create' import { agentSessionUnconfirmedSendParts, - agentSessionWriteNoticeParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' -import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { attemptStructuredAgentSessionSend, forgetStructuredAgentSessionFence, resetStructuredAgentSessionFencesForTests } from './structured-agent-session-send-attempt' -import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { nativeChatRewindReasonCopy } from './native-chat-rewind-copy' -import { handBackStructuredAgentSessionMessage } from './structured-agent-session-message-hand-back' import { takeStructuredAgentSessionSendSlot, type StructuredAgentSessionSendInput @@ -35,108 +30,26 @@ import { findStructuredAgentSessionPendingSend, getStructuredAgentSessionPendingSends, publishStructuredAgentSessionSends, - structuredAgentSessionSendsWatched, structuredAgentSessionsWithPendingSends, updateStructuredAgentSessionPendingSend, type StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' - -/** From the moment a message is sent: past it, the message goes back to the composer, saying it - * was not sent if it never went out, and that nobody could confirm it if it did. */ -export const STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS = 30_000 - -/** `returned`: back in the composer, not sent. `unconfirmed`: back in the composer, though the host - * may hold it. */ -export type StructuredAgentSessionSendOutcome = 'recorded' | 'returned' | 'unconfirmed' | 'dropped' - -type SendRuntime = { - target: RuntimeClientTarget - abort: AbortController - deadline: ReturnType - resolve: (outcome: StructuredAgentSessionSendOutcome) => void -} - -const runtimes = new Map() - -/** Ends a send for good: the entry leaves (or stays as `recorded`) and its caller is answered. */ -function finish( - entry: StructuredAgentSessionPendingSend, - outcome: StructuredAgentSessionSendOutcome, - keep?: Partial -): void { - const runtime = runtimes.get(entry.clientMessageId) - if (runtime) { - clearTimeout(runtime.deadline) - runtimes.delete(entry.clientMessageId) - runtime.resolve(outcome) - } - updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, keep ?? null) -} - -/** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ -function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { - try { - handBackStructuredAgentSessionMessage( - entry.sessionId, - entry.clientMessageId, - entry.body, - entry.imageConnectionIds - ) - return true - } catch (error) { - console.error('[native-chat-send] a message could not be put back in the composer', error) - return false - } -} - -function handBack( - entry: StructuredAgentSessionPendingSend, - notice: readonly AgentSessionWriteNoticePart[] | null -): void { - try { - if (entry.callerKeepsText) { - return - } - const parts: readonly AgentSessionWriteNoticePart[] | null = returnToComposer(entry) - ? notice - : [...(notice ?? []), 'messageNotSaved'] - if (parts) { - publishStructuredAgentSessionSends(entry.sessionId, { - notice: agentSessionWriteNoticeText([...parts]) - }) - } - } finally { - // Bookkeeping never holds the chat: whatever the hand-back met, the send ends. - finish(entry, notice?.includes('sendOutcomeLost') ? 'unconfirmed' : 'returned') - } -} - -/** Settled by the host's answer, from the send's own reply or the journal, whichever comes first. */ -function settleRecorded( - entry: StructuredAgentSessionPendingSend, - submission: AgentJournalSubmission | null, - from: 'reply' | 'journal' -): void { - // A message a Stop took back stays in the chat with its stop row, drawn by the host's row; the - // composer is left alone. An open chat draws a recorded one until its row arrives, which can - // trail the reply. - const keep = - from === 'reply' && - !(submission && dispatchWasWithdrawn(submission)) && - structuredAgentSessionSendsWatched(entry.sessionId) - finish(entry, 'recorded', keep ? { phase: 'recorded', issued: true } : undefined) -} - -/** Why the host turned the send away. Behind a rewind whose outcome is unknown it was not sent: the - * rewind's words say why, and the next send has the host check it first. */ -function refusedSendParts(failure: AgentSessionWriteFailure): AgentSessionWriteNoticePart[] { - return failure.kind === 'refused' && failure.details?.reason === 'rewindUnconfirmed' - ? [ - { text: nativeChatRewindReasonCopy('outcome-unknown') }, - ...agentSessionWriteNotDoneParts('composer-send') - ] - : agentSessionWriteNoticeParts(failure, 'composer-send') -} +import { + beginSend, + finish, + handBack, + refusedSendParts, + runtimes, + settleRecorded, + STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS, + type StructuredAgentSessionSendOutcome, + type StructuredAgentSessionSent +} from './structured-agent-session-send-runtime' +export { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from './structured-agent-session-send-runtime' +export type { + StructuredAgentSessionSendOutcome, + StructuredAgentSessionSent +} from './structured-agent-session-send-runtime' /** The send's one request, and what its answer settles. Nothing is ever sent again. */ async function attempt(entry: StructuredAgentSessionPendingSend): Promise { @@ -169,45 +82,13 @@ async function attempt(entry: StructuredAgentSessionPendingSend): Promise } } -function onDeadline(sessionId: string, clientMessageId: string): void { - const entry = findStructuredAgentSessionPendingSend(sessionId, clientMessageId) - if (!entry || entry.phase === 'recorded') { - return - } - runtimes.get(clientMessageId)?.abort.abort() - // One that went out may still land: its row then shows it beside the text given back. - handBack( - entry, - entry.issued - ? ['sendOutcomeLost'] - : ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')] - ) -} - -export type StructuredAgentSessionSent = { - clientMessageId: string - outcome: Promise -} - -/** Sends a message holding its chat's slot; its 30 s start now. */ function dispatch( entry: StructuredAgentSessionPendingSend, target: RuntimeClientTarget ): StructuredAgentSessionSent { - const { clientMessageId, sessionId } = entry - const outcome = new Promise((resolve) => { - runtimes.set(clientMessageId, { - target, - abort: new AbortController(), - deadline: setTimeout( - () => onDeadline(sessionId, clientMessageId), - STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - ), - resolve - }) - }) + const sent = beginSend(entry, target) void attempt(entry) - return { clientMessageId, outcome } + return sent } /** @@ -224,12 +105,27 @@ export function sendStructuredAgentSessionMessage( } export type StructuredAgentSessionReservedSend = { + outcome: Promise + /** Older creates wait for acquisition; their send budget still begins at send. */ + legacy: () => void /** Sends it once the chat exists; null when the reservation was released or dropped meanwhile. */ send: (target: RuntimeClientTarget) => StructuredAgentSessionSent | null + /** The same message, owned by create instead of a later send request. */ + create: (target: RuntimeClientTarget) => StructuredAgentSessionCreateSend | null /** Gives the slot back unsent. */ release: () => void } +export type StructuredAgentSessionCreateSend = StructuredAgentSessionSent & { + message: StructuredAgentSessionFirstMessage + settle: ( + result: + | { kind: 'recorded'; submission: AgentJournalSubmission } + | { kind: 'refused'; failure?: AgentSessionWriteFailure } + | { kind: 'unconfirmed' } + ) => void +} + /** * A launch's prompt holds its chat's one send from the click: drawn as sending, so nothing typed * meanwhile overtakes it, and sent once the chat exists. Null while the chat already has a send out. @@ -243,17 +139,81 @@ export function reserveStructuredAgentSessionSend( } // Once sent, the entry is the send's: what it keeps after settling is not the reservation's. let sent = false + let settleReservation = (_outcome: StructuredAgentSessionSendOutcome): void => {} + const outcome = new Promise((resolve) => { + settleReservation = resolve + }) const held = (): StructuredAgentSessionPendingSend | undefined => sent ? undefined : findStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId) + const deadline = setTimeout(() => { + const current = held() + if (current) { + handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) + settleReservation('returned') + } + }, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) return { + outcome, + legacy: () => clearTimeout(deadline), send: (target) => { const current = held() sent = true - return current ? dispatch(current, target) : null + clearTimeout(deadline) + const sending = current ? dispatch(current, target) : null + if (sending) { + void sending.outcome.then(settleReservation) + } + return sending + }, + create: (target) => { + const current = held() + if (!current) { + return null + } + sent = true + clearTimeout(deadline) + const remaining = STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - (Date.now() - current.queuedAt) + if (remaining <= 0) { + handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) + settleReservation('returned') + return null + } + const sending = beginSend(current, target, remaining) + void sending.outcome.then(settleReservation) + updateStructuredAgentSessionPendingSend(current.sessionId, current.clientMessageId, { + issued: true + }) + return { + ...sending, + message: { clientMessageId: current.clientMessageId, body: current.body }, + settle: (result) => { + const pending = findStructuredAgentSessionPendingSend( + current.sessionId, + current.clientMessageId + ) + if (!pending || pending.phase === 'recorded' || !runtimes.has(current.clientMessageId)) { + return + } + if (result.kind === 'recorded') { + settleRecorded(pending, result.submission, 'reply') + } else { + handBack( + pending, + result.kind === 'unconfirmed' + ? agentSessionUnconfirmedSendParts(undefined) + : result.failure + ? refusedSendParts(result.failure) + : agentSessionWriteNotDoneParts('composer-send') + ) + } + } + } }, release: () => { + clearTimeout(deadline) if (held()) { updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, null) + settleReservation('dropped') } } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts index cf9e66a0db8..24ee3073779 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts @@ -1,12 +1,12 @@ // Each chat's sends the host has not answered yet, in memory only: what the sender keeps and the // chat's view reads. -import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' +import type { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation' export type StructuredAgentSessionPendingSend = { clientMessageId: string sessionId: string - body: AgentJournalMessageItem + body: ReturnType previewUris: readonly string[] queuedAt: number delivery?: 'queue-if-active' diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts index 946ffbf966b..00d93bdd8f8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-registry.ts @@ -11,6 +11,20 @@ export function findStructuredAgentSessionReadOwner( ): StructuredAgentSessionReadOwner | undefined { return owners.get(structuredReadOwnerKey(sessionId, target)) } +/** A closed chat no longer needs an unmounted create page or an active reader. */ +export function retireStructuredAgentSessionReadOwner( + sessionId: string, + target: RuntimeClientTarget +): void { + const key = structuredReadOwnerKey(sessionId, target) + const owner = owners.get(key) + owners.delete(key) + try { + owner?.dispose() + } catch (error) { + console.warn('[native-chat] retiring a conversation reader failed', error) + } +} export function getOrCreateStructuredReadOwner( key: string, create: () => StructuredAgentSessionReadOwner diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index eb08719aba0..ac45d2c0cbf 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -3,6 +3,7 @@ import type { AgentProviderSessionMetadata } from '../../../../shared/agent-sess import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import { AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryPage, type AgentSessionHistoryResult } from '../../../../shared/agent-session-wire' import { @@ -30,6 +31,7 @@ export type StructuredAgentSessionReadSnapshot = { } export type StructuredAgentSessionReadOwner = { + publishCreated: (page: AgentSessionHistoryPage) => void activate: () => () => void dispose: () => void getSnapshot: () => StructuredAgentSessionReadSnapshot @@ -45,6 +47,7 @@ import { } from './structured-agent-session-read-owner-registry' export { findStructuredAgentSessionReadOwner, + retireStructuredAgentSessionReadOwner, resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner-registry' @@ -218,6 +221,11 @@ function createReadOwner( } } owner = { + publishCreated: (page) => { + if (snapshot.state.epoch === null) { + apply({ type: 'history-page', page }) + } + }, activate: () => { // Remounts and StrictMode re-run setup on an owner whose cleanup just forgot it. adoptStructuredReadOwner(key, owner) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts new file mode 100644 index 00000000000..fe8cfafa58c --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts @@ -0,0 +1,150 @@ +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { + agentSessionWriteNoticeParts, + agentSessionWriteNotDoneParts +} from '../../../../shared/agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' +import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' +import { nativeChatRewindReasonCopy } from './native-chat-rewind-copy' +import { handBackStructuredAgentSessionMessage } from './structured-agent-session-message-hand-back' +import { + findStructuredAgentSessionPendingSend, + publishStructuredAgentSessionSends, + structuredAgentSessionSendsWatched, + updateStructuredAgentSessionPendingSend, + type StructuredAgentSessionPendingSend +} from './structured-agent-session-pending-sends' + +export const STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS = 30_000 + +export type StructuredAgentSessionSendOutcome = 'recorded' | 'returned' | 'unconfirmed' | 'dropped' + +type SendRuntime = { + target: RuntimeClientTarget + abort: AbortController + deadline: ReturnType + resolve: (outcome: StructuredAgentSessionSendOutcome) => void +} + +export const runtimes = new Map() + +/** Ends a send for good: the entry leaves (or stays as `recorded`) and its caller is answered. */ +export function finish( + entry: StructuredAgentSessionPendingSend, + outcome: StructuredAgentSessionSendOutcome, + keep?: Partial +): void { + const runtime = runtimes.get(entry.clientMessageId) + if (runtime) { + clearTimeout(runtime.deadline) + runtimes.delete(entry.clientMessageId) + runtime.resolve(outcome) + } + updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, keep ?? null) +} + +/** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ +function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { + try { + handBackStructuredAgentSessionMessage( + entry.sessionId, + entry.clientMessageId, + entry.body, + entry.imageConnectionIds + ) + return true + } catch (error) { + console.error('[native-chat-send] a message could not be put back in the composer', error) + return false + } +} + +export function handBack( + entry: StructuredAgentSessionPendingSend, + notice: readonly AgentSessionWriteNoticePart[] | null +): void { + try { + if (entry.callerKeepsText) { + return + } + const parts: readonly AgentSessionWriteNoticePart[] | null = returnToComposer(entry) + ? notice + : [...(notice ?? []), 'messageNotSaved'] + if (parts) { + publishStructuredAgentSessionSends(entry.sessionId, { + notice: agentSessionWriteNoticeText([...parts]) + }) + } + } finally { + // Bookkeeping never holds the chat: whatever the hand-back met, the send ends. + finish(entry, notice?.includes('sendOutcomeLost') ? 'unconfirmed' : 'returned') + } +} + +/** Settled by the host's answer, from the send's own reply or the journal, whichever comes first. */ +export function settleRecorded( + entry: StructuredAgentSessionPendingSend, + submission: AgentJournalSubmission | null, + from: 'reply' | 'journal' +): void { + // A message a Stop took back stays in the chat with its stop row, drawn by the host's row; the + // composer is left alone. An open chat draws a recorded one until its row arrives, which can + // trail the reply. + const keep = + from === 'reply' && + !(submission && dispatchWasWithdrawn(submission)) && + structuredAgentSessionSendsWatched(entry.sessionId) + finish(entry, 'recorded', keep ? { phase: 'recorded', issued: true } : undefined) +} + +/** Why the host turned the send away. Behind a rewind whose outcome is unknown it was not sent: the + * rewind's words say why, and the next send has the host check it first. */ +export function refusedSendParts(failure: AgentSessionWriteFailure): AgentSessionWriteNoticePart[] { + return failure.kind === 'refused' && failure.details?.reason === 'rewindUnconfirmed' + ? [ + { text: nativeChatRewindReasonCopy('outcome-unknown') }, + ...agentSessionWriteNotDoneParts('composer-send') + ] + : agentSessionWriteNoticeParts(failure, 'composer-send') +} + +function onDeadline(sessionId: string, clientMessageId: string): void { + const entry = findStructuredAgentSessionPendingSend(sessionId, clientMessageId) + if (!entry || entry.phase === 'recorded') { + return + } + runtimes.get(clientMessageId)?.abort.abort() + // One that went out may still land: its row then shows it beside the text given back. + handBack( + entry, + entry.issued + ? ['sendOutcomeLost'] + : ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')] + ) +} + +export type StructuredAgentSessionSent = { + clientMessageId: string + outcome: Promise +} + +/** Sends a message holding its chat's slot; its 30 s start now. */ +export function beginSend( + entry: StructuredAgentSessionPendingSend, + target: RuntimeClientTarget, + budgetMs = STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS +): StructuredAgentSessionSent { + const { clientMessageId, sessionId } = entry + const outcome = new Promise((resolve) => { + runtimes.set(clientMessageId, { + target, + abort: new AbortController(), + deadline: setTimeout(() => onDeadline(sessionId, clientMessageId), budgetMs), + resolve + }) + }) + return { clientMessageId, outcome } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts index 4babd5ab7ec..20df6c45fe0 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-stop-control.ts @@ -3,6 +3,8 @@ export function structuredAgentSessionStopControl(input: { /** The host has published this chat to this view. */ published: boolean + /** Temporary: hosts without create.message.v1 still leave the prompt on this client. */ + legacyLaunch?: { takeBackText: () => void } /** The Stop the host is asked for once it has published this chat. */ host: { /** The host takes a Stop naming no turn (and this view holds its fence). */ @@ -15,24 +17,21 @@ export function structuredAgentSessionStopControl(input: { sending: boolean /** A Stop's part of that send: what has not gone out goes no further. */ stopSends: () => void - /** Before the host publishes the chat: the launch's unsent text goes back to the composer. */ - takeBackLaunchText: () => void } }): { canStop: boolean; stop: () => Promise } { - const { published, host } = input + const { published, host, legacyLaunch } = input const { turnId, isWorking } = input.transportState - const { sending, stopSends, takeBackLaunchText } = input.sends + const { sending, stopSends } = input.sends return { - // Before the host publishes this chat to this view, nothing sent has reached it: a Stop takes - // back what this client holds, so a start that never answers cannot hold the message hostage. - canStop: - turnId !== null || - (!published && sending) || - (host.stopsConversation && (isWorking || sending)), + canStop: published + ? turnId !== null || (host.stopsConversation && (isWorking || sending)) + : legacyLaunch !== undefined && sending, stop: () => { if (!published) { - takeBackLaunchText() - stopSends() + if (legacyLaunch) { + legacyLaunch.takeBackText() + stopSends() + } return Promise.resolve(null) } return host.stop(turnId, stopSends) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 6ea30fa31a3..84c3897fcfc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -59,8 +59,12 @@ vi.mock('./use-structured-agent-session-sends', () => ({ } })) +vi.mock('@/lib/structured-agent-session-launch-create-support', () => ({ + useStructuredLaunchCreateSupport: () => false +})) + vi.mock('@/lib/structured-agent-session-launch-prompt', () => ({ - takeBackStructuredLaunchPrompts: mocks.takeBackLaunchText + takeBackLegacyStructuredLaunchPrompts: mocks.takeBackLaunchText })) vi.mock('./native-chat-session-option-settings-write', () => ({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 5c5f4615057..f2af91bc5c3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -5,7 +5,7 @@ import { structuredAgentSessionNewSendsQueue } from './structured-agent-session- import type { AgentType } from '../../../../shared/agent-status-types' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' -import { takeBackStructuredLaunchPrompts } from '@/lib/structured-agent-session-launch-prompt' +import { useStructuredLegacyLaunchStop } from '@/lib/structured-agent-session-legacy-launch-stop' import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHostQueuesCommands, @@ -92,6 +92,7 @@ export function useStructuredAgentSession(args: { enabled: transportEnabled }) const commandPending = useRef(false) + const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId) const transportState = useStructuredAgentSessionTransportState(state, transportEnabled) const { conversationCommands, @@ -274,12 +275,12 @@ export function useStructuredAgentSession(args: { turnId: transportState.turnId, ...structuredAgentSessionStopControl({ published: transportEnabled, + ...legacyLaunchStop, host: stopControl, transportState, sends: { sending, - stopSends: sends.stopSends, - takeBackLaunchText: () => takeBackStructuredLaunchPrompts(sessionId) + stopSends: sends.stopSends } }), stopPressed: stopControl.pressed, diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 6f95d73d0ed..3b2d0c778f2 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -1,20 +1,12 @@ import type { TuiAgent } from '../../../shared/tui-agent' -import type { - AgentSessionAttachResult, - AgentSessionMutationResult -} from '../../../shared/agent-session-wire' import { createStructuredAgentSessionId, structuredAgentSessionCreateParams, type StructuredAgentSessionCreateParams, + type StructuredAgentSessionFirstMessage, type StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import { resolveStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' -import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' -import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' -import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' -import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { useAppStore } from '@/store' import { @@ -30,9 +22,7 @@ import { import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { ExecutionHostId } from '../../../shared/execution-host' import { createBrowserUuid } from '@/lib/browser-uuid' -import { askHostCreateSupport } from '@/lib/structured-agent-session-host-admission' import { - StructuredAgentSessionCreateError, StructuredAgentSessionCreateRefusalError, StructuredAgentSessionCreateUnknownOutcomeError, StructuredAgentSessionOwnerUnresolvedError @@ -54,6 +44,9 @@ export type StructuredAgentSessionLaunchIntent = { target: RuntimeClientTarget agent: TuiAgent params: StructuredAgentSessionCreateParams + createMessageSupport?: boolean + createPrepared?: true + prepareCreate?: () => void /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> } @@ -86,27 +79,6 @@ function structuredAgentSessionOwnerTarget( return { executionHostId, target } } -const DEFINITIVE_CREATE_FAILURE_CODES = [ - 'structured_agent_session_unsupported', - 'method_not_found' -] as const - -function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string | null { - if (error instanceof StructuredAgentSessionCreateError) { - // Our own classes already carry the verdict; message sniffing below could only invert it. - return error instanceof StructuredAgentSessionCreateRefusalError && - isDefinitiveAgentSessionCreateRefusal(error.code) - ? error.code - : null - } - for (const code of DEFINITIVE_CREATE_FAILURE_CODES) { - if (hasRuntimeRpcErrorCode(error, code)) { - return code - } - } - return null -} - /** `executionHostId` is the host the launch was routed to; absent, the catalog must name exactly * one, or the launch is refused rather than sent to whichever host a fallback picks. */ export function createStructuredAgentSessionLaunchIntent( @@ -190,6 +162,9 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { resumeFrom?: StructuredAgentSessionResumeSource /** A paired server's seed, kept with the launch so a reload shows what create runs. */ seedOptions?: Readonly> + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> + createMessageSupport?: boolean }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) @@ -215,8 +190,14 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { }, worktree: toRuntimeWorktreeSelector(args.worktreeId), agent: args.agent, - ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}) + ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}), + ...(args.firstMessage ? { firstMessage: args.firstMessage } : {}), + ...(args.options ? { options: args.options } : {}) }, + ...(args.createMessageSupport === undefined + ? {} + : { createMessageSupport: args.createMessageSupport }), + ...(args.createMessageSupport === undefined ? {} : { createPrepared: true as const }), ...launchSeedOptions(state, { target }, args.agent, args.seedOptions) } } @@ -231,75 +212,7 @@ export function abandonStructuredAgentSessionLaunchIntent( ) } -/** - * Only the host that will execute the session can answer whether it supports creating one there. - * Both providers ask: the host classifies per agent, and Codex inherits the - * unresolvable-selector retry above along with the probe. The unknown branch stays on the chat for - * reconciliation: a retry may follow a create whose reply was lost. Answers the seed create will use. - */ -async function requireHostCreateSupport( - intent: StructuredAgentSessionLaunchIntent -): Promise { - const support = await askHostCreateSupport(intent.target, intent.params.worktree, intent.agent) - if (support.kind === 'unreachable') { - throw new StructuredAgentSessionCreateUnknownOutcomeError( - support.message, - support.code, - readAgentSessionErrorRefusal(support.error) - ) - } - // A workspace the host still cannot resolve is refused here, as before; Retry asks again. - if (support.kind === 'declined' || support.kind === 'workspace-unresolved') { - abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError( - 'structured_agent_session_unsupported', - 'structured_agent_session_unsupported' - ) - } - return support.seedOptions -} - -/** Told the seed a paired server says this create will use, which may differ from an earlier - * attempt's; a local launch reads its own settings instead. */ -export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void - -export async function launchStructuredAgentSession( - intent: StructuredAgentSessionLaunchIntent, - onHostSeed?: StructuredLaunchHostSeedListener -): Promise> { - const hostSeed = await requireHostCreateSupport(intent) - if (intent.target.kind !== 'local') { - onHostSeed?.(hostSeed) - } - let result: AgentSessionMutationResult - try { - result = await callStructuredAgentSession>( - intent.target, - 'agentSession.create', - intent.params - ) - } catch (error) { - const code = definitiveStructuredAgentSessionCreateErrorCode(error) - if (code) { - abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError( - error instanceof Error ? error.message : String(error), - code, - readAgentSessionErrorRefusal(error) - ) - } - throw error - } - if (!result.ok) { - const { code, message, ownerVerdict } = result.refusal - const refusal = readAgentSessionRefusalReference(result.refusal) - // A failed operation whose provider is proven gone is a failure a new operation may retry. - if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') { - // Keep the focus intent: the session may exist, and recovery still has to adopt it. - throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal) - } - abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError(message, code, refusal) - } - return { sessionId: result.value.sessionId, fence: result.value.fence } -} +export { + launchStructuredAgentSession, + type StructuredLaunchHostSeedListener +} from './structured-agent-session-launch-create-call' diff --git a/src/renderer/src/lib/structured-agent-session-create-hydration.ts b/src/renderer/src/lib/structured-agent-session-create-hydration.ts new file mode 100644 index 00000000000..9525df8f742 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-create-hydration.ts @@ -0,0 +1,18 @@ +import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' +import { hasStructuredAgentLaunchCancellationTombstonePersisted } from './structured-agent-session-launch-persistence' +import { getStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner' +import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' + +/** The host's create proof belongs to the existing reader until its tab closes. */ +export function publishStructuredAgentSessionCreateHydration( + intent: StructuredAgentSessionLaunchIntent, + page: AgentSessionHistoryPage, + fence: number +): void { + if (hasStructuredAgentLaunchCancellationTombstonePersisted(intent.sessionId)) { + return + } + getStructuredAgentSessionReadOwner(intent.sessionId, intent.target).publishCreated(page) + noteStructuredAgentSessionFence(intent.sessionId, fence) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts new file mode 100644 index 00000000000..be0f3c6b853 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -0,0 +1,153 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' +import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' +import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' +import { + callStructuredAgentSession, + supportsStructuredAgentSessionCreateMessage +} from '@/runtime/structured-agent-session-client' +import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' +import { askHostCreateSupport } from './structured-agent-session-host-admission' +import { + abandonStructuredAgentSessionLaunchIntent, + type StructuredAgentSessionLaunchIntent +} from './launch-structured-agent-session' +import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { + StructuredAgentSessionCreateError, + StructuredAgentSessionCreateRefusalError, + StructuredAgentSessionCreateUnknownOutcomeError +} from './structured-agent-session-launch-errors' + +type LaunchSeed = Readonly> | undefined +const DEFINITIVE_CREATE_FAILURE_CODES = [ + 'structured_agent_session_unsupported', + 'method_not_found' +] as const + +function definitiveStructuredAgentSessionCreateErrorCode(error: unknown): string | null { + if (error instanceof StructuredAgentSessionCreateError) { + // Our own classes already carry the verdict; message sniffing below could only invert it. + return error instanceof StructuredAgentSessionCreateRefusalError && + isDefinitiveAgentSessionCreateRefusal(error.code) + ? error.code + : null + } + for (const code of DEFINITIVE_CREATE_FAILURE_CODES) { + if (hasRuntimeRpcErrorCode(error, code)) { + return code + } + } + return null +} + +/** + * Only the host that will execute the session can answer whether it supports creating one there. + * Both providers ask: the host classifies per agent, and Codex inherits the + * unresolvable-selector retry above along with the probe. The unknown branch stays on the chat for + * reconciliation: a retry may follow a create whose reply was lost. Answers the seed create will use. + */ +async function requireHostCreateSupport( + intent: StructuredAgentSessionLaunchIntent +): Promise { + const support = await askHostCreateSupport(intent.target, intent.params.worktree, intent.agent) + if (support.kind === 'unreachable') { + throw new StructuredAgentSessionCreateUnknownOutcomeError( + support.message, + support.code, + readAgentSessionErrorRefusal(support.error) + ) + } + if (support.kind === 'declined' || support.kind === 'workspace-unresolved') { + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError( + 'structured_agent_session_unsupported', + 'structured_agent_session_unsupported' + ) + } + return support.seedOptions +} + +/** Told the seed a paired server says this create will use, which may differ from an earlier + * attempt's; a local launch reads its own settings instead. */ +export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void + +export async function launchStructuredAgentSession( + intent: StructuredAgentSessionLaunchIntent, + onHostSeed?: StructuredLaunchHostSeedListener +): Promise { + const hostSeed = await requireHostCreateSupport(intent) + if (intent.target.kind !== 'local') { + onHostSeed?.(hostSeed) + } + if (!intent.createPrepared) { + let supported = false + try { + supported = await supportsStructuredAgentSessionCreateMessage(intent.target) + } catch { + // Unknown capability stays on the compatible create path. + } + if (!intent.createPrepared) { + intent.createMessageSupport = supported + intent.prepareCreate?.() + intent.createPrepared = true + } + } + let result: AgentSessionMutationResult + try { + result = await callStructuredAgentSession>( + intent.target, + 'agentSession.create', + intent.params + ) + } catch (error) { + const code = definitiveStructuredAgentSessionCreateErrorCode(error) + if (code) { + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError( + error instanceof Error ? error.message : String(error), + code, + readAgentSessionErrorRefusal(error) + ) + } + throw error + } + if (!result.ok) { + const { code, message, ownerVerdict } = result.refusal + const refusal = readAgentSessionRefusalReference(result.refusal) + // A failed operation whose provider is proven gone is a failure a new operation may retry. + if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') { + // Keep the focus intent: the session may exist, and recovery still has to adopt it. + throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal) + } + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError(message, code, refusal) + } + const firstMessage = intent.params.firstMessage + ? (result.value.firstMessage ?? + result.value.page.submissions.find( + (submission) => submission.clientMessageId === intent.params.firstMessage?.clientMessageId + )) + : undefined + if ( + intent.params.firstMessage && + firstMessage?.clientMessageId !== intent.params.firstMessage.clientMessageId + ) { + throw new StructuredAgentSessionCreateUnknownOutcomeError( + 'The created chat did not confirm its first message.', + 'agent_session_operation_unknown' + ) + } + if (intent.createMessageSupport === true) { + publishStructuredAgentSessionCreateHydration(intent, result.value.page, result.value.fence) + } + return { + sessionId: result.value.sessionId, + fence: result.value.fence, + ...(firstMessage ? { firstMessage } : {}) + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts new file mode 100644 index 00000000000..45613b5c7aa --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -0,0 +1,523 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../shared/agent-session-wire' +import { + CreateIntentParams, + SendParams +} from '../../../shared/rpc-contract/structured-agent-session-params' +import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import type { HostCreateSupport } from './structured-agent-session-host-admission' + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), + supports: vi.fn<() => Promise>(), + admit: vi.fn<() => Promise>(), + inventory: vi.fn() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionCreateMessage: mocks.supports +})) +vi.mock('./structured-agent-session-host-admission', () => ({ + askHostCreateSupport: mocks.admit +})) +vi.mock('@/runtime/structured-session-tab-inventory', () => ({ + readStructuredSessionTabInventory: mocks.inventory +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: {}, + unifiedTabsByWorktree: {}, + seedNativeChatLaunchDraft: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + }), + subscribe: () => () => {} + } +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { + startStructuredAgentLaunch, + cancelStructuredAgentLaunch, + retryStructuredAgentSessionLaunch, + getStructuredAgentSessionLaunchLifecycle +} from './structured-agent-session-launch' +import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' +import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { + resetStructuredAgentLaunchPersistenceForTests, + readStructuredAgentLaunchRecord +} from './structured-agent-session-launch-persistence' +import { newAgentLaunchRequestId } from './agent-launch-request-id' +import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { + getStructuredAgentSessionReadOwner, + findStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from '@/components/native-chat/structured-agent-session-read-owner' +import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' +import { getStructuredAgentSessionPendingSends } from '@/components/native-chat/structured-agent-session-pending-sends' +import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache, + writeNativeChatDraftCache +} from '@/components/native-chat/native-chat-draft-cache' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' + +function deferred() { + let resolve = (_value: T): void => {} + let reject = (_error: unknown): void => {} + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +function createParams() { + return CreateIntentParams.parse( + mocks.call.mock.calls.find(([, method]) => method === 'agentSession.create')?.[2] + ) +} +function submission(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'pending', + handoverRecorded: true, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } +} +function created() { + const params = createParams() + const rows = params.firstMessage ? [submission(params.firstMessage.clientMessageId)] : [] + const cursor = { epoch: 'epoch-1', sequence: rows.length } + const page: AgentSessionHistoryPage = { + sessionId: params.envelope.sessionId, + epoch: cursor.epoch, + fence: 1, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: rows, + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false, + latestTurn: null + } + return { + ok: true, + replayed: false, + value: { sessionId: page.sessionId, fence: 1, page, unconfirmedClientMessageIds: [] } + } +} +const target = { kind: 'local' } as const +const start = () => + startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text' + }) +const draft = (sessionId: string) => + readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(sessionId)) + +describe('the first message owned by create', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + localStorage.clear() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + clearNativeChatDraftCacheForTests() + mocks.supports.mockResolvedValue(true) + mocks.admit.mockResolvedValue({ kind: 'admitted' }) + mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create')) + }) + afterEach(() => { + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + vi.useRealTimers() + }) + + it('freezes the message and held options in create and publishes without another read or send', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'picked-model') + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const params = createParams() + expect(params.firstMessage?.body.blocks).toEqual([{ type: 'text', text: 'opening text' }]) + expect(params.options).toEqual({ model: 'picked-model' }) + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionCreateFingerprint({ sessionId: launch.sessionId, ...params }) + ) + expect(getStructuredAgentSessionPendingSends(launch.sessionId)[0]?.phase).toBe('sending') + const stop = vi.fn(async () => null) + expect( + structuredAgentSessionStopControl({ + published: false, + host: { stopsConversation: true, stop }, + transportState: { turnId: null, isWorking: false }, + sends: { sending: true, stopSends: vi.fn() } + }).canStop + ).toBe(false) + create.resolve(created()) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + const state = getStructuredAgentSessionReadOwner(launch.sessionId, target).getSnapshot().state + expect(state.fence).toBe(1) + const isWorking = isStructuredAgentSessionMainAgentWorking(null, state.submissions, state.fence) + expect(isWorking).toBe(true) + const control = structuredAgentSessionStopControl({ + published: true, + host: { stopsConversation: true, stop }, + transportState: { turnId: null, isWorking }, + sends: { sending: false, stopSends: vi.fn() } + }) + expect(control.canStop).toBe(true) + await control.stop() + expect(stop).toHaveBeenCalledWith(null, expect.any(Function)) + expect(draft(launch.sessionId)).toBe('') + expect(mocks.inventory).not.toHaveBeenCalled() + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + }) + + it('returns a refused create once after the new draft, with no later send', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + writeNativeChatDraftCache( + structuredAgentSessionDraftScopeKey(launch.sessionId), + 'typed meanwhile' + ) + create.resolve({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported', message: 'unavailable' } + }) + await expect(launch.launchResult).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + await launch.promptDeliveryResult?.catch(() => undefined) + expect(draft(launch.sessionId)).toBe('typed meanwhile\n\nopening text') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + expect(getStructuredAgentSessionPendingSends(launch.sessionId)).toEqual([]) + }) + + it('returns text at 30 seconds and a late create reply cannot send it again', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(30_000) + expect(draft(launch.sessionId)).toBe('opening text') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + inComposer: true, + unconfirmed: true + }) + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( + 'visibility-unknown' + ) + expect(draft(launch.sessionId)).toBe('opening text') + create.resolve(created()) + await launch.launchResult + expect(draft(launch.sessionId)).toBe('opening text') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + }) + + it('keeps the old create-then-send payload for an older host', async () => { + mocks.supports.mockResolvedValue(false) + mocks.call.mockImplementation(async (_target, method, params) => { + if (method === 'agentSession.create') { + return created() + } + const send = SendParams.parse(params) + return { + ok: true, + value: { + clientMessageId: send.envelope.clientOperationId, + submission: submission(send.envelope.clientOperationId) + } + } + }) + const launch = start() + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(createParams()).not.toHaveProperty('firstMessage') + expect(createParams()).not.toHaveProperty('options') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.send' + ]) + }) + + it('keeps the older host acquisition wait outside the send deadline', async () => { + const create = deferred() + mocks.supports.mockResolvedValue(false) + mocks.call.mockReturnValue(create.promise) + const launch = start() + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(45_000) + expect(draft(launch.sessionId)).toBe('') + expect(getStructuredAgentSessionPendingSends(launch.sessionId)[0]?.phase).toBe('sending') + mocks.call.mockImplementation(async (_target, _method, params) => { + const send = SendParams.parse(params) + return { + ok: true, + value: { + clientMessageId: send.envelope.clientOperationId, + submission: submission(send.envelope.clientOperationId) + } + } + }) + create.resolve(created()) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.send' + ]) + }) + + it('bounds the click even if capability negotiation has not answered', async () => { + const support = deferred() + mocks.supports.mockReturnValue(support.promise) + mocks.call.mockImplementation(async () => created()) + const launch = start() + await vi.advanceTimersByTimeAsync(30_000) + expect(draft(launch.sessionId)).toBe('opening text') + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + inComposer: true + }) + expect(draft(launch.sessionId)).toBe('opening text') + support.resolve(true) + await launch.launchResult + expect(createParams()).not.toHaveProperty('firstMessage') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + expect(draft(launch.sessionId)).toBe('opening text') + }) + + it('keeps the same first-message intent when the paired host reports its seed', async () => { + mocks.admit.mockResolvedValue({ kind: 'admitted', seedOptions: { model: 'host-default' } }) + mocks.call.mockImplementation(async () => created()) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'runtime:server-1', + prompt: 'opening text' + }) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(createParams().firstMessage?.body.blocks).toEqual([ + { type: 'text', text: 'opening text' } + ]) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(mocks.call).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'server-1' }, + 'agentSession.create', + expect.objectContaining({ firstMessage: expect.any(Object) }) + ) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + expect( + getStructuredAgentSessionReadOwner(launch.sessionId, { + kind: 'environment', + environmentId: 'server-1' + }).getSnapshot().state.fence + ).toBe(1) + expect( + getStructuredAgentSessionReadOwner(launch.sessionId, target).getSnapshot().state.fence + ).toBeNull() + }) + + it('publishes while a later option write still waits for the agent', async () => { + const create = deferred() + const option = deferred() + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.setOption' ? option.promise : create.promise + ) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'later-pick') + create.resolve(created()) + let published = false + void launch.launchResult.then(() => { + published = true + }) + await vi.waitFor(() => expect(published).toBe(true)) + await launch.launchResult + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.setOption' + ]) + option.resolve({ ok: true, value: { selected: 'later-pick' } }) + }) + + it('recovers a reload by replaying the frozen create when the old row is outside history', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const original = createParams() + expect(readStructuredAgentLaunchRecord(launch.sessionId)?.firstMessage).toEqual( + original.firstMessage + ) + const response = created() + const receipt = response.value.page.submissions[0] + response.value.page.submissions = [] + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionSendsForTests() + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.history' + ? { ok: true, page: response.value.page } + : { ...response, replayed: true, value: { ...response.value, firstMessage: receipt } } + ) + markStructuredAgentSessionLaunchPublished('workspace-1', launch.sessionId, 'local') + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + const creates = mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create') + expect(creates).toHaveLength(2) + expect(creates[1]?.[2]).toEqual(original) + expect(mocks.supports).toHaveBeenCalledOnce() + expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')).toEqual([]) + expect(draft(launch.sessionId)).toBe('') + }) + + it('allows Retry after the deadline while the original create never answers', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const original = createParams() + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + mocks.inventory.mockResolvedValue([]) + mocks.call.mockImplementation(async () => created()) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + const creates = mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create') + expect(creates).toHaveLength(2) + expect(creates[1]?.[2]).toEqual(original) + expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')).toEqual([]) + expect(draft(launch.sessionId)).toBe('opening text') + }) + + it('does not revive a closed conversation reader when create replies late', async () => { + const create = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(cancelStructuredAgentLaunch('workspace-1', launch.sessionId)).toBe(true) + create.resolve(created()) + await expect(launch.launchResult).rejects.toThrow('structured session launch cancelled') + expect(findStructuredAgentSessionReadOwner(launch.sessionId, target)).toBeUndefined() + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + }) + + it('keeps Retry actionable when a published tab starts an unanswered message confirmation', async () => { + mocks.call.mockReturnValue(new Promise(() => {})) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + mocks.inventory.mockResolvedValue([ + { + worktree: 'workspace-1', + tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] + } + ]) + expect( + markStructuredAgentSessionLaunchPublished('workspace-1', launch.sessionId, 'local') + ).toBe(false) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(30_000) + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( + 'visibility-unknown' + ) + expect(draft(launch.sessionId)).toBe('opening text') + mocks.inventory.mockResolvedValue([]) + mocks.call.mockImplementation(async () => created()) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + 'agentSession.history', + 'agentSession.create' + ]) + expect(draft(launch.sessionId)).toBe('opening text') + }) + + it.each(['create', 'history'] as const)( + 'offers Retry again when a second %s attempt never answers, without returning text twice', + async (method) => { + mocks.call.mockReturnValue(new Promise(() => {})) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const original = createParams() + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + mocks.inventory.mockResolvedValue( + method === 'create' + ? [] + : [ + { + worktree: 'workspace-1', + tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] + } + ] + ) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(30_000) + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( + 'visibility-unknown' + ) + expect(draft(launch.sessionId)).toBe('opening text') + mocks.call.mockImplementation(async () => + method === 'history' ? { ok: true, page: created().value.page } : created() + ) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) + await vi.waitFor(() => + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + ) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.create', + `agentSession.${method}`, + `agentSession.${method}` + ]) + expect( + mocks.call.mock.calls + .filter(([, name]) => name === 'agentSession.create') + .map(([, , params]) => params) + ).toEqual(method === 'create' ? [original, original, original] : [original]) + expect(draft(launch.sessionId)).toBe('opening text') + } + ) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts new file mode 100644 index 00000000000..e04f44b15e1 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -0,0 +1,60 @@ +import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { StructuredLaunchState } from './structured-agent-session-launch-registry' +import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' +import { + prepareStructuredLaunchPromptForCreate, + prepareLegacyStructuredLaunchPrompts +} from './structured-agent-session-launch-prompt' +import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { + applyStructuredLaunchHeldOptions, + settleStructuredLaunchCreateOptions +} from './structured-agent-session-launch-options' + +/** Freeze what create carries once; an ambiguous reply always replays the same payload. */ +export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): void { + const { intent } = state + if (intent.createPrepared) { + return + } + if (intent.createMessageSupport !== true) { + prepareLegacyStructuredLaunchPrompts(intent.sessionId) + intent.createPrepared = true + notifyStructuredLaunchListeners() + return + } + const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) + const options = Object.keys(state.selection.held).length ? { ...state.selection.held } : undefined + const params = { + ...intent.params, + ...(firstMessage ? { firstMessage } : {}), + ...(options ? { options } : {}) + } + intent.params = { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: structuredAgentSessionCreateFingerprint({ + sessionId: intent.sessionId, + ...params + }) + } + } + intent.createPrepared = true + notifyStructuredLaunchListeners() +} + +/** Later picks use the normal option path without holding publication behind startup. */ +export function publishStructuredLaunchCreateOptions( + state: StructuredLaunchState, + receipt: StructuredAgentLaunchReceipt +): StructuredAgentLaunchReceipt | Promise { + if (state.intent.createMessageSupport !== true) { + return applyStructuredLaunchHeldOptions(state, receipt) + } + settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) + void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { + console.warn('[native-chat] applying a launch option failed', error) + }) + return receipt +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-support.ts b/src/renderer/src/lib/structured-agent-session-launch-create-support.ts new file mode 100644 index 00000000000..2b46b5c4342 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-create-support.ts @@ -0,0 +1,17 @@ +import { useSyncExternalStore } from 'react' +import { + getPersistedStructuredAgentLaunchRecord, + getStructuredLaunchStateBySessionId, + subscribeStructuredAgentLaunchStatus +} from './structured-agent-session-launch-registry' + +/** Unknown capability keeps Stop unavailable until create can publish or choose the old flow. */ +export function useStructuredLaunchCreateSupport(sessionId: string): boolean | undefined { + return useSyncExternalStore( + subscribeStructuredAgentLaunchStatus, + () => + getStructuredLaunchStateBySessionId(sessionId)?.intent.createMessageSupport ?? + getPersistedStructuredAgentLaunchRecord(sessionId)?.createMessageSupport, + () => undefined + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-message.ts b/src/renderer/src/lib/structured-agent-session-launch-message.ts index e31de65c8a8..a1d42025b3e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-message.ts @@ -12,6 +12,7 @@ import { } from './structured-agent-session-launch-registry' import { retryStructuredAgentSessionLaunch } from './structured-agent-session-launch' import { newAgentLaunchRequestId } from './agent-launch-request-id' +import { trackStructuredLaunchPromptOutcome } from './structured-agent-session-launch-prompt-outcome' /** Null when the chat's start has not failed, or its restart could not begin. */ export function relaunchFailedStructuredAgentSessionWithMessage( @@ -37,5 +38,6 @@ export function relaunchFailedStructuredAgentSessionWithMessage( options: { requestId: newAgentLaunchRequestId(), prompt: text }, stagedPrompt: stageStructuredLaunchPrompt(sessionId, text, options) }) + trackStructuredLaunchPromptOutcome(state, caller) return caller.promptDeliveryResult ?? null } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 136ef96096e..5f5bde375d0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -142,6 +142,17 @@ function settleHeldOption( replies.delete(id) } notifyStructuredLaunchListeners() + state.callers.onSettled() +} + +/** Selections included in the durable create are already the host's option intent. */ +export function settleStructuredLaunchCreateOptions( + state: StructuredLaunchState, + options: Readonly> +): void { + for (const [id, encoded] of Object.entries(options)) { + settleHeldOption(state, id, encoded, { kind: 'accepted', options }) + } } /** diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index fb64780083b..02fd0616618 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -1,8 +1,12 @@ import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { settleStructuredLaunchCallers } from '@/lib/structured-agent-session-launch-callers' +import { + settleStructuredLaunchCallers, + structuredLaunchCallersHavePendingWork +} from '@/lib/structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from '@/lib/structured-agent-session-launch-recovery' import { structuredLaunchFailure } from './structured-agent-session-launch-failure' import { + deleteStructuredLaunchStateIfCurrent, notifyStructuredLaunchListeners, retireStructuredAgentSessionLaunchCancellationTombstone, type StructuredLaunchState @@ -10,6 +14,23 @@ import { // How a launch's create outcome settles its callers: published, refused, failed or unknown. +function cleanupLaunchState(state: StructuredLaunchState): void { + if (deleteStructuredLaunchStateIfCurrent(state)) { + notifyStructuredLaunchListeners() + } +} + +export function maybeCleanupLaunchState(state: StructuredLaunchState): void { + if ( + state.callers.outcome === 'failed' || + structuredLaunchCallersHavePendingWork(state.callers) || + Object.keys(state.selection.held).length > 0 + ) { + return + } + cleanupLaunchState(state) +} + function settleStructuredLaunchRefusal(state: StructuredLaunchState): void { if (state.callers.outcome !== 'pending' && state.callers.outcome !== 'unknown') { return diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index bd241b890bc..ccd9298f380 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,54 +1,19 @@ -import { isTuiAgent } from '../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../shared/tui-agent' -import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' +import { + validRecord, + type StructuredAgentLaunchPersistedRecord +} from './structured-agent-session-launch-record' +export { + structuredAgentLaunchRecordFor, + type StructuredAgentLaunchPersistedRecord, + type StructuredAgentLaunchPersistedLifecycle +} from './structured-agent-session-launch-record' import { parseStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' -import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' - -export type StructuredAgentLaunchPersistedRecord = { - sessionId: string - /** The host the chat was created on. Records written before paired hosts could hold a chat lack - * it and load as local, the only host a chat could then be launched on. */ - executionHostId: ExecutionHostId - agent: TuiAgent - lifecycle: StructuredAgentLaunchPersistedLifecycle - clientOperationId: string - payloadFingerprint: string - expectedRuntimeFence: number | null - resumeFrom?: StructuredAgentSessionResumeSource - /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ - seedOptions?: Readonly> - /** When a failed launch failed; records written by older builds lack it. */ - failedAt?: number -} - -/** What survives a reload of an unpublished launch. */ -export function structuredAgentLaunchRecordFor( - intent: StructuredAgentSessionLaunchIntent, - lifecycle: StructuredAgentLaunchPersistedLifecycle -): StructuredAgentLaunchPersistedRecord { - const { envelope, resumeFrom } = intent.params - // A local launch re-reads this machine's settings on reload; only a paired server's seed is kept. - const pairedSeed = intent.target.kind === 'local' ? undefined : intent.seedOptions - return { - sessionId: intent.sessionId, - executionHostId: intent.executionHostId, - agent: intent.agent, - lifecycle, - clientOperationId: envelope.clientOperationId, - payloadFingerprint: envelope.payloadFingerprint, - expectedRuntimeFence: envelope.expectedRuntimeFence, - ...(resumeFrom ? { resumeFrom } : {}), - ...(pairedSeed ? { seedOptions: pairedSeed } : {}) - } -} - const LAUNCH_STORAGE_KEY = 'orca:structuredAgentLaunches:v1' const TOMBSTONE_STORAGE_KEY = 'orca:structuredAgentLaunchCancelledSessions:v1' const records = new Map() @@ -59,55 +24,6 @@ const remoteTombstoneCancelledAt = new Map() const REMOTE_TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000 let loaded = false -function validRecord(value: unknown): value is Omit< - StructuredAgentLaunchPersistedRecord, - 'executionHostId' -> & { - executionHostId?: string -} { - if (!value || typeof value !== 'object') { - return false - } - if ( - !('sessionId' in value) || - !('agent' in value) || - !('lifecycle' in value) || - !('clientOperationId' in value) || - !('payloadFingerprint' in value) || - !('expectedRuntimeFence' in value) - ) { - return false - } - const { - sessionId, - agent, - lifecycle, - clientOperationId, - payloadFingerprint, - expectedRuntimeFence - } = value - const resumeFrom = 'resumeFrom' in value ? value.resumeFrom : undefined - const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined - const failedAt = 'failedAt' in value ? value.failedAt : undefined - return ( - (executionHostId === undefined || - (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && - typeof sessionId === 'string' && - sessionId.length > 0 && - isTuiAgent(agent) && - (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && - typeof clientOperationId === 'string' && - typeof payloadFingerprint === 'string' && - (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && - (failedAt === undefined || Number.isFinite(failedAt)) && - (resumeFrom === undefined || - (typeof resumeFrom === 'object' && - resumeFrom !== null && - 'providerSessionId' in resumeFrom && - typeof resumeFrom.providerSessionId === 'string')) - ) -} - function load(): void { if (loaded) { return diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts new file mode 100644 index 00000000000..aba3d5db48a --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts @@ -0,0 +1,53 @@ +import type { StructuredLaunchCaller } from './structured-agent-session-launch-callers' +import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' +import { + notifyStructuredLaunchListeners, + type StructuredLaunchState +} from './structured-agent-session-launch-registry' + +/** The message deadline frees the composer and offers the existing Retry while create is unknown. */ +export function trackStructuredLaunchPromptOutcome( + state: StructuredLaunchState, + caller: StructuredLaunchCaller +): void { + const group = state.callers + void caller.promptDeliveryResult?.then((outcome) => { + if ( + state.callers !== group || + state.cancelled || + group.outcome !== 'pending' || + !(outcome.inComposer || outcome.unconfirmed) + ) { + return + } + state.visibilityUnknown = true + group.outcome = 'unknown' + notifyStructuredLaunchListeners() + }) +} + +/** Confirmation checks share the opening message deadline after its send slot has settled. */ +export function trackStructuredLaunchConfirmationDeadline( + state: StructuredLaunchState, + promise: Promise +): void { + if (state.intent.createMessageSupport === false) { + return + } + const deadline = setTimeout(() => { + if ( + state.promise !== promise || + state.cancelled || + state.callers.outcome !== 'pending' || + state.intent.createMessageSupport === false + ) { + return + } + state.visibilityUnknown = true + state.callers.outcome = 'unknown' + notifyStructuredLaunchListeners() + }, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) + const clear = (): void => clearTimeout(deadline) + void promise.then(clear, clear) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts index df96ac036f3..6082f6d1ff7 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-stop.test.ts @@ -36,7 +36,7 @@ import { hasStagedStructuredLaunchPrompt, settleStructuredAgentLaunchPrompt, stageStructuredLaunchPrompt, - takeBackStructuredLaunchPrompts + takeBackLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' import { newAgentPromptOutcome } from './new-agent-prompt-outcome' @@ -97,14 +97,14 @@ function unpublishedStop(stopSends = () => stopStructuredAgentSessionSends(SESSI const hostStop = vi.fn(async () => null) const control = structuredAgentSessionStopControl({ published: false, + legacyLaunch: { takeBackText: () => takeBackLegacyStructuredLaunchPrompts(SESSION) }, host: { stopsConversation: true, stop: hostStop }, transportState: { turnId: null, isWorking: false }, sends: { sending: getStructuredAgentSessionPendingSends(SESSION).some( (entry) => entry.phase === 'sending' ), - stopSends, - takeBackLaunchText: () => takeBackStructuredLaunchPrompts(SESSION) + stopSends } }) return { control, hostStop } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index c538b9ced5e..f1a2b826d19 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -34,7 +34,7 @@ import { hasStagedStructuredLaunchPrompt, settleStructuredAgentLaunchPrompt, stageStructuredLaunchPrompt, - takeBackStructuredLaunchPrompts + takeBackLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' const SESSION = 'session-1' @@ -188,7 +188,7 @@ describe('settleStructuredAgentLaunchPrompt', () => { }) expect(pendingTexts()).toEqual(['review this:sending']) - takeBackStructuredLaunchPrompts(SESSION) + takeBackLegacyStructuredLaunchPrompts(SESSION) publish({ sessionId: SESSION, fence: 1 }) await expect(settled).resolves.toEqual({ delivered: false, failureNotified: true }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 0a5cd692c45..c8b9db609e1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -1,9 +1,14 @@ import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { agentSessionThrownFailure } from '../../../shared/agent-session-write-failure' import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' import { dropStructuredAgentSessionSends, reserveStructuredAgentSessionSend, sendStructuredAgentSessionMessage, + type StructuredAgentSessionCreateSend, type StructuredAgentSessionReservedSend } from '@/components/native-chat/structured-agent-session-message-sender' import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' @@ -32,7 +37,7 @@ export type StructuredLaunchPromptOptions = { onPromptDelivered?: () => void } -type LaunchReceipt = { sessionId: string; fence: number } +type LaunchReceipt = { sessionId: string; fence: number; firstMessage?: AgentJournalSubmission } /** A launch's text, held in memory from the click until its chat exists, then sent once: every * caller waiting on it shares the one send. From the click it holds the chat's one send slot, drawn @@ -44,6 +49,11 @@ export type StagedStructuredLaunchPrompt = { /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ callerKeepsText?: true delivery?: Promise + /** Create owns this prompt; it must never become a later send. */ + createOwned?: true + createSend?: StructuredAgentSessionCreateSend + whenCreateSettled: Promise + settleCreate: (outcome: StagedDelivery) => void /** Its launch was cancelled: never sent, never given back. */ discarded?: true /** Settles once it is discarded, so its callers need not wait on a create that may never end. */ @@ -62,17 +72,35 @@ export function stageStructuredLaunchPrompt( const whenDiscarded = new Promise((resolve) => { discard = resolve }) + let settleCreate = (_outcome: StagedDelivery): void => {} + const whenCreateSettled = new Promise((resolve) => { + settleCreate = resolve + }) const prompt: StagedStructuredLaunchPrompt = { sessionId, text, slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options }), ...options, whenDiscarded, - discard + discard, + whenCreateSettled, + settleCreate } const forSession = staged.get(sessionId) ?? new Set() forSession.add(prompt) staged.set(sessionId, forSession) + void prompt.slot?.outcome.then((outcome) => { + const result: StagedDelivery = { + delivered: outcome === 'recorded', + inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true } : {}) + } + if (!prompt.delivery) { + prompt.delivery = Promise.resolve(result) + unstage(prompt) + } + prompt.settleCreate(result) + }) return prompt } @@ -103,9 +131,9 @@ export function discardStructuredAgentSessionChatSends(sessionId: string): void /** A Stop before the chat is published: text its launch has not sent goes back to the composer, * never sent. The start itself goes on. */ -export function takeBackStructuredLaunchPrompts(sessionId: string): void { +export function takeBackLegacyStructuredLaunchPrompts(sessionId: string): void { for (const prompt of staged.get(sessionId) ?? []) { - if (prompt.delivery) { + if (prompt.delivery || prompt.createOwned) { continue } unstage(prompt) @@ -115,6 +143,44 @@ export function takeBackStructuredLaunchPrompts(sessionId: string): void { } } +export function prepareLegacyStructuredLaunchPrompts(sessionId: string): void { + for (const prompt of staged.get(sessionId) ?? []) { + prompt.slot?.legacy() + } +} + +/** Transfers the first staged prompt to create, retaining the sender's deadline and bubble. */ +export function prepareStructuredLaunchPromptForCreate( + sessionId: string, + target: RuntimeClientTarget +): StructuredAgentSessionFirstMessage | undefined { + const prompt = [...(staged.get(sessionId) ?? [])].find( + (candidate) => !candidate.discarded && !candidate.delivery + ) + if (!prompt) { + return undefined + } + prompt.createOwned = true + const sent = prompt.slot?.create(target) + prompt.createSend = sent ?? undefined + prompt.delivery = sent + ? sent.outcome.then((outcome) => { + unstage(prompt) + return { + delivered: outcome === 'recorded', + inComposer: + !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) + } + }) + : Promise.resolve({ delivered: false, inComposer: !prompt.callerKeepsText }).then((outcome) => { + unstage(prompt) + return outcome + }) + void prompt.delivery.then(prompt.settleCreate) + return sent?.message +} + /** Whether a launch still holds text for this chat that has not reached its host. */ export function hasStagedStructuredLaunchPrompt(sessionId: string): boolean { return (staged.get(sessionId)?.size ?? 0) > 0 @@ -192,14 +258,18 @@ export function settleStructuredAgentLaunchPrompt(args: { if (!prompt || prompt.discarded) { return { delivered: false, failureNotified: true } } + if (prompt.createOwned) { + prompt.createSend?.settle( + receipt.firstMessage + ? { kind: 'recorded', submission: receipt.firstMessage } + : { kind: 'unconfirmed' } + ) + } const { delivered, inComposer, unconfirmed, busy } = await sendStagedPrompt( prompt, receipt, args.target ) - if (delivered) { - args.options.onPromptDelivered?.() - } return { delivered, failureNotified: false, @@ -209,6 +279,13 @@ export function settleStructuredAgentLaunchPrompt(args: { } }, (error: unknown) => { + if (prompt?.createOwned) { + prompt.createSend?.settle( + error instanceof StructuredAgentSessionCreateRefusalError + ? { kind: 'refused', failure: agentSessionThrownFailure(error, error.code) } + : { kind: 'unconfirmed' } + ) + } // The chat never started: its text waits in the chat's composer for the person's own Send. if (prompt && !prompt.discarded && !prompt.delivery) { unstage(prompt) @@ -218,12 +295,24 @@ export function settleStructuredAgentLaunchPrompt(args: { throw error } ) - if (!prompt || prompt.delivery) { - return settled - } - const cancelled = prompt.whenDiscarded.then((): StructuredPromptDeliveryResult => ({ - delivered: false, - failureNotified: true - })) - return Promise.race([settled, cancelled]) + const result = prompt + ? Promise.race([ + settled, + prompt.whenCreateSettled.then(({ inComposer, ...outcome }) => ({ + ...outcome, + failureNotified: false, + ...(inComposer ? { inComposer: true as const } : {}) + })), + prompt.whenDiscarded.then((): StructuredPromptDeliveryResult => ({ + delivered: false, + failureNotified: true + })) + ]) + : settled + return result.then((outcome) => { + if (outcome.delivered) { + args.options.onPromptDelivered?.() + } + return outcome + }) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts new file mode 100644 index 00000000000..f172a83e7e4 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts @@ -0,0 +1,33 @@ +import { recoverPublishedSessionReceipt } from './structured-agent-session-launch-recovery' +import { publishStructuredLaunchCreateOptions } from './structured-agent-session-launch-create-message' +import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' +import { trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' +import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' +import { + getStructuredLaunchStateBySessionId, + notifyStructuredLaunchListeners +} from './structured-agent-session-launch-registry' + +/** A tab proves the chat exists; its journal must also confirm the immutable opening message. */ +export function confirmStructuredLaunchFirstMessagePublication( + worktreeId: string, + sessionId: string +): void { + const state = + getStructuredLaunchStateBySessionId(sessionId) ?? + restorePersistedStructuredLaunchState(worktreeId, sessionId) + if (!state || state.callers.outcome === 'pending' || state.cancelled) { + return + } + state.callers.outcome = 'pending' + state.visibilityUnknown = false + state.promise = recoverPublishedSessionReceipt(state) + .then((receipt) => publishStructuredLaunchCreateOptions(state, receipt)) + .catch((error: unknown) => { + state.visibilityUnknown = true + throw error + }) + trackLaunchSettlement(state, state.promise) + trackStructuredLaunchConfirmationDeadline(state, state.promise) + notifyStructuredLaunchListeners() +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index 76f4c728f99..3a8161a2dfe 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -8,6 +8,20 @@ import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' +function confirmStructuredLaunchFirstMessagePublication( + worktreeId: string, + sessionId: string +): void { + // Store teardown imports publication bookkeeping; recovery may read the store after it exists. + void import('./structured-agent-session-launch-publication-confirmation') + .then((confirmation) => + confirmation.confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) + ) + .catch((error: unknown) => { + console.warn('[native-chat] confirming a published opening message failed', error) + }) +} + /** `executionHostId` published the chat; only the host its launch was sent to settles it. */ export function markStructuredAgentSessionLaunchPublished( worktreeId: string, @@ -20,6 +34,10 @@ export function markStructuredAgentSessionLaunchPublished( if (persisted?.executionHostId !== executionHostId) { return false } + if (persisted.firstMessage) { + confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) + return false + } deleteStructuredAgentLaunchRecord(sessionId) notifyStructuredLaunchListeners() return true @@ -38,6 +56,10 @@ export function markStructuredAgentSessionLaunchPublished( if (state.callers.outcome === 'pending') { return true } + if (state.intent.params.firstMessage) { + confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) + return false + } state.callers.outcome = 'published' deleteStructuredAgentLaunchRecord(sessionId) state.callers.onSettled() diff --git a/src/renderer/src/lib/structured-agent-session-launch-record.test.ts b/src/renderer/src/lib/structured-agent-session-launch-record.test.ts new file mode 100644 index 00000000000..20de32c7641 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-record.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { validRecord } from './structured-agent-session-launch-record' + +const record = { + sessionId: 'codex_11111111_2222_3333_4444_555555555555', + executionHostId: 'local', + agent: 'codex', + lifecycle: 'visibility-unknown', + clientOperationId: 'create-operation', + payloadFingerprint: 'a'.repeat(64), + expectedRuntimeFence: null, + createMessageSupport: true, + firstMessage: { + clientMessageId: 'opening-message', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening π text' }] } + } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('a persisted create opening message', () => { + it('loads the frozen user message in a browser without Node Buffer', () => { + vi.stubGlobal('Buffer', undefined) + expect(() => validRecord(record)).not.toThrow() + expect(validRecord(record)).toBe(true) + }) + + it.each([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'forged response' }] }, + { kind: 'message', role: 'user', blocks: [{ type: 'tool-call', name: 'forged tool' }] }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 5 }] } + ])('refuses a saved opening body that cannot be sent through the user-message wire', (body) => { + expect(validRecord({ ...record, firstMessage: { ...record.firstMessage, body } })).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-record.ts b/src/renderer/src/lib/structured-agent-session-launch-record.ts new file mode 100644 index 00000000000..0f631ef5811 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-record.ts @@ -0,0 +1,125 @@ +import { isTuiAgent } from '../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { + StructuredAgentSessionFirstMessage, + StructuredAgentSessionResumeSource +} from '../../../shared/structured-agent-session-create' +import { isAgentSessionOptions } from '../../../shared/agent-session-record' +import { SendBodyStructure } from '../../../shared/rpc-contract/structured-agent-session-message-params' +import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' + +export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' + +export type StructuredAgentLaunchPersistedRecord = { + sessionId: string + /** The host the chat was created on. Records written before paired hosts could hold a chat lack + * it and load as local, the only host a chat could then be launched on. */ + executionHostId: ExecutionHostId + agent: TuiAgent + lifecycle: StructuredAgentLaunchPersistedLifecycle + clientOperationId: string + payloadFingerprint: string + expectedRuntimeFence: number | null + resumeFrom?: StructuredAgentSessionResumeSource + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> + createMessageSupport?: boolean + /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ + seedOptions?: Readonly> + /** When a failed launch failed; records written by older builds lack it. */ + failedAt?: number +} + +/** What survives a reload of an unpublished launch. */ +export function structuredAgentLaunchRecordFor( + intent: StructuredAgentSessionLaunchIntent, + lifecycle: StructuredAgentLaunchPersistedLifecycle +): StructuredAgentLaunchPersistedRecord { + const { envelope, resumeFrom, firstMessage, options } = intent.params + // A local launch re-reads this machine's settings on reload; only a paired server's seed is kept. + const pairedSeed = intent.target.kind === 'local' ? undefined : intent.seedOptions + return { + sessionId: intent.sessionId, + executionHostId: intent.executionHostId, + agent: intent.agent, + lifecycle, + clientOperationId: envelope.clientOperationId, + payloadFingerprint: envelope.payloadFingerprint, + expectedRuntimeFence: envelope.expectedRuntimeFence, + ...(resumeFrom ? { resumeFrom } : {}), + ...(firstMessage ? { firstMessage } : {}), + ...(options ? { options } : {}), + ...(intent.createMessageSupport === undefined + ? {} + : { createMessageSupport: intent.createMessageSupport }), + ...(pairedSeed ? { seedOptions: pairedSeed } : {}) + } +} + +export function validRecord(value: unknown): value is Omit< + StructuredAgentLaunchPersistedRecord, + 'executionHostId' +> & { + executionHostId?: string +} { + if (!value || typeof value !== 'object') { + return false + } + if ( + !('sessionId' in value) || + !('agent' in value) || + !('lifecycle' in value) || + !('clientOperationId' in value) || + !('payloadFingerprint' in value) || + !('expectedRuntimeFence' in value) + ) { + return false + } + const { + sessionId, + agent, + lifecycle, + clientOperationId, + payloadFingerprint, + expectedRuntimeFence + } = value + const resumeFrom = 'resumeFrom' in value ? value.resumeFrom : undefined + const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined + const failedAt = 'failedAt' in value ? value.failedAt : undefined + const firstMessage = 'firstMessage' in value ? value.firstMessage : undefined + const options = 'options' in value ? value.options : undefined + const createMessageSupport = + 'createMessageSupport' in value ? value.createMessageSupport : undefined + return ( + (executionHostId === undefined || + (typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) && + typeof sessionId === 'string' && + sessionId.length > 0 && + isTuiAgent(agent) && + (lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed') && + typeof clientOperationId === 'string' && + typeof payloadFingerprint === 'string' && + (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && + (failedAt === undefined || Number.isFinite(failedAt)) && + (firstMessage === undefined || validFirstMessage(firstMessage)) && + (options === undefined || isAgentSessionOptions(options)) && + (createMessageSupport === undefined || typeof createMessageSupport === 'boolean') && + (resumeFrom === undefined || + (typeof resumeFrom === 'object' && + resumeFrom !== null && + 'providerSessionId' in resumeFrom && + typeof resumeFrom.providerSessionId === 'string')) + ) +} + +function validFirstMessage(value: unknown): value is StructuredAgentSessionFirstMessage { + return ( + typeof value === 'object' && + value !== null && + 'clientMessageId' in value && + typeof value.clientMessageId === 'string' && + 'body' in value && + SendBodyStructure.safeParse(value.body).success + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 8743cc1a27f..5badfaf0496 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -1,4 +1,5 @@ import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { launchStructuredAgentSession, StructuredAgentSessionCreateRefusalError, @@ -7,8 +8,13 @@ import { } from '@/lib/launch-structured-agent-session' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readStructuredSessionTabInventory } from '@/runtime/structured-session-tab-inventory' +import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' -export type StructuredAgentLaunchReceipt = { sessionId: string; fence: number } +export type StructuredAgentLaunchReceipt = { + sessionId: string + fence: number + firstMessage?: AgentJournalSubmission +} export type StructuredLaunchRecoveryState = { intent: StructuredAgentSessionLaunchIntent @@ -47,7 +53,7 @@ async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Pro } } -async function recoverPublishedSessionReceipt( +export async function recoverPublishedSessionReceipt( state: StructuredLaunchRecoveryState ): Promise { await verifyPublishedSession(state) @@ -61,7 +67,19 @@ async function recoverPublishedSessionReceipt( if (typeof fence !== 'number') { throw new Error('structured session fence publication unavailable') } - return { sessionId: state.intent.sessionId, fence } + const firstMessage = state.intent.params.firstMessage + ? history.page.submissions.find( + (submission) => + submission.clientMessageId === state.intent.params.firstMessage?.clientMessageId + ) + : undefined + if (state.intent.params.firstMessage && !firstMessage) { + return launchStructuredAgentSession(state.intent, state.onHostSeed) + } + if (state.intent.createMessageSupport === true) { + publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) + } + return { sessionId: state.intent.sessionId, fence, ...(firstMessage ? { firstMessage } : {}) } } async function retrySameIntent( @@ -72,7 +90,9 @@ async function retrySameIntent( try { const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed) throwIfLaunchCancelled(state) - await verifyPublishedSession(state) + if (state.intent.createMessageSupport !== true) { + await verifyPublishedSession(state) + } return receipt } catch (error) { if (state.cancelled) { @@ -116,7 +136,9 @@ export async function launchAndReconcile( } try { throwIfLaunchCancelled(state) - await verifyPublishedSession(state) + if (state.intent.createMessageSupport !== true) { + await verifyPublishedSession(state) + } return receipt } catch (error) { if (state.cancelled) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts index 5bf6859ef05..8a511ff876f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts @@ -33,7 +33,12 @@ export function restorePersistedStructuredLaunchState( payloadFingerprint: record.payloadFingerprint, expectedRuntimeFence: record.expectedRuntimeFence, ...(record.resumeFrom ? { resumeFrom: record.resumeFrom } : {}), - ...(record.seedOptions ? { seedOptions: record.seedOptions } : {}) + ...(record.seedOptions ? { seedOptions: record.seedOptions } : {}), + ...(record.firstMessage ? { firstMessage: record.firstMessage } : {}), + ...(record.options ? { options: record.options } : {}), + ...(record.createMessageSupport === undefined + ? {} + : { createMessageSupport: record.createMessageSupport }) }) } catch (error) { // A record naming a host no runtime serves cannot be retried anywhere. diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 50a352ce92e..4af2eae3879 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -19,13 +19,11 @@ import { addStructuredLaunchCaller, createStructuredLaunchCallerGroup, releaseStructuredLaunchCallerAfterUnknownOutcome, - structuredLaunchCallersHavePendingWork, type StructuredAgentLaunchOptions, type StructuredLaunchCaller } from '@/lib/structured-agent-session-launch-callers' import * as launchDraft from './structured-agent-session-launch-draft' import { - deleteStructuredLaunchStateIfCurrent, getStructuredAgentSessionLaunchLifecycle, getStructuredLaunchStateBySessionId, markStructuredAgentSessionLaunchCancelled, @@ -39,8 +37,18 @@ import { claimableStructuredLaunchAttempt, getJoinableStructuredLaunchState } from './structured-agent-session-launch-holders' -import { applyStructuredLaunchHeldOptions } from './structured-agent-session-launch-options' -import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' +import { + prepareStructuredLaunchCreateMessage, + publishStructuredLaunchCreateOptions +} from './structured-agent-session-launch-create-message' +import { + maybeCleanupLaunchState, + trackLaunchSettlement +} from './structured-agent-session-launch-outcome-tracking' +import { + trackStructuredLaunchPromptOutcome, + trackStructuredLaunchConfirmationDeadline +} from './structured-agent-session-launch-prompt-outcome' import { repeatedStructuredLaunchAttempt, structuredLaunchRequest, @@ -94,25 +102,13 @@ function joinLaunchDelivery( return mode ? { ...rest, promptDelivery: mode } : rest } -function cleanupLaunchState(state: StructuredLaunchState): void { - if (deleteStructuredLaunchStateIfCurrent(state)) { - notifyStructuredLaunchListeners() - } -} - -function maybeCleanupLaunchState(state: StructuredLaunchState): void { - if (state.callers.outcome === 'failed' || structuredLaunchCallersHavePendingWork(state.callers)) { - return - } - cleanupLaunchState(state) -} - /** Every sender waits on the launch promise, so picks held during launch reach the host first. */ function publishWithHeldOptions( state: StructuredLaunchState, created: Promise ): Promise { - return created.then((receipt) => applyStructuredLaunchHeldOptions(state, receipt)) + state.intent.prepareCreate = () => prepareStructuredLaunchCreateMessage(state) + return created.then((receipt) => publishStructuredLaunchCreateOptions(state, receipt)) } /** Each attempt's probe names the seed the paired server's create will use; the picker shows it. */ @@ -123,8 +119,11 @@ function adoptPairedHostSeed( if (JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { return } - const { seedOptions: _previous, ...intent } = state.intent - state.intent = seedOptions ? { ...intent, seedOptions } : intent + if (seedOptions) { + state.intent.seedOptions = seedOptions + } else { + delete state.intent.seedOptions + } state.selection = { ...state.selection, seed: seedOptions } notifyStructuredLaunchListeners() } @@ -151,6 +150,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { wasVisibilityUnknown ? reconcileUnknownLaunch(state) : launchAndReconcile(state) ) trackLaunchSettlement(state, state.promise) + trackStructuredLaunchConfirmationDeadline(state, state.promise) notifyStructuredLaunchListeners() } @@ -191,16 +191,15 @@ function joinStructuredLaunchState( // A re-delivery waits on the text its action staged, if any, and never stages its own. const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined const callerOptions = retrying || (repeat && !repeat.stagedPrompt) ? joinedWithoutPrompt : joined - return { - state: existing, - caller: addStructuredLaunchCaller({ - group: existing.callers, - launchResult: existing.promise, - target: existing.intent.target, - options: callerOptions, - stagedPrompt - }) - } + const caller = addStructuredLaunchCaller({ + group: existing.callers, + launchResult: existing.promise, + target: existing.intent.target, + options: callerOptions, + stagedPrompt + }) + trackStructuredLaunchPromptOutcome(existing, caller) + return { state: existing, caller } } function structuredAgentLaunchState( @@ -255,6 +254,7 @@ function structuredAgentLaunchState( options, stagedPrompt }) + trackStructuredLaunchPromptOutcome(state, caller) setStructuredLaunchState(state) notifyStructuredLaunchListeners() trackLaunchSettlement(state, state.promise) diff --git a/src/renderer/src/lib/structured-agent-session-legacy-launch-stop.ts b/src/renderer/src/lib/structured-agent-session-legacy-launch-stop.ts new file mode 100644 index 00000000000..3d574014390 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-legacy-launch-stop.ts @@ -0,0 +1,11 @@ +import { useStructuredLaunchCreateSupport } from './structured-agent-session-launch-create-support' +import { takeBackLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' + +/** Stop takes the launch text back only when the host predates create carrying the first message. */ +export function useStructuredLegacyLaunchStop(sessionId: string): { + legacyLaunch?: { takeBackText: () => void } +} { + return useStructuredLaunchCreateSupport(sessionId) === false + ? { legacyLaunch: { takeBackText: () => takeBackLegacyStructuredLaunchPrompts(sessionId) } } + : {} +} diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 1b7fb4db53e..9b53f8e3c34 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -17,6 +17,7 @@ import { AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability, @@ -52,6 +53,12 @@ export function supportsStructuredAgentSessionPromptCancel( return structuredAgentSessionHostSupports(target, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) } +export function supportsStructuredAgentSessionCreateMessage( + target: RuntimeClientTarget +): Promise { + return structuredAgentSessionHostSupports(target, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) +} + /** Whether the host writes no row for a Stop that stopped nothing, so a repeated Stop is quiet. */ export function supportsStructuredAgentSessionQuietRepeatedStop( target: RuntimeClientTarget diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts index 57f96f63aba..4e1ad623341 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts @@ -46,6 +46,11 @@ import { retireStructuredAgentSessionTab, suppressCancelledStructuredSessionTabs } from './structured-agent-session-tab-retirement' +import { + getStructuredAgentSessionReadOwner, + findStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from '@/components/native-chat/structured-agent-session-read-owner' const target: RuntimeClientTarget = { kind: 'local' } @@ -79,6 +84,7 @@ function snapshot(): RuntimeMobileSessionTabsResult { beforeEach(() => { vi.clearAllMocks() + resetStructuredAgentSessionReadOwnersForTests() mocks.closeSession.mockResolvedValue('closed') mocks.callRuntime.mockResolvedValue(undefined) mocks.hasTombstone.mockReturnValue(false) @@ -147,4 +153,32 @@ describe('structured agent session tab retirement', () => { expect(mocks.closeSession).toHaveBeenCalledTimes(1) release() }) + + it('retires an unmounted created reader only on the owning host', async () => { + const local = getStructuredAgentSessionReadOwner('session-1', target) + const remoteTarget = { kind: 'environment', environmentId: 'server-1' } as const + const remote = getStructuredAgentSessionReadOwner('session-1', remoteTarget) + const dispose = vi.spyOn(local, 'dispose') + retireStructuredAgentSessionTab({ target, worktreeId: 'wt-1', sessionId: 'session-1' }) + expect(findStructuredAgentSessionReadOwner('session-1', target)).toBeUndefined() + expect(findStructuredAgentSessionReadOwner('session-1', remoteTarget)).toBe(remote) + expect(dispose).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1')) + }) + + it('still closes the host tab if reader disposal fails', async () => { + const owner = getStructuredAgentSessionReadOwner('session-1', target) + vi.spyOn(owner, 'dispose').mockImplementation(() => { + throw new Error('subscription cleanup failed') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => + retireStructuredAgentSessionTab({ target, worktreeId: 'wt-1', sessionId: 'session-1' }) + ).not.toThrow() + expect(findStructuredAgentSessionReadOwner('session-1', target)).toBeUndefined() + await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) + expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1') + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) }) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 93779ae87b9..7ef64129b87 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -10,6 +10,7 @@ import { import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' import { stopStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-message-sender' +import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' import { closeStructuredAgentSession } from './structured-agent-session-close' import { withLocalSessionTabCloseOwner } from './local-session-tab-close-owner' import { executionHostIdForStructuredTarget } from './structured-agent-session-owner' @@ -29,6 +30,7 @@ export function retireStructuredAgentSessionTab(args: { sessionId: string onError?: (error: unknown) => void }): void { + retireStructuredAgentSessionReadOwner(args.sessionId, args.target) const key = retirementKey(args.target, args.worktreeId, args.sessionId) const existing = inFlightRetirements.get(key) if (existing) { diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts index 8b3c73e2ff1..bb948c90112 100644 --- a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -3,6 +3,11 @@ import * as intent from '@/lib/worktree-sleep-intent' import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { + getStructuredAgentSessionReadOwner, + findStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from '@/components/native-chat/structured-agent-session-read-owner' const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent const WORKTREE_ID = 'repo1::/path/wt1' @@ -27,6 +32,7 @@ describe('worktree sleep intent lifecycle', () => { beforeEach(() => { clearWorktreeSleepIntent(WORKTREE_ID) clearWorktreeSleepIntent(FOLDER_KEY) + resetStructuredAgentSessionReadOwnersForTests() }) it('is released by activating the worktree', () => { @@ -164,4 +170,25 @@ describe('worktree sleep intent lifecycle', () => { expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false) expect(woke).not.toHaveBeenCalled() }) + + it.each([WORKTREE_ID, FOLDER_KEY])( + 'retires created chat readers when %s is purged', + (workspaceId) => { + const store = createTestStore() + seedWorktree(store) + const target = { kind: 'environment', environmentId: 'server-1' } as const + const owner = getStructuredAgentSessionReadOwner('created-chat', target) + const local = getStructuredAgentSessionReadOwner('created-chat', { kind: 'local' }) + store.getState().createUnifiedTab(workspaceId, 'agent-session', { + entityId: 'created-chat', + executionHostId: 'runtime:server-1', + activate: false + }) + const dispose = vi.spyOn(owner, 'dispose') + store.setState(buildWorktreePurgeState(store.getState(), [workspaceId])) + expect(findStructuredAgentSessionReadOwner('created-chat', target)).toBeUndefined() + expect(findStructuredAgentSessionReadOwner('created-chat', { kind: 'local' })).toBe(local) + expect(dispose).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 39552a15a3e..bfca9033088 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -16,9 +16,11 @@ import { structuredLaunchStates } from '@/lib/structured-agent-session-launch-registry' import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' +import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import { structuredAgentSessionFocusOwner, + structuredAgentSessionTargetForTab, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' @@ -45,6 +47,7 @@ export function buildWorktreePurgeState( launch.intent.executionHostId ) discardStructuredAgentSessionChatSends(launch.intent.sessionId) + retireStructuredAgentSessionReadOwner(launch.intent.sessionId, launch.intent.target) clearWebSessionFocusIntentIfMatches( structuredAgentSessionFocusOwner(launch.intent.target), worktreeId, @@ -55,6 +58,12 @@ export function buildWorktreePurgeState( } for (const worktreeId of worktreeIdSet) { for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType === 'agent-session') { + const target = structuredAgentSessionTargetForTab(s, tab) + if (target) { + retireStructuredAgentSessionReadOwner(tab.entityId, target) + } + } // A retained launch here survived a reload, so its persisted record names its host. const owner = tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId) diff --git a/src/shared/agent-session-create-capabilities.ts b/src/shared/agent-session-create-capabilities.ts new file mode 100644 index 00000000000..68f05cc210a --- /dev/null +++ b/src/shared/agent-session-create-capabilities.ts @@ -0,0 +1,15 @@ +// The capabilities that gate what an `agentSession.create` may carry. + +// Why: `agentSession.create` is a strict object, so an older host refuses a payload carrying the +// reserved `tabId` rather than ignoring it. A client sends the field only to a host advertising this. +export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = + 'agentSession.create.tab-id.v1' as const +// Why: same strict object; a host advertising this records the conversation's first message with +// the chat it creates, so the message is the host's from the start. +export const AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY = + 'agentSession.create.message.v1' as const + +export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [ + AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 21aec935cda..ae2ddc18e25 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -326,6 +326,8 @@ export type AgentSessionMutationResult = // ─── Per-method payloads ──────────────────────────────────────────────────── export type AgentSessionAttachResult = { + /** Create's committed opening message, even when its row is outside the returned history page. */ + firstMessage?: AgentJournalSubmission sessionId: string fence: number page: AgentSessionHistoryPage diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 37f5cbcb7aa..c6f3882e4ed 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -12,12 +12,14 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY } from './protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from './agent-session-create-capabilities' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from './agent-session-optional-model-capability' // Electron clients can decode client-hosted page placement; becoming a page // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, // The renderer that reads a paired host's structured chats is the one that reads its own. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ea0e2e98237..5d6c8cf0fa6 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -58,6 +58,7 @@ export { } from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' +import { AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES } from './agent-session-create-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability' import { @@ -332,16 +333,11 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = 'git.antigravity-configured-model.v1' as const -// Why: `agentSession.create` is a strict object, so an older host refuses a payload carrying the -// reserved `tabId` rather than ignoring it. A client sends the field only to a host advertising this. -export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = - 'agentSession.create.tab-id.v1' as const - export const RUNTIME_CAPABILITIES = [ ...ORCAD_RUNTIME_CAPABILITIES, QODER_OWNED_TERMINAL_CREATE_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, - AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, + ...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, 'files.pathsExist', 'runtime.status.compat.v1', diff --git a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts index a54be6e3e72..bdcbdedaaa3 100644 --- a/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts +++ b/src/shared/rpc-contract/structured-agent-session-agent-params.test.ts @@ -4,8 +4,10 @@ import { AttachParams, CreateIntentParams, CreateSupportParams, - ModelCatalogParams + ModelCatalogParams, + RespondToQuestionParams } from './structured-agent-session-params' +import { AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES } from '../agent-session-question-answer' const ENVELOPE = { sessionId: 'agent-session-0123456789abcdef0123456789abcdef', @@ -15,6 +17,53 @@ const ENVELOPE = { } describe('structured agent params', () => { + it('keeps normal question answers valid and bounds the combined answer body after schema extraction', () => { + const base = { envelope: ENVELOPE, itemId: 'question-1', expectedRevision: 1 } + const ordinary = { ...base, answers: [{ questionId: 'q1', optionIds: ['yes'], other: 'why' }] } + expect(() => RespondToQuestionParams.safeParse(ordinary)).not.toThrow() + expect(RespondToQuestionParams.safeParse(ordinary).success).toBe(true) + const oversized = { + ...base, + answers: Array.from({ length: 4 }, (_, index) => ({ + questionId: `q${index}`, + optionIds: [], + other: 'x'.repeat(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES) + })) + } + expect(RespondToQuestionParams.safeParse(oversized).success).toBe(false) + }) + it('accepts only the same bounded user message a send accepts, with bounded options', () => { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening text' }] } + const params = { + envelope: ENVELOPE, + worktree: 'wt', + agent: 'codex', + firstMessage: { clientMessageId: 'message-1', body }, + options: { model: 'picked-model' } + } + expect(CreateIntentParams.safeParse(params).success).toBe(true) + for (const firstMessage of [ + { clientMessageId: '', body }, + { clientMessageId: 'message-1', body: { ...body, role: 'assistant' } }, + { clientMessageId: 'message-1', body: { ...body, blocks: [] } }, + { clientMessageId: 'message-1', body, source: 'provider' }, + { + clientMessageId: 'message-1', + body: { ...body, blocks: [{ type: 'text', text: 'x'.repeat(256 * 1024) }] } + } + ]) { + expect(CreateIntentParams.safeParse({ ...params, firstMessage }).success).toBe(false) + } + expect(CreateIntentParams.safeParse({ ...params, options: { model: 1 } }).success).toBe(false) + expect( + CreateIntentParams.safeParse({ + ...params, + options: Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [`key-${index}`, 'value']) + ) + }).success + ).toBe(false) + }) it.each(['claude', 'codex', 'grok', 'qwen-code'])('accept the agent id %s', (agent) => { expect(CreateSupportParams.safeParse({ worktree: 'wt', agent }).success).toBe(true) expect(ModelCatalogParams.safeParse({ agent }).success).toBe(true) diff --git a/src/shared/rpc-contract/structured-agent-session-message-params.ts b/src/shared/rpc-contract/structured-agent-session-message-params.ts new file mode 100644 index 00000000000..13c27385f94 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-message-params.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +export const MAX_PROMPT_BYTES = 256 * 1024 +export const MAX_BLOCKS = 64 +export const MAX_OPTION_LABEL = 512 + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +export const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendBodyStructure = z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + +export const SendBody = SendBodyStructure.refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' +) diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 0d89fda381f..d9dd55c7b44 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,3 +1,14 @@ +import { + SendBody, + MAX_OPTION_LABEL, + MAX_PROMPT_BYTES +} from './structured-agent-session-message-params' +export { + SendBlock, + MAX_BLOCKS, + MAX_OPTION_LABEL, + MAX_PROMPT_BYTES +} from './structured-agent-session-message-params' import { Identifier, JournalCursor, @@ -12,6 +23,7 @@ export { } from './structured-agent-session-identifiers' import { z } from 'zod' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' +import { isAgentSessionOptions } from '../agent-session-record' import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' import { normalizeExecutionHostId } from '../execution-host' import { @@ -27,16 +39,10 @@ import { // Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. export const MAX_RESPONSE_OPTION_ID_LENGTH = AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH -export const MAX_PROMPT_BYTES = 256 * 1024 - /** Matches the journal's bounds on one grouped prompt. */ const MAX_QUESTION_ANSWER_QUESTIONS = 4 const MAX_QUESTION_ANSWER_OPTIONS = 64 -export const MAX_BLOCKS = 64 - -export const MAX_OPTION_LABEL = 512 - /** One relaunch cannot offer more chats than a profile plausibly holds. */ export const MAX_RESTART_RESUME_SESSIONS = 512 @@ -114,26 +120,6 @@ export const ResumeSource = z }) .strict() -export const CreateIntentParams = z - .object({ - envelope: MutationEnvelope, - worktree: Identifier('Invalid worktree selector'), - agent: StructuredAgent, - resumeFrom: ResumeSource.optional(), - /** - * The tab id the client reserved for this chat, so it can place the tab before the reply. The - * host owns the id from here: it is persisted on the session record and is what the host's tab - * snapshot will publish, so it must be a host tab id, as `agent.launch` requires of `paneKey`. - * - * This object is strict, so an older host refuses a payload carrying it. A client sends it - * only after `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY` is advertised. - */ - tabId: z.string().refine(isAgentSessionSurfaceTabId, 'Invalid chat tab ID').optional() - }) - .strict() - -export const CreateParams = z.union([AttachParams, CreateIntentParams]) - export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), @@ -141,25 +127,6 @@ export const CreateSupportParams = z }) .strict() -/** Clients may only author user turns. Accepting an assistant or tool role here - * would let one client write words into the agent's mouth in another's - * timeline, and the provider — not the client — owns those. */ -export const SendBlock = z.discriminatedUnion('type', [ - z.object({ type: z.literal('text'), text: z.string() }).strict(), - z - .object({ - type: z.literal('image-ref'), - path: z.string().min(1).max(4096).optional(), - url: z.string().min(1).max(4096).optional(), - alt: z.string().max(MAX_OPTION_LABEL).optional() - }) - .strict() - .refine( - (value) => Boolean(value.path) !== Boolean(value.url), - 'Provide exactly one of path/url' - ) -]) - export const SendParams = z .object({ envelope: MutationEnvelope, @@ -168,20 +135,32 @@ export const SendParams = z * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is * advertised. Participates in the operation fingerprint, never the body fingerprint. */ delivery: z.literal('queue-if-active').optional(), - body: z - .object({ - kind: z.literal('message'), - role: z.literal('user'), - blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) - }) - .strict() - .refine( - (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, - 'Message is too large' - ) + body: SendBody }) .strict() +export const CreateIntentParams = z + .object({ + envelope: MutationEnvelope, + worktree: Identifier('Invalid worktree selector'), + agent: StructuredAgent, + resumeFrom: ResumeSource.optional(), + /** Strict payload: send only after the host advertises the create-tab-id capability. */ + tabId: z.string().refine(isAgentSessionSurfaceTabId, 'Invalid chat tab ID').optional(), + /** Both fields require `agentSession.create.message.v1` on the executing host. */ + firstMessage: z + .object({ + clientMessageId: Identifier('Invalid client message id'), + body: SendParams.shape.body + }) + .strict() + .optional(), + options: z.record(z.string(), z.string()).refine(isAgentSessionOptions).optional() + }) + .strict() + +export const CreateParams = z.union([AttachParams, CreateIntentParams]) + export const CancelParams = z .object({ envelope: MutationEnvelope, diff --git a/src/shared/structured-agent-session-create.test.ts b/src/shared/structured-agent-session-create.test.ts index 3bee2efb623..f83658659a1 100644 --- a/src/shared/structured-agent-session-create.test.ts +++ b/src/shared/structured-agent-session-create.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' +import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, RUNTIME_CAPABILITIES } from './protocol-version' import { AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, - RUNTIME_CAPABILITIES -} from './protocol-version' -import { structuredAgentSessionCreateParams } from './structured-agent-session-create' + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY +} from './agent-session-create-capabilities' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from './electron-remote-runtime-client-capabilities' +import { + structuredAgentSessionCreateParams, + type StructuredAgentSessionFirstMessage +} from './structured-agent-session-create' import { structuredAgentSessionCreateFingerprint, structuredAgentSessionPayloadFingerprint @@ -20,7 +25,12 @@ function nextUuid(): string { } function createParams( - overrides: { resumeFrom?: { providerSessionId: string }; tabId?: string } = {} + overrides: { + resumeFrom?: { providerSessionId: string } + tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Record + } = {} ) { return structuredAgentSessionCreateParams({ sessionId: SESSION_ID, @@ -33,6 +43,45 @@ function createParams( } describe('structured agent session create params', () => { + it('advertises create-message support only on clients using the new flow', () => { + expect(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain( + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ) + expect(NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES).not.toContain( + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ) + }) + it('fingerprints the first message identity, content and opening option picks', () => { + const firstMessage: StructuredAgentSessionFirstMessage = { + clientMessageId: 'message-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'opening text' }] } + } + const first = createParams({ firstMessage, options: { model: 'picked-model' } }) + expect(first.firstMessage).toEqual(firstMessage) + expect(first.options).toEqual({ model: 'picked-model' }) + expect(first.envelope.payloadFingerprint).toBe( + createParams({ firstMessage, options: { model: 'picked-model' } }).envelope.payloadFingerprint + ) + for (const changed of [ + createParams(), + createParams({ + firstMessage: { ...firstMessage, clientMessageId: 'message-2' }, + options: { model: 'picked-model' } + }), + createParams({ + firstMessage: { + ...firstMessage, + body: { ...firstMessage.body, blocks: [{ type: 'text', text: 'different text' }] } + }, + options: { model: 'picked-model' } + }), + createParams({ firstMessage, options: { model: 'other-model' } }) + ]) { + expect(changed.envelope.payloadFingerprint).not.toBe(first.envelope.payloadFingerprint) + } + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + expect(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY).toBe('agentSession.create.message.v1') + }) it('carries resumeFrom only when the create adopts a conversation', () => { expect(createParams()).not.toHaveProperty('resumeFrom') expect(createParams({ resumeFrom: RESUME })).toMatchObject({ resumeFrom: RESUME }) diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index 1ca1dd7f627..c4ef6fb40c5 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -1,5 +1,7 @@ import type { StructuredAgentId } from './agent-session-provider-handle' import type { AgentSessionMutationEnvelope } from './agent-session-wire' +import type { z } from 'zod' +import type { SendBody } from './rpc-contract/structured-agent-session-message-params' import { createStructuredAgentSessionOperationId, structuredAgentSessionCreateFingerprint @@ -25,6 +27,14 @@ export type StructuredAgentSessionCreateParams = { resumeFrom?: StructuredAgentSessionResumeSource /** Sent only to a host advertising `AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY`. */ tabId?: string + /** Sent only to a host advertising `AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY`. */ + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> +} + +export type StructuredAgentSessionFirstMessage = { + clientMessageId: string + body: z.infer } /** Provider-prefixed so a session id names its lane on sight, and underscore-only @@ -55,6 +65,8 @@ export function structuredAgentSessionCreateParams(args: { agent: StructuredAgentId resumeFrom?: StructuredAgentSessionResumeSource tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> randomUuid: () => string now?: number }): StructuredAgentSessionCreateParams { @@ -62,7 +74,9 @@ export function structuredAgentSessionCreateParams(args: { worktree: args.worktree, agent: args.agent, ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}), - ...(args.tabId ? { tabId: args.tabId } : {}) + ...(args.tabId ? { tabId: args.tabId } : {}), + ...(args.firstMessage ? { firstMessage: args.firstMessage } : {}), + ...(args.options ? { options: args.options } : {}) } return { envelope: { diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index d075af5d28b..1d20f32e09f 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,4 +1,5 @@ import { sha256 } from './sha256' +import type { StructuredAgentSessionFirstMessage } from './structured-agent-session-create' import { isAgentSessionHandleProvider, type StructuredAgentId @@ -48,6 +49,8 @@ export function structuredAgentSessionCreateFingerprint(input: { agent: StructuredAgentId resumeFrom?: { providerSessionId: string } tabId?: string + firstMessage?: StructuredAgentSessionFirstMessage + options?: Readonly> }): string { return structuredAgentSessionPayloadFingerprint({ method: 'agentSession.create', @@ -60,7 +63,9 @@ export function structuredAgentSessionCreateFingerprint(input: { resumeFrom: input.resumeFrom, // The host digests the same field; a retry naming another tab still replays with the // recorded one, since the host owns the id. - tabId: input.tabId + tabId: input.tabId, + firstMessage: input.firstMessage, + options: input.options } }) } diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index f5a67421301..aec8ad3988e 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -1,5 +1,7 @@ import type { AgentJournalItemBody, AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentSessionMutationEnvelope } from './agent-session-wire' +import type { z } from 'zod' +import type { SendBody } from './rpc-contract/structured-agent-session-message-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' export type StructuredAgentSessionSendMutation = { @@ -65,7 +67,7 @@ export type StructuredAgentSessionAttachment = { export function structuredAgentSessionSendBody( text: string, attachments: readonly StructuredAgentSessionAttachment[] -): AgentJournalMessageItem { +): z.infer { return { kind: 'message', role: 'user', diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 12fd794ff2d..d29a36c2da7 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -32,6 +32,7 @@ import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { resolveBaselineReleaseRef } from './release-checkout' import { callBuild, @@ -98,7 +99,9 @@ beforeAll(async () => { */ function legacyClientCapabilities(): string[] { return baseline.capabilities.filter( - (capability) => capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + (capability) => + capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY && + capability !== AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY ) } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 949457d4f63..cab588ad54d 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -26,6 +26,7 @@ export function structuredHostStub( restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })), continueInterrupted: vi.fn(async () => ({ sessionId, outcome: 'superseded' })), attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), + create: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), // Attach-shaped entries take a client-supplied location, so the host is asked whether it // supports creating there. A real host always answers; leaving it unstubbed made every // `ensure` refuse for the harness's own reason rather than the location's. @@ -111,7 +112,10 @@ export function installableHost( const host = { ...hostCalls, // The catalog read checks the session's record for a floating chat's own folder; none here. - deps: { modelCatalog: { read: hostCalls.modelCatalog }, store: { getRecord: () => null } }, + deps: { + modelCatalog: { read: hostCalls.modelCatalog }, + store: { getRecord: () => null, getOperationRow: () => null } + }, restartResume: { list: hostCalls.restartResumableList, listFailures: hostCalls.restartResumableFailures, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 2ce2afbb317..0a7753eac29 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -1,5 +1,6 @@ import { expect, vi } from 'vitest' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { attachParams, ATTENTION_READ, @@ -73,9 +74,17 @@ export async function expectDeclaredSurfaceExecutes( const retirement = vi.fn() const runtime = runtimeStub({ retireStructuredAttention: retirement }) for (const { method, hostMethod, result } of STRUCTURED_CALLS) { + const atRestCreate = + method === 'agentSession.create' && + build.capabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) && + clientCapabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + const expectedHostMethod = atRestCreate ? 'create' : hostMethod + const otherCreateMethod = + method === 'agentSession.create' ? (atRestCreate ? 'attach' : 'create') : null // Two methods share one host method, so "has been called" would already be // true from the earlier one: only this call's own delta pins the pairing. - const before = hostMethod ? hostCalls[hostMethod].mock.calls.length : 0 + const before = expectedHostMethod ? hostCalls[expectedHostMethod].mock.calls.length : 0 + const otherBefore = otherCreateMethod ? hostCalls[otherCreateMethod].mock.calls.length : 0 const replies = await callBuild( build, method, @@ -83,12 +92,15 @@ export async function expectDeclaredSurfaceExecutes( { clientKind: 'runtime', clientCapabilities }, runtime ) - if (hostMethod) { + if (expectedHostMethod) { expect( - hostCalls[hostMethod].mock.calls.length - before, - `${build.label}: ${method} did not reach the host` + hostCalls[expectedHostMethod].mock.calls.length - before, + `${build.label}: ${method} did not reach the host: ${JSON.stringify(replies)}` ).toBe(1) } + if (otherCreateMethod) { + expect(hostCalls[otherCreateMethod].mock.calls.length - otherBefore).toBe(0) + } if (method === 'agentSession.acknowledgeAttention') { expect(retirement).toHaveBeenCalledExactlyOnceWith(ATTENTION_READ) }