From b922342199bb4c816072deb42cc58cf755420488 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:16:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(native-chat):=20the=20chat=20reads=20"Stop?= =?UTF-8?q?ping=E2=80=A6"=20from=20your=20Stop=20until=20the=20turn=20actu?= =?UTF-8?q?ally=20ends=20(#24369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(native-chat): a Stop over a card sent now into the running turn keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded. * fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it. * test(native-chat): the Claude re-send case fails on its diff, inside the test's budget * test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again. * fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card * refactor(native-chat): derive the queue's pause from Stop and Resume journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety. * fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule. * fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row. * fix(native-chat): stop creating the unused queue pause table The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists. * fix(native-chat): a Stop's pause never hides the restart pause A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send. * test(native-chat): pin the Stop's no-resend, lift and held-card rules - The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind. * refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller The Stop row that paused the queue becomes the general Stop event { reason, turnId?, at, caller? }, whose reason is the host's existing stop cause. It still rides a tombstone of a host-only id (a released host deletes the journal from the first unknown row kind), and Resume keeps its own marker on its own id. Only a person's Stop (reason user-stop) pauses the queue. * test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event * test(native-chat): pin that Stop and Resume rows never reach apps or count as history * test(native-chat): only a person's Stop event pauses the queue * test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick. * test(native-chat): a card held at a starting agent is checked before the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane. * test(native-chat): a released build keeps and folds a journal holding Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses. * style(native-chat): format the Stop event changes * test(native-chat): type the released build's exports through one checked helper * fix(native-chat): the Stop/Resume row guard narrows to those tombstones only * test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue. * fix(native-chat): a Stop that stops nothing new writes no Stop event A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes. * test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop * chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled * fix(native-chat): any later Stop event ends a person's Stop pause A person's Stop paused the queue until their next accepted turn or Resume, and a later Stop of another reason (the host stopping the agent, an eviction, a close) was ignored. Now the pause is the latest Stop event's: a later Stop of any reason ends a person's pause, and only a person's Stop pauses. The fold keeps the latest Stop event whatever its reason. An eviction of a resting chat writes no Stop event (a Stop that stops nothing writes nothing), so it cannot release held cards; a test pins that no event means no lift. * fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed A Stop pressed before the agent's turn shows in the journal (before Claude's echo, or before Codex opens the turn) records no turn. A second press once the turn showed compared that missing turn with the live one, wrote a second Stop event, and held a card queued between the presses. A repeat is now judged by what was sent since the Stop in force: with nothing sent after it (a refused send aside), a Stop that named no turn, or named the live one, is repeated and writes nothing. Anything sent since and not refused, including a send whose fate is unknown, makes the new press write, since its interrupt may send that card back to waiting. Tests: the two-press case across the turn showing; a steer between the presses settled unknown; and a Stop naming a turn that ended while the next card is sent but shows no turn yet, which writes and holds that card. The fold test that claimed an eviction path is renamed. * fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes A Stop or Resume row's value is read from disk with no shape check, and the pause fold stored whatever it found. A stored `stopEvent: null` would then throw on every pause check for that chat: the queue's pick, its send, and every queue update to clients. No build writes such a row, so this is hardening. The fold now reads a Stop only when it is an object with a string reason and a finite time, and a Resume only when it is `true`. Anything else is ignored: it pauses nothing and ends nothing. The row is still not treated as malformed, which could cut the history short. * fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends Every stop that ends work now writes the Stop's event before it ends the child: a person's close of the chat, an eviction (worktree teardown, orchestration stop, tab cleanup) and the idle sweep's stop of a start that never landed. A stop that ends nothing writes nothing, and quit writes none: its resume marker records why. The turn-end write reads the latest Stop event where every turn row is built, so the adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a person's Stop or close named, ending with no verdict of its own after that Stop, ends as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that found the turn running. When the provider refuses the interrupt and the turn runs on, a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop again after a refusal is a new Stop. * refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event The cause of a stop was threaded in memory from each entry through the host's stop step, the adapter router and each adapter's close onto the `ended` it settled with, and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters settle a turn they cut as interrupted with no verdict, the host's fallback does the same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the journal's latest Stop event to say whether it was a person's. - `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`. - Claude reads an error result after a person's Stop as their cancellation from the journal's Stop event (through the event sink), not from a per-turn slot, and a refused interrupt is the host's refusal row, not `withdrawTurnStop`. - An owed wind-down keeps no cause: its retry's fallback reads the Stop event. - The mutation context's Stop passes no cause: its step already wrote the event, and the delivery loop's child-end reason is read back from it. - A Stop pressed before its turn showed applies to the turn that opens under it, unless a send a person made since was accepted. * test(native-chat): a turn a later send opened is no Stop's that named no turn * test(native-chat): the restart test's death proof carries its detail * refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and the Stop names that turn, so the turn running instead never reads as the person's by its id alone. * fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next A Stop that named no turn read as the person's cancellation for every later turn that opened after it, until a send a person made was accepted. The queue's drain, orchestration mail and a restart continuation send as the host, so a turn they opened long after, cut by a crash, read "Interrupted" as if the person had stopped it. The Stop now applies only to the first turn opened after it. * fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The translator judged whether a person's Stop explained it by its own copy of the Stop rule, which ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed". The translator now writes such an end as interrupted with no verdict and no error row whenever a person's Stop may name the turn, and the journal's one rule decides as it writes the end. * fix(native-chat): a person's Stop and /clear each name why they end the agent The host's mutation path ended the agent with one "recorded" ending for every caller, which read back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop `user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat. * fix(native-chat): a host stop judges whether it ends work after the provider's rows land A close, eviction or host stop decided whether it ended a running turn from the journal as it stood, while the provider's own rows (the turn its echo opened) could still be in the session's event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It now reads after the sink drains, as a person's Stop does, through the same check; a drain that fails or takes over a second reads working. * fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed, Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.". A Stop that ends the provider's session ends whatever is in flight, so its event now names the live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only the turn the Stop names. The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at its line budget. * fix(native-chat): the idle sweep reads working by the same rule as a stop's event The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event, which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed work now reads the main agent working the way every session list and the event writer do. * test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain A host stop now drains the session's sink once to judge whether it ends work, so the tests that fail the eviction's drain-published step skip that first drain. * fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop event, whatever send it retires: a person's Stop pause holds through it. * fix(native-chat): stopping a start that carries no send writes no Stop event A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried. A start with a send already reads working, so the clause only mattered for a start with none, which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle clock, which is why the idle sweep had been changed to close the conversation in the same pass. The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before. * test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns, restart as a process that dies with no close, which like a quit writes no Stop event, and assert that both the Stop's and the restart's pauses are in force first. * fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason An eviction or host stop that landed while a person's Stop or close was already ending the same turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of that turn read as news. A host reason now writes nothing while a person's Stop still decides what runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does, so the mail turn after it is not the turnless Stop's. * fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop The translator left an error result that names no reason to the journal's Stop rule whenever a person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn opened next. So a real error on a later turn read "Failed" with its error text dropped. The translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core `turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree. * fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it A person's Stop pressed while the agent starts names no turn, and the send it stopped is cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration mail, a restart continuation, the queue's drain, all of which send as the host), so a host eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A Stop that named no turn now binds only a turn no send journaled after it opened: any send since, of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex accepts it, instead of opening the stopped send's own turn first. * test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new sequence, so by sequence alone it would bind the next turn opened after the rewind. A send journaled after the restated row voids that binding (the previous commit), which this pins. * fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but the relaunch still added the error row saying the provider stopped mid-response, which a live Stop never writes. The settle now skips that row when every turn it interrupts is the person's Stop's by the journal's one rule; a crash nobody stopped keeps it. * test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn * fix(native-chat): a host stop whose sink drain fails reads the journal as it stands A host stop drains the session's sink before judging whether it ends work, and a failed or slow drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing, which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is now best effort: the stop goes ahead either way and only its record is at stake, so a failed or slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule. * fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened A person's Stop pressed before any turn showed names no turn. It bound the first turn opened after it, then (5ead1f6bccd) any turn opened by no later send, so a turn the host started for a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host eviction of it wrote no Stop event when its send had been abandoned by the close first. The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal aliases to the submission), and a handed-over send's item sits at its handover, so the target set is derived from the journal. A card the Stop held is handed over after it, so it is no target; a Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a restated Stop binds no turn opened after it. With no turn running, a host stop defers to the person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks before its echo row lands, passes the send its echo acknowledged. * fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal A drain past its bound may still hold the turn's row, while the echo's acceptance has already landed, so the journal as it stands read nothing running: a person's close of that turn wrote no Stop event and the turn read as news. The two drain outcomes now differ: one that failed has nothing more to deliver, so the journal's read holds (as before); one still running reads working. * test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's The branch had no test. An eviction with only the stopped send unanswered writes nothing; one with a send made after the Stop still unanswered writes its event. * fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due The previous commit read every drain past its bound as working, so a host eviction or stop of an agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened no turn the journal holds, the race it was for; otherwise the journal's read holds. * test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule that deferred when any one was would pass the old control. * fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered into a running turn, whose item carries that turn's scope, are skipped. * feat(native-chat): a chat reads Stopping from the person's Stop until the work it stopped ends The host derives it on each journal publish from the Stop's event, the live turn and the Stop's own answer, and publishes it as an optional field on the session status and the main agent's row. Clients present it: the chat's tail line and Stop control, the sidebar row, worktree ps and the phone's row. The chat and the phone also read their own Stop press until its request answers. * test(native-chat): pin Stopping on the phone and across mixed versions * test: give touched fake journals and mocks their SAFETY notes * test(native-chat): a turn waiting on the person reads attention, never Stopping * test(native-chat): the sidebar row follows Stopping when it is the only field that moved * test(mobile): the phone reads Stopping from its own Stop until the request answers * test(mobile): type the held Stop request instead of casting it * chore: keep the base lockfile (a local pnpm run rewrote it) * fix(native-chat): narrow the Stop note's optional failure; type the phone test's reply * test(native-chat): type the Stop test envelope's fields narrowly * fix(native-chat): a Stop the agent declined, or whose child end failed, says so while the turn runs on A Stop naming the turn that still runs, refused by the agent, now writes the Stop's refused fact instead of 'already finished'. A session-ending Stop whose child end fails while the work runs on revises its note to unconfirmed. * fix(native-chat): Stopping holds while any press of the Stop took A repeat press refused after an earlier press took no longer clears Stopping. Exit early when the Stop named a turn that is not the live one. * fix(native-chat): keep Stop enabled while the host says Stopping Only this client's own Stop request in flight disables Stop and Esc. A repeat Stop is how a stop the provider took but never answered escalates. * fix(sidebar): every agent row says Stopping in place of its tool line The dashboard row, which the sidebar's non-compact mode also draws, read the last tool line while a person's Stop ended the turn. It now shares the compact row's rule. * fix(mobile): the worktree list sees Stopping change on its own A snapshot whose only change was the host dropping Stopping compared equal and was thrown away, leaving the row on Stopping. * refactor(native-chat): fold the status feed in src/shared for both clients The snapshot merge and the contact-loss strip move out of the renderer feed so the phone folds the same stream the same way. * feat(mobile): let phones read the structured session status stream agentSession.subscribeStatus joins the mobile allowlist. The agent-session methods move to their own file, which the at-cap allowlist spreads in, and the allowlist test reads the Set instead of parsing the source. * feat(mobile): the phone chat reads Stopping from the host, like the desktop One status stream per client, opened on a host that advertises the status feed; a refusal to phones reads as no feed. The chat reads Stopping from the host or its own press, and holds Stop only while its own request is in flight. * test: give the new fakes checked types or a SAFETY reason * revert(native-chat): drop the refused-named-turn rewrite of a Stop's note Codex can send its refusal before the turn's end frames, so reading the turn as still live after a flush races; the Codex Stop that ends nothing is handled by ending the process instead. The base's 'already finished' note and its test expectation return. The Claude wind-down failure revise stays. * fix(mobile): release the status stream when the host ends it; refusals last one connection The feed now drops the handle of a stream the host ended or refused, so the logical client never replays it on a later session. A refusal to phones holds for one connection, so a host updated while the phone stays paired is asked again. * perf(native-chat): read the live turn's opener from its record when deriving Stopping After a Stop that named no turn, every later commit walked and copied the whole journal to find the live turn's record. The derivation now reads that record off the rendered snapshot's tail and decides with the same rule. * refactor(mobile): move the method-unavailable check into transport The status feed imported it from the Files tab's fallback. No behaviour change. * test(native-chat): a send after a Stop reads Working before its turn opens Pins the derivation's running-only read of the newest turn: the stopped turn, already ended, must not keep the next send on Stopping. * fix(sidebar): the compact row leads with Stopping so a narrow sidebar keeps it whole At the default width the row read 'Codex Chat - Stoppin…': the model and time keep their room and the line truncates from the end. Stopping now leads the line the way monitoring already does, so the chat name is what gets cut. Also pins that the turn bar keeps its running clock while the tail line says Stopping. * test(native-chat): the retry of a close whose exit was unproven writes no second Stop event The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its event stands alone and the child's end keeps the cause, for a person's close and an eviction. * fix(native-chat): read and write a Stop's answer by the turn its event records Stop notes are now one row per turn, keyed by the turn the Stop's event records. Performing a Stop and deriving Stopping share that key. A refused or unconfirmed answer never overwrites one that took at the same key; only a session-ending Stop's failed wind-down downgrades it, and that step now revises the note the Stop actually wrote, carried on the wind-down. Stopping reads the turn's note whenever it was first written, plus newer notes no other turn owns. Test fixtures gain the host logger and the phone's quietRepeatedStop. * refactor(native-chat): read a Stop's target once for its event and its note The chat's Stop now reads what it is aimed at (the named turn and whether the Stop ends the provider session) once, and both its event and its note's key derive their turn from that one value through the same rule. Adds the host test for a Stop naming an ended turn on a provider whose Stop ends the session. * fix(native-chat): the host never steers a message into a turn a Stop is ending A queued card's Send-now, or a send made while a person's Stop ends the turn, went to the agent as a steer into that turn. The delivery loop now holds any waiting message while the host's own Stopping reading holds, and sends it as its own turn once the turn ends. The Stopping reader also stops at the Stop's position and looks the turn's note up by key, instead of walking the whole journal. * feat(native-chat): while Stopping, the composer says a message runs after the stop Desktop and phone: the composer placeholder reads "Queue a message to run after the stop" while the chat reads Stopping, and a queued card's Steer (and the desktop's steer shortcut) is held. New key translated in all 6 catalogs. * refactor(native-chat): the chat pane's Stop controls live in their own module The pane went over its line limit once merged with main. Its Stopping reading, the press that holds Stop, and the steer and placeholder it hands the composer move to native-chat-structured-stop-controls.ts. * fix(native-chat): hold a send at its handover, reading the feed's own Stopping The hold was checked when the delivery step started, but the handover runs in a later step after waiting on the agent's start, so a Stop landing in between let a new send steer into the stopping turn. The check now runs at the handover. It reads the status feed's projection for the commit instead of rendering the journal again, so holding a send adds no journal read of its own. * refactor(native-chat): one display status decides Stopping on every surface agentStopDisplayStatus combines whether the agent works, the host's flag and this client's own press. The chat pane, sidebar and dashboard rows, and the phone's chat all read it, instead of each combining the flags. * fix(native-chat): Stopping holds until the stopped turn ends, whatever the Stop's answer A Stop the agent declined, or whose end went unconfirmed, used to drop the chat back to Working. It now stays on Stopping until the turn ends, and Stop stays enabled so a repeat press escalates. The Stop's answer is no longer read for Stopping, so its note goes back to the key the base gives it (the restore of queued-stop.ts and the removed key test landed in the previous commit). The note still keeps a press that took over a later refusal, and a failed process end still says the Stop went unconfirmed. * fix(native-chat): a Stop binds only the turn it actually stopped A Stop pressed before any turn showed used to claim, at end-write time, whatever turn the stopped send later opened, even when the Stop stopped nothing. A turn that then died on its own read as "Interrupted" (your cancellation) instead of "Failed". Now a person's Stop that named no turn binds, in memory only, every turn that ends while the Stop settles, and afterwards only the turn its interrupt took. The settle ends a still-running stopped turn once. A relaunch finds nothing in memory, so an unsettled turnless Stop binds no turn. A Codex Stop whose answered turn does not open within its wait, or whose send's answer was lost, now answers refused, so the host ends the child and the turn can never run. * fix(native-chat): keep the person's queue pause and close binding after a Stop settles A host stop or eviction with no turn running now defers to a person's Stop while its queue pause still holds with nothing sent since, read from rows, so a held card is not handed off on reopen after a Stop that did nothing or whose kill failed. A person's close that named no turn opens a settle around its child's end, so a turn that end cuts reads as theirs. A press opens its settle only when the latest Stop event is its own or the one in force it repeats: a late Stop, a card's interrupt or a lost event row reopens no earlier Stop. A Codex Stop that cannot reach a turn still able to open says the Stop is unconfirmed rather than that no turn ran, and a second Stop still reaches a turn an earlier wait left unopened. Also drops the unused openedBy plumbing and the unreachable "a written cancellation stays one" rule, and pins a relaunch after a named Stop. * fix(native-chat): keep a failed Stop's turn display-only, and settle edges off the commit path A Stop that failed marks the turn it could not stop for "Stopping…" only (JournalStopSettle.failedOn): no turn-end rule reads it, so that turn's own end with no verdict reads as a failure, not the person's. A settle edge writes no row, so it no longer goes through the journal's commit listener, which also delivers history, counts as activity for the idle sweep and schedules the queue drain. A narrow settle-edge hook republishes the status row and wakes the steer hold's handover, and nothing else. * fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle A Codex Stop that waited for a turn Codex answered a send into now answers "may still open" whenever that turn neither opened nor ended and its send is still owed, however the wait ended (it ran out, or the thread went idle). Before, a first press after an idle thread said no turn was running and kept Codex, while an identical second press ended it. Adds a test that a person's close the conversation outlives (as /clear does) closes its settle, so a later turn that ends on its own reads as a failure. * fix(native-chat): a Stop that failed before its turn showed still reads Stopping through that turn A Stop that failed with no turn open marked nothing, so the chat dropped to Working and the turn that then opened never read "Stopping…". The display-only mark now also covers that case: the first turn that opens after the Stop failed, provided no message was handed to the agent in between. No turn-end rule reads the mark, so that turn's own end with no verdict still reads as a failure. * test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop With one ordered journal writer the Stop's event is in the fold when its write returns, so the press reads whether it owns the latest Stop from the fold instead of awaiting the write. Pins the case the read must refuse. * test(native-chat): name the settle, not a stream drain, in the Stop's own-end test * test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout Codex answers an interrupt it took before it sends turn/completed (interrupted): on TurnAborted the app-server answers pending interrupts, then ends the turn, on one channel. The test fake did the reverse. It now answers first and ends the turn on a later read, and the tests that read the turn's end right after a Stop wait for it. New end-to-end test through the shipped host, journal and Codex adapter: with the real order, every end row of the stopped turn reads interrupted by the Stop (named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the settle window turns the in-flight case red: the Stop's own end row then has no verdict, which reads as failed until Codex's end lands. * test(native-chat): Stopping ends with a Codex turn whose interrupt is answered before its end With Codex's real order (the interrupt's answer, then turn/completed interrupted), the status shows Stopping while the Stop settles, drops it once the turn ends, and never carries a verdict other than the person's cancellation. * fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once A Stop that named no turn, made after Codex answered a send but before the turn opened, used to wait up to 5 s for the turn to open before interrupting, and ended the Codex process when it didn't. The stated reason, that Codex refuses an interrupt until it opens the turn, holds only part of the time: with no turn active, Codex takes an interrupt once its thread runs (turn_interrupt_inner), and refuses it with -32600 "no active turn to interrupt" before that or once the turn has ended. The Stop now sends the interrupt at once. Only on that refusal, while the turn has neither opened nor ended, does it wait for the turn to open (bounded at 5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One that never opens, or that an earlier wait already gave up on, fails the Stop, and the host ends the child as before. Sends still wait for the turn to open before steering into it. The test fake models Codex taking an interrupt once the thread runs (run()). * fix(mobile): name how the phone's status stream is released in the subscription inventory Main made each inventory entry state its release; the status feed's stream is released from its subscribe params, as the session event stream is. * fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did A Stop whose interrupt Codex refused as finding no active turn skipped the wait when an earlier wait, a Stop's or a send's, had already given up on that turn. Every press now waits its own bound and retries once if the turn starts, so a turn that opens during a later press is still stopped. Both presses still reach the same verdict when it never starts. * fix(codex): never steer a turn whose interrupt Codex answered Codex answers an interrupt as the turn aborts, before it sends that turn's turn/completed. In that gap the adapter still counted the turn as running, so a message handed over right after a Stop settled (the Stop's own end row already reads the turn ended) went out as turn/steer, which Codex refused with -32600 "no active turn to steer", and only then as turn/start. The adapter now marks a turn whose interrupt Codex answered as aborted until its turn/completed, never steers into it, and starts the message's own turn directly. Steering a turn that is genuinely running is unchanged. * fix(native-chat): a message sent while Stopping is queued as a card, whatever the setting While the chat reads Stopping (the host's flag or this client's own Stop in flight) there is no turn left to steer into, so the desktop asks the host to queue the send even with the queueing setting off, and it is never drawn as a bubble inside the turn being stopped. The phone already queued every send on a capable host; a test now pins that it does so while Stopping. * fix(native-chat): a message queued while Stopping is a card at once, not after the stop A person's Stop holds the session's lane until Codex answers its interrupt, and a send was admitted only behind it. By then the turn read ended, so a send that asked to be queued went out plain: no card for the whole of Stopping, then a bubble and a new turn. While the host reads that a person's Stop is ending the work, a text send that asks to be queued is admitted without waiting for the lane: the same ledger and lease admission, and a plan that only writes the card through the journal's ordered writer. The card runs when the stop lands; the Stop's pause holds only cards queued before it. Anything else, including a Stop that settled by the time the send runs, takes the lane as before. The Codex test fake now drops the active turn when it takes an interrupt, as Codex does before it answers, so a turn/start after the answer opens a new turn. * fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send A Stop on a Codex turn that was answered but never opened ends the Codex process. That end settled the send as in doubt (unknown, recovered), and the client's outbox holds every later send behind a send in doubt until the person presses Retry, which re-sends the very message they stopped. The chat looked stuck. Codex records a prompt only once its turn has started, so a send whose turn never opened never ran. When the Stop's refusal says so (turnMayOpen), the child end now settles the unanswered sends as withdrawn, the verdict Codex's own interrupted-turn end already gives an unechoed send. The flag rides on the owed wind-down, so a retry after a failed child end withdraws them too. Claude's child end still leaves its unanswered send in doubt. * fix(native-chat): derive the withdrawal of a Codex send whose turn never opened Replaces the flag the Stop carried to the child's end, and its copy on the owed wind-down, with a reading of the journal at the settlement that lands. A Codex child's unanswered send is withdrawn when a person's Stop is in force since it was sent and no turn row ran, or was written, after it; any other end (a turn that opened, a host's close, a crash, Claude) still leaves it in doubt. A retried wind-down reads the same rows, so it withdraws the same sends. * fix(native-chat): a card queued while Stopping runs past the cards the Stop holds A card queued before a person's Stop waits under its pause until Resume. One queued after it, as a message sent while Stopping now is, was stuck behind them too, since the queue never reorders. Such a card was asked for after the Stop, so it runs when the stop lands, past the cards held only by that Stop's pause; a returned card and the restart and /clear pauses still hold everything behind them. Also: the host's own Stopping reading is gated on working, as the published flag is, so a failed Stop's mark never reads Stopping on an idle session; a send that falls back to the lane re-reads the conversation's journal there; and the end-to-end test asserts the queue's pause rather than a per-card field. * fix(native-chat): a paused queue labels only the cards it holds Since a card queued after a person's Stop runs past the cards the Stop holds, labelling every card "paused" while the queue's pause is published misreads that card. The host now marks each card its pause holds (heldByPause, a new optional field), and the desktop and phone label only those. An older host marks none, so a client keeps today's labels; an older client ignores the field. Adds a test of the desktop's own send through the real outbox: while the chat reads Stopping, the request asks the host to queue it and no bubble is drawn, on a host that advertises the queue. * revert(native-chat): defer the per-card queue pause label to the queue's rollout The heldByPause field and its labels are visible only where the host advertises the queued-messages capability, which shipped hosts do not yet do. Deferred to that rollout; the real-outbox send test stays. * fix(native-chat): while Stopping, say and show what a send does where the queue is dark Shipped hosts do not advertise the queued-messages capability, so a message sent while Stopping goes out plain: the host holds it until the stopped turn ends and then runs it as its own turn. The composer still said "Queue a message to run after the stop", and the message was drawn inside the turn being stopped. Now the placeholder reads "Send a message to run after the stop" where the host does not queue sends, and "Queue a message…" only where it does (desktop and phone, all six catalogs). A send this client made that the host has not recorded yet is drawn after the Stopping line while the chat reads Stopping, as a message held behind a running command already is; once the host hands it over it opens its own turn. Client presentation only. * fix(native-chat): keep a send in doubt when a turn was open for it The derived withdrawal read a turn as open for a send only if it still ran or was written after the send. A send steered into a running Codex turn whose interrupt failed met neither once the adapter's end settled that turn ahead of the host's settle, so it read withdrawn, though Codex drains a steer into the running turn and may hold it. A turn that ended after the send was handed over was open for it too: such a send stays in doubt, as before. Pins that case, and that a send made after the Stop, to a child that then dies before its turn opens, stays in doubt. * fix(native-chat): restore the per-card queue pause label Kept after all: a paused queue labels only the cards it holds (heldByPause), which is visible only where the host advertises the queued-messages capability. * fix(native-chat): draw only a send made while Stopping after the Stopping line Every send the host had not recorded yet was drawn after the Stopping line, including one made just before the Stop, which the host steers into the turn; it then jumped up into that turn once recorded. The outbox now marks a send made while the chat reads Stopping, and only those wait after the line. * fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing Whether a turn was open for a send was read from end times: a turn that ended after the send's handover counted. A send made while a Stop ended the turn is handed over once that turn reads ended, yet Codex's own end for it can arrive later, so such a send whose own turn never opened read in doubt again, and the chat's queue held behind it. The handover already records where the send went: its message joins the turn running then (a steer) or belongs to no turn (it starts its own). Only a send that started its own turn, with none opened since, is withdrawn; one that joined a running turn, or has no recorded place, stays in doubt. The Codex test fake takes an answered interrupt as Codex does, dropping the turn before its end arrives. * refactor(native-chat): move queue-while-stopping to its own follow-up The queued-messages capability is off on every shipped host (#21062), so the parts of this PR that act only when it is on move to a follow-up stacked on this one: admitting a queued card while a Stop holds the session's lane, a card queued after a Stop running past the cards it holds, the per-card pause mark, and asking the host to queue a send made while Stopping. This PR keeps the Stopping state, the host's steer hold, the rule that never steers a turn whose interrupt Codex answered, and what a send while Stopping looks like where the queue is off. * fix(native-chat): leave no Stop row when the Stop took back a send that never ran A Stop on a Codex send whose turn never opened ends the child, and the child's end takes the send back into the composer. The Stop still wrote "Cancellation requested." at the conversation level, so with the send gone it sat under the previous finished turn and read as if that turn had been stopped. A Stop that found no turn running and whose child end took back every send it found now writes no row; a Stop of a running turn, or one that leaves a send in doubt, still does. * fix(native-chat): count a send whose answer was lost when a Stop takes it back The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart. * test(native-chat): pin which sends a Stop's child end can take back A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted. * fix(native-chat): read the host's Stopping beside main's startup phase Main now reads only the startup phase from the status feed and no longer publishes which child is starting. The chat reads the host's Stopping from its own hook beside it, and the Stopping bridge test mocks the execution-host lookup main's owner resolution now calls. * test(native-chat): read the outbox reconcile from where main moved it * fix(native-chat): a retried message no longer waits behind a later Stop Retry dropped the Stop it had outlived but kept the mark that it was sent while a Stop was ending a turn, so a retried message waited behind whatever later, unrelated turn a Stop was ending. Retry is a new send: drop that mark too. * fix(native-chat): word a send after a Stop by whether this send will queue The 'queue a message to run after the stop' placeholder read the host's queue capability alone. A send queues only when the host queues and this send asks it to: the queue setting is on and no pending prompt blocks the queue. Desktop and phone now word the placeholder from that same decision their send uses. * test(native-chat): one test per case for the words of a send after a Stop * refactor(native-chat): the dictation hook owns the composer's dictation state Keeps NativeChatComposer within its line limit after the Stop props and main's /context answer both landed in it. * refactor(native-chat): name the dictation hook for what it owns now * fix(native-chat): a Stop's note says it took once a joined close proves the exit A session-ending Stop whose child's end failed revises its note to 'unconfirmed'. Since main's #24862, the next Stop joins that close rather than stopping again, and wrote no note, so a close that then proved the exit left 'unconfirmed' under a turn that ended. The close now carries the note it settles, and its proven exit revises it to 'Cancellation requested.'. A join that fails again leaves it unconfirmed. * fix(native-chat): a proven turn end says a Stop's unconfirmed note took Replaces the note carried on the child's close. Every settlement that ends turns interrupted on a proven exit, live or after a crash, also revises an unconfirmed Stop note on those turns to 'Cancellation requested.', found by the note's turn scope, in the same batch. A note a Stop wrote before its turn showed is re-keyed onto the running turn when it becomes unconfirmed, in one batch, so that end finds it. Known limit: with no turn open yet, the note keeps its key and no turn's end revises it. * test(native-chat): a Stop's unconfirmed note says it took when the agent exits on its own * test(native-chat): name the Codex handle as main's opaque handle does * test: restore the provider handle import the main merge dropped * fix: derive Stop note wording from interrupted turns * test: name the raw replay case for what it covers * refactor(native-chat): move the waiting-slot split into its own hook * test: follow main's chat font-size rename in the stopping tests * test: follow main's single live-line value in the Stopping tests * test: give the android live-line fixtures the stopping field * chore: keep the session host under its line limit after the main merge * chore: keep the composer test and the phone chat view under their line limits after the main merge The Stop control now disables itself while Stopping, so the composer passes the flag through and its test file stays as main has it. The phone chat header's Stop moves to its own component. * perf(native-chat): read a Stop note's fields before parsing its key on every snapshot Every snapshot projects each item through the Stop-note read, and each new snapshot rebuilds the index of Stop notes by turn. Both parsed every item's key first; they now check the row's kind and turn scope (and, for the projection, its unconfirmed-stop failure) before the parse. Every Stop note is a status row, so what each finds is unchanged. --- mobile/src/files/MobileFileExplorerPanel.tsx | 3 +- .../src/files/file-explorer-reply-schema.ts | 2 +- mobile/src/files/file-list-fallback.test.ts | 17 +- mobile/src/files/file-list-fallback.ts | 14 - .../MobileNativeChatLiveLine.android.test.ts | 24 +- .../session/MobileNativeChatLiveLine.test.ts | 7 + .../src/session/MobileNativeChatLiveLine.tsx | 5 +- .../session/MobileNativeChatOverlay.test.ts | 32 + .../src/session/MobileNativeChatOverlay.tsx | 5 +- .../MobileNativeChatQueuedMessages.test.tsx | 19 + .../MobileNativeChatQueuedMessages.tsx | 11 +- .../session/MobileNativeChatStopButton.tsx | 28 + .../src/session/MobileNativeChatView.test.ts | 49 ++ mobile/src/session/MobileNativeChatView.tsx | 22 +- .../MobileNativeChatView.turn-status.test.ts | 6 +- ...uctured-agent-session-host-support.test.ts | 40 +- ...e-structured-agent-session-host-support.ts | 5 + ...ile-structured-session-status-feed.test.ts | 200 ++++++ .../mobile-structured-session-status-feed.ts | 139 ++++ ...use-mobile-native-chat-input-lease.test.ts | 22 + .../use-mobile-native-chat-input-lease.ts | 25 +- .../use-mobile-native-chat-queued-slot.ts | 17 +- .../use-mobile-native-chat-turn-disclosure.ts | 12 +- ...agent-session-newer-approval-send.test.tsx | 40 ++ ...tured-agent-session-prompt-cancel.test.tsx | 1 + ...tured-agent-session-queued.test-fixture.ts | 1 + .../use-mobile-structured-agent-session.ts | 40 +- ...use-mobile-structured-send-with-outcome.ts | 13 +- ...mobile-structured-session-host-stopping.ts | 28 + .../use-mobile-structured-stop-press.test.ts | 59 ++ .../use-mobile-structured-stop-press.ts | 32 + ...-mobile-structured-turn-indicator.test.tsx | 202 +++++- .../mobile-method-unavailable.test.ts | 17 + .../transport/mobile-method-unavailable.ts | 13 + .../transport/rpc-subscription-inventory.ts | 8 + mobile/src/worktree/agent-row-display.test.ts | 9 + mobile/src/worktree/agent-row-display.ts | 14 +- .../worktree/worktree-list-snapshot.test.ts | 17 + mobile/src/worktree/worktree-list-snapshot.ts | 3 +- .../first-work-branch-rename.test.ts | 6 +- .../server/server-ingest-structured.ts | 3 +- .../claude-background-wake-retention.test.ts | 4 +- src/main/codex/codex-structured-rewind.ts | 1 + .../codex/codex-structured-session-acquire.ts | 1 + .../codex/codex-structured-session-state.ts | 3 + .../codex-structured-turn-cancellation.ts | 3 + src/main/codex/codex-structured-turn-start.ts | 10 +- .../agent-session-journal/journal-reducer.ts | 5 +- .../journal-stop-marks.ts | 36 +- .../journal-stop-note-projection.ts | 22 + .../journal-stop-turn-end.ts | 2 +- .../queued-message-pause.ts | 13 +- .../agent-session-journal-batch.ts | 39 +- ...agent-session-stop-note-projection.test.ts | 389 ++++++++++++ .../agent-session-stop-note-refresh.ts | 77 +++ ...nt-session-background-task-channel.test.ts | 2 +- .../structured-agent-session-chat-stop.ts | 16 +- ...agent-session-claude-stop-note-end.test.ts | 350 +++++++++++ ...tructured-agent-session-client-delivery.ts | 9 +- ...-agent-session-codex-stop-note-end.test.ts | 254 ++++++++ .../structured-agent-session-conversations.ts | 18 + ...gent-session-dead-generation-settlement.ts | 3 +- .../structured-agent-session-delivery-loop.ts | 8 + .../structured-agent-session-host-delivery.ts | 13 +- .../structured-agent-session-host.ts | 1 + ...agent-session-recovered-turn-clock.test.ts | 2 +- ...t-session-rewind-stop-note.test-fixture.ts | 110 ++++ .../structured-agent-session-rewind.test.ts | 5 + .../structured-agent-session-rewind.ts | 20 +- ...agent-session-status-feed-stopping.test.ts | 151 +++++ .../structured-agent-session-status-feed.ts | 26 +- ...agent-session-status-journal-projection.ts | 13 +- ...d-agent-session-status-summary-equality.ts | 1 + ...agent-session-steer-while-stopping.test.ts | 220 +++++++ ...structured-agent-session-stop-wind-down.ts | 107 ++++ ...red-agent-session-stopping-journal.test.ts | 151 +++++ .../structured-agent-session-stopping.test.ts | 594 ++++++++++++++++++ .../structured-agent-session-stopping.ts | 90 +++ .../structured-agent-session-subscribers.ts | 10 + .../structured-agent-session-turns-cancel.ts | 72 +-- ...structured-conversation-compaction.test.ts | 20 +- .../structured-rewind-recovery.ts | 5 +- src/main/runtime/mobile-rpc-allowlist.test.ts | 14 +- ...ructured-agent-session-rpc.test-fixture.ts | 3 +- ...untime-rpc-mobile-agent-session-methods.ts | 30 + .../runtime-rpc-mobile-method-allowlist.ts | 28 +- ...ime-worktree-agent-rows-structured.test.ts | 10 + ...gent-session-codex-interrupt-order.test.ts | 80 ++- .../dashboard/DashboardAgentRow.test.tsx | 17 + .../dashboard/DashboardAgentRow.tsx | 5 +- .../dashboard/DashboardAgentRowToolStep.tsx | 15 +- .../native-chat/NativeChatComposer.tsx | 4 + .../NativeChatComposerActions.test.tsx | 23 + .../native-chat/NativeChatComposerActions.tsx | 9 +- .../native-chat/NativeChatComposerField.tsx | 15 +- ...ChatMessageList.stopping-turn-bar.test.tsx | 126 ++++ .../native-chat/NativeChatMessageList.tsx | 8 +- .../NativeChatQueuedMessageCard.tsx | 5 +- .../NativeChatQueuedMessageList.test.tsx | 15 + .../NativeChatQueuedMessageList.tsx | 4 + ...iveChatStructuredSession.stopping.test.tsx | 154 +++++ ...tiveChatStructuredSession.test-harness.tsx | 10 + .../NativeChatStructuredSession.tsx | 33 +- .../NativeChatTurnActivityLine.test.tsx | 26 + .../NativeChatTurnActivityLine.tsx | 27 +- ...tructuredAgentSessionStatusBridge.test.tsx | 26 +- .../StructuredAgentSessionStatusBridge.tsx | 28 +- .../native-chat-composer-placeholder.test.ts | 29 + .../native-chat-composer-target.ts | 23 +- .../native-chat/native-chat-composer-types.ts | 6 + .../native-chat-interactive-prompt.ts | 16 + .../native-chat-structured-stop-controls.ts | 44 ++ .../native-chat-transcript-slots.ts | 6 +- ...uctured-agent-session-outbox-retry.test.ts | 80 +++ .../structured-agent-session-outbox-retry.ts | 4 +- ...structured-agent-session-outbox-storage.ts | 6 +- ...nt-session-status-bridge-stopping.test.tsx | 118 ++++ .../use-native-chat-transcript-slots.ts | 17 +- .../use-structured-agent-session-outbox.ts | 15 +- ...ructured-agent-session-stop-press.test.tsx | 66 ++ ...use-structured-agent-session-stop-press.ts | 32 + .../use-structured-agent-session-stop.ts | 54 ++ ...gent-session.queue-while-stopping.test.tsx | 119 ++++ ...tured-agent-session.queued-gating.test.tsx | 68 +- .../use-structured-agent-session.ts | 43 +- .../worktree-card-compact-agent-line-order.ts | 19 + ...ee-card-compact-agent-row-stopping.test.ts | 71 +++ .../worktree-card-compact-agent-row.tsx | 16 +- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + .../src/lib/agent-row-stopping-label.ts | 19 + ...gent-session-status-feed-lifecycle.test.ts | 21 + .../structured-agent-session-status-feed.ts | 44 +- src/shared/agent-lead-status-fold.ts | 2 + .../agent-session-status-snapshot-fold.ts | 51 ++ src/shared/agent-session-wire.ts | 5 + src/shared/agent-status-types.ts | 1 + src/shared/agent-stop-display-status.test.ts | 16 + src/shared/agent-stop-display-status.ts | 18 + src/shared/main-agent-status.ts | 10 +- src/shared/native-chat-live-line.test.ts | 19 +- src/shared/native-chat-live-line.ts | 12 +- src/shared/native-chat-turn-membership.ts | 18 +- src/shared/native-chat-turn-status.test.ts | 11 + src/shared/native-chat-turn-status.ts | 29 +- src/shared/native-chat-types.ts | 2 + ...uctured-agent-session-agent-status.test.ts | 11 + .../structured-agent-session-agent-status.ts | 5 +- ...ctured-agent-session-message-projection.ts | 4 +- ...tructured-agent-session-outbox-delivery.ts | 16 +- src/shared/structured-agent-session-outbox.ts | 2 + ...-session-stop-event-downgrade.unit.test.ts | 71 +++ ...ss-version-agent-session-wire.unit.test.ts | 3 + .../cross-version-stop-note-scenarios.ts | 194 ++++++ .../cross-version-stop-note-test-rig.ts | 213 +++++++ .../cross-version-stopping.unit.test.ts | 207 ++++++ .../versioned-agent-session-wire.ts | 42 ++ 162 files changed, 6322 insertions(+), 397 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatStopButton.tsx create mode 100644 mobile/src/session/mobile-structured-session-status-feed.test.ts create mode 100644 mobile/src/session/mobile-structured-session-status-feed.ts create mode 100644 mobile/src/session/use-mobile-structured-session-host-stopping.ts create mode 100644 mobile/src/session/use-mobile-structured-stop-press.test.ts create mode 100644 mobile/src/session/use-mobile-structured-stop-press.ts create mode 100644 mobile/src/transport/mobile-method-unavailable.test.ts create mode 100644 mobile/src/transport/mobile-method-unavailable.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts create mode 100644 src/renderer/src/lib/agent-row-stopping-label.ts create mode 100644 src/shared/agent-session-status-snapshot-fold.ts create mode 100644 src/shared/agent-stop-display-status.test.ts create mode 100644 src/shared/agent-stop-display-status.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 864e8bfec73..28edfb94f71 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -28,7 +28,8 @@ import { resetDirectoryLoadRevisions, type DirectoryLoadRevisions } from './directory-load-revisions' -import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' +import { directoryCacheFromFileList } from './file-list-fallback' import { fileDirectoryRead, legacyFileListRead } from './mobile-file-explorer-operations' import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' import { MobileFileExplorerRow } from './mobile-file-explorer-row' diff --git a/mobile/src/files/file-explorer-reply-schema.ts b/mobile/src/files/file-explorer-reply-schema.ts index 696bdde55a1..1379eb764fe 100644 --- a/mobile/src/files/file-explorer-reply-schema.ts +++ b/mobile/src/files/file-explorer-reply-schema.ts @@ -31,7 +31,7 @@ export const fileDirectoryEntriesSchema = salvagingArray( * The capped flat list an older desktop answers when `files.readDir` is not allowlisted. * * `files` and `truncated` are both required and both read unguarded: directoryCacheFromFileList - * walks `files` and splits each `relativePath` (file-list-fallback.ts:48), and + * walks `files` and splits each `relativePath` (file-list-fallback.ts:34), and * MobileFileExplorerPanel.tsx:136 publishes `truncated` into the state that draws the "Showing * first 5000" note. A row without a string `relativePath` drops — it can name no directory — where * main crashed the whole fallback on it. diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts index a69affc5fe8..4f26d733081 100644 --- a/mobile/src/files/file-list-fallback.test.ts +++ b/mobile/src/files/file-list-fallback.test.ts @@ -1,22 +1,7 @@ import { describe, expect, it } from 'vitest' -import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { directoryCacheFromFileList } from './file-list-fallback' import { getDirectoryCacheState } from './file-tree' -describe('isMobileMethodUnavailableError', () => { - it('detects old-desktop allowlist and missing-method failures', () => { - expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) - expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) - expect( - isMobileMethodUnavailableError( - 'some_code', - "Method 'files.readDir' is not available to mobile clients" - ) - ).toBe(true) - expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) - expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) - }) -}) - describe('directoryCacheFromFileList', () => { it('synthesizes every ancestor directory from flat paths', () => { const cache = directoryCacheFromFileList([ diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts index e0db7236bad..6c9366d8cfb 100644 --- a/mobile/src/files/file-list-fallback.ts +++ b/mobile/src/files/file-list-fallback.ts @@ -15,20 +15,6 @@ export type LegacyFilesListResult = { truncated: boolean } -// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: -// 'forbidden' = method exists but is not mobile-allowlisted on the old -// desktop; 'method_not_found' = desktop predates the method entirely. -export function isMobileMethodUnavailableError( - code: string | undefined, - message: string | undefined -): boolean { - return ( - code === 'forbidden' || - code === 'method_not_found' || - message?.includes('not available to mobile clients') === true - ) -} - // Takes only the member it reads: the explorer's reply reader checks `relativePath` and passes the // rest of each row through, so naming the whole row here would re-declare what it deliberately did not. export function directoryCacheFromFileList( diff --git a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts index daecbe6ab62..1750c34ed58 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts @@ -56,7 +56,13 @@ describe('MobileNativeChatLiveLine on Android', () => { act(() => { renderer = create( createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning: block, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning: block, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) @@ -78,7 +84,13 @@ describe('MobileNativeChatLiveLine on Android', () => { it('keeps the sheet through the block ending, and never opens one for the next by itself', () => { const line = (reasoning: NativeChatLiveReasoning | null) => createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) @@ -103,7 +115,13 @@ describe('MobileNativeChatLiveLine on Android', () => { it('opens no sheet for the next block when the first ends with the sheet open', () => { const line = (reasoning: NativeChatLiveReasoning | null) => createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) diff --git a/mobile/src/session/MobileNativeChatLiveLine.test.ts b/mobile/src/session/MobileNativeChatLiveLine.test.ts index 37bcc460ca5..a14f89371b4 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.test.ts @@ -61,6 +61,7 @@ describe('MobileNativeChatLiveLine', () => { function element( fields: { thinking?: boolean + stopping?: boolean activityText?: string | null reasoning?: NativeChatLiveReasoning | null reasoningExpanded?: boolean @@ -69,6 +70,7 @@ describe('MobileNativeChatLiveLine', () => { return createElement(MobileNativeChatLiveLine, { line: { thinking: true, + stopping: false, activityText: null, reasoning: null, reasoningExpanded: false, @@ -110,6 +112,11 @@ describe('MobileNativeChatLiveLine', () => { expect(labels(render({ activityText: 'Running pnpm test' }))).toEqual(['Running pnpm test']) }) + it("reads Stopping over provider activity once a person's Stop is ending the turn", () => { + const root = render({ thinking: false, activityText: 'Running pnpm test', stopping: true }) + expect(labels(root)).toEqual(['Stopping…']) + }) + it('announces what it says to assistive tech, and is no button while it discloses nothing', () => { const line = header(render()) expect(line.props.accessibilityLiveRegion).toBe('polite') diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx index 30ead56325c..d0390eea7a8 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.tsx +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -10,8 +10,9 @@ import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageAc import { MobileNativeChatReasoningBody } from './MobileNativeChatReasoningRow' import type { MobileNativeChatLiveLine as LiveLine } from './use-mobile-native-chat-turn-disclosure' -/** The live turn's tail line: a spinner beside what the provider says it is doing, else - * "Thinking", else "Working…". The clock stays in the turn bar. While the agent's open reasoning +/** The live turn's tail line: a spinner beside "Stopping…" once the person's Stop is ending the + * turn, else what the provider says it is doing, else "Thinking", else "Working…". The clock + * stays in the turn bar. While the agent's open reasoning * block has text it is also that block's disclosure, and the block's row draws nothing. * Desktop parity: `NativeChatTurnActivityLine`. */ export function MobileNativeChatLiveLine({ diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 0ade370d13b..23edda19a50 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -25,6 +25,8 @@ type Tick = { streamingText?: string streamLive?: boolean identity?: string + /** The host says a person's Stop is ending the turn. */ + stopping?: boolean } function overlayElement(tick: Tick): ReturnType { @@ -34,6 +36,11 @@ function overlayElement(tick: Tick): ReturnType { nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, nativeChatAgent: 'claude', nativeChatAgentWorking: tick.streamLive ?? false, + nativeChatTurnIndicator: { + thinking: false, + activityText: null, + stopping: tick.stopping ?? false + }, nativeChatStreamingText: tick.streamingText, nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', @@ -185,3 +192,28 @@ describe('MobileNativeChatOverlay streaming gate', () => { expect(streaming()).toBeNull() }) }) + +describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function steerHeld(tick: Tick): Promise { + await act(async () => { + renderer = create(overlayElement(tick)) + }) + const view = renderer!.root.find((node) => node.type === 'ChatView') + return view.props.queuedSlot?.cards?.props?.steerHeld + } + + it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => { + expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true) + }) + + it('leaves Steer to the cards otherwise', async () => { + expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false) + }) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 3d08c8d66bd..7d392e810da 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -68,7 +68,10 @@ export function MobileNativeChatOverlay({ onEdit: queued.edit, pause: queued.pause, onResume: queued.resume, - sessionKey: queued.sessionKey + sessionKey: queued.sessionKey, + // Nothing steers into a turn a Stop is ending; the host holds such a send until it ends. + // The indicator's `stopping` is the display status, decided once in the session hook. + steerHeld: controller.nativeChatTurnIndicator?.stopping === true }) if (!controller.showNativeChat) { return null diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a5..a8c740bdfd4 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -156,6 +156,25 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) + it("holds Steer while a person's Stop ends the turn; Delete still works", async () => { + const onSend = vi.fn(async () => true) + const onDelete = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'w' })], + onSend, + onDelete, + steerHeld: true + }) + const steer = mounted.root.findByProps({ + accessibilityLabel: 'Submit without interrupting the model' + }) + expect(steer.props.disabled).toBe(true) + expect(steer.props.accessibilityState).toEqual({ disabled: true }) + const trash = mounted.root.findByProps({ accessibilityLabel: 'Delete this queued message' }) + await act(async () => trash.props.onPress()) + expect(onDelete).toHaveBeenCalledWith('w') + }) + it('deletes a card from its trash button', async () => { const onDelete = vi.fn(async () => true) const mounted = await mount({ cards: [card({ messageId: 'w' })], onDelete }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 4b53ee81556..cda3264eb95 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -34,6 +34,8 @@ export type MobileNativeChatQueuedMessagesProps = { /** The whole queue's pause: the box's first row, with Resume. */ pause?: MobileQueuePause onResume?: () => Promise + /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ + steerHeld?: boolean } /** The host-held queued drafts, as one box of compact rows between transcript and @@ -44,7 +46,8 @@ export function MobileNativeChatQueuedMessages({ onDelete, onEdit, pause, - onResume + onResume, + steerHeld = false }: MobileNativeChatQueuedMessagesProps): React.JSX.Element | null { // One in-flight action per card; a second tap must not double-consume. The ref // closes the same-frame double tap the disabled state cannot. @@ -136,7 +139,7 @@ export function MobileNativeChatQueuedMessages({ [ styles.textAction, pressed && styles.pressed, - busy && styles.disabled + (busy || steerHeld) && styles.disabled ]} - disabled={busy} + disabled={busy || steerHeld} onPress={() => void run(card.messageId, onSend)} > {steers ? ( diff --git a/mobile/src/session/MobileNativeChatStopButton.tsx b/mobile/src/session/MobileNativeChatStopButton.tsx new file mode 100644 index 00000000000..94034e1d47d --- /dev/null +++ b/mobile/src/session/MobileNativeChatStopButton.tsx @@ -0,0 +1,28 @@ +import { Pressable, Text } from 'react-native' +import { Square } from 'lucide-react-native' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' + +/** The chat header's Stop. `held`: this phone's own Stop request is still in flight. */ +export function MobileNativeChatStopButton({ + onStop, + held +}: { + onStop?: () => void + held: boolean +}): React.JSX.Element { + const label = held ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop' + return ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + disabled={held} + hitSlop={8} + accessibilityLabel={held ? label : 'Stop the agent'} + > + + {label} + + ) +} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index cf9be553e79..5dc4e59a7ab 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -151,6 +151,55 @@ describe('MobileNativeChatView', () => { }) } + it("holds Stop and says Stopping while this phone's own Stop request is in flight", async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { + thinking: false, + activityText: null, + stopping: true, + stopRequestInFlight: true + } + }) + const stop = renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stopping…' + ) + expect(stop.props.disabled).toBe(true) + }) + + it.each([ + ['queue', 'Queue a message to run after the stop'], + ['send', 'Send a message to run after the stop'] + ] as const)( + 'tells the composer a message sent now runs after the stop (%s)', + async (afterStop, placeholder) => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true, afterStop } + }) + const composer = renderer!.root.find((node) => node.type === 'Composer') + expect(composer.props.placeholder).toBe(placeholder) + } + ) + + // A Stop the provider took and never answered ends only at a repeat Stop. + it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true } + }) + const stop = renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stop the agent' + ) + expect(stop.props.disabled).toBe(false) + }) + /** Ids of the rows the list is currently rendering. */ it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => { const props = { structuredActivityUi: true, agentWorking: true, canStop: false } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a9b4a2f2191..020b3dcb796 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -10,7 +10,7 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' -import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import { ArrowDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { @@ -34,6 +34,7 @@ import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' +import { MobileNativeChatStopButton } from './MobileNativeChatStopButton' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -280,6 +281,8 @@ export function MobileNativeChatView({ // Per-turn status rows: one live indicator while the turn runs, then a settled // "Worked for N" row. The structured lane owns them; the bridge lane keeps its // three-dot indicator. + // The display status, decided once in the session hook. + const stopping = turnIndicator?.stopping === true const turns = useMobileNativeChatTurnDisclosure({ messages: data, enabled: structuredActivityUi, @@ -289,6 +292,7 @@ export function MobileNativeChatView({ turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, + stopping, lineYields: structuredActivityUi && (ask != null || permission != null || question != null), scopeKey: sendSurfaceId }) @@ -423,15 +427,11 @@ export function MobileNativeChatView({ {canStop ? ( - [styles.stopButton, pressed && styles.pressed]} - onPress={onStop} - hitSlop={8} - accessibilityLabel="Stop the agent" - > - - Stop - + // Only this phone's own request holds Stop: a repeat is how a stuck stop escalates. + ) : null} {sendErrorMessage ? ( @@ -466,7 +466,7 @@ export function MobileNativeChatView({ onMicPressOut={onMicPressOut} disabled={lockReason !== null} sendDisabled={expandedPromptOwnsSend} - placeholder={mobileNativeChatComposerPlaceholder(lockReason)} + placeholder={mobileNativeChatComposerPlaceholder(lockReason, turnIndicator?.afterStop)} filePaths={filePaths} onNeedFiles={onNeedFiles} /> diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 47137a7deaa..a16968fb327 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -182,7 +182,9 @@ describe('MobileNativeChatView', () => { function footerProps(): Record | null { const list = renderer!.root.find((node) => String(node.type) === 'FlatList') const line = list.props.ListFooterComponent?.props.line - return line ? { thinking: line.thinking, activityText: line.activityText } : null + return line + ? { thinking: line.thinking, activityText: line.activityText, stopping: line.stopping } + : null } function workingIndicators(): ReactTestInstance[] { @@ -196,7 +198,7 @@ describe('MobileNativeChatView', () => { expect(props.structuredActivityUi).toBe(true) expect(props.turnStatus).toMatchObject({ workedSeconds: null }) // Nothing reports reasoning, so the tail line reads plain working instead of guessing. - expect(footerProps()).toEqual({ thinking: false, activityText: null }) + expect(footerProps()).toEqual({ thinking: false, activityText: null, stopping: false }) expect(listIds().at(-1)).toBe('a1') expect(props.activeTurnIsWorking).toBe(true) expect(workingIndicators()).toHaveLength(0) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index 0902f318ae3..b6e91fa8a7b 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -3,49 +3,35 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' describe('structuredAgentSessionHostSupport', () => { it('reads each structured-session feature from the host capability list', () => { - expect(structuredAgentSessionHostSupport([])).toEqual({ + const none = { promptCancel: false, questionAnswers: false, queuedMessages: false, - quietRepeatedStop: false - }) + quietRepeatedStop: false, + statusFeed: false + } + expect(structuredAgentSessionHostSupport([])).toEqual(none) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: false, - questionAnswers: true, - queuedMessages: false, - quietRepeatedStop: false - }) + ).toEqual({ ...none, questionAnswers: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: true, - questionAnswers: false, - queuedMessages: false, - quietRepeatedStop: false - }) + ).toEqual({ ...none, promptCancel: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: false, - questionAnswers: false, - queuedMessages: true, - quietRepeatedStop: false - }) + ).toEqual({ ...none, queuedMessages: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY]) - ).toEqual({ - promptCancel: false, - questionAnswers: false, - queuedMessages: false, - quietRepeatedStop: true - }) + ).toEqual({ ...none, quietRepeatedStop: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, statusFeed: true }) }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 9ae1d1e90b1..cb5dcc5c8ec 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -11,6 +12,9 @@ export type StructuredAgentSessionHostSupport = { questionAnswers: boolean /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ queuedMessages: boolean + /** The host publishes every session's status on one stream. A host from before phones could + * read it refuses the call, which reads the same as its absence. */ + statusFeed: boolean /** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */ quietRepeatedStop: boolean } @@ -22,6 +26,7 @@ export function structuredAgentSessionHostSupport( promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), + statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/mobile-structured-session-status-feed.test.ts b/mobile/src/session/mobile-structured-session-status-feed.test.ts new file mode 100644 index 00000000000..3b8deaf5dfd --- /dev/null +++ b/mobile/src/session/mobile-structured-session-status-feed.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const REFUSAL = "Method 'agentSession.subscribeStatus' is not available to mobile clients" +const FORBIDDEN = { + type: 'error', + message: REFUSAL, + error: { code: 'forbidden', message: REFUSAL } +} + +function summary(stopping: boolean): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: 'workspace-1', + agent: 'codex', + status: 'working', + latestPrompt: 'ship it', + updatedAt: 1, + hostExecutionOwned: true, + ...(stopping ? { stopping: true } : {}) + } +} + +describe("the phone's status stream", () => { + let frames: ((value: unknown) => void)[] + let stateListeners: ((state: ConnectionState) => void)[] + let client: RpcClient + + beforeEach(() => { + frames = [] + stateListeners = [] + // A fresh client per test: the stream is one per client for its life. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reaches the client only through subscribe and onStateChange. + client = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + frames.push(onData) + return () => {} + }), + onStateChange: (listener: (state: ConnectionState) => void) => { + stateListeners.push(listener) + return () => {} + } + } as unknown as RpcClient + }) + + function read(): AgentSessionStatusSummary | undefined { + return mobileStructuredSessionStatusFeed(client).getSnapshot().get('session-1') + } + + function connection(state: ConnectionState): void { + for (const listener of stateListeners) { + listener(state) + } + } + + it('opens one stream per client however many chats read it', () => { + const feed = mobileStructuredSessionStatusFeed(client) + feed.subscribe(() => {}) + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + + expect(client.subscribe).toHaveBeenCalledOnce() + expect(client.subscribe).toHaveBeenCalledWith( + 'agentSession.subscribeStatus', + {}, + expect.any(Function) + ) + }) + + it("follows the host's Stopping from the snapshot through each status frame", () => { + const listener = vi.fn() + mobileStructuredSessionStatusFeed(client).subscribe(listener) + + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + expect(read()?.stopping).toBe(true) + + frames[0]?.({ type: 'status', session: summary(false) }) + expect(read()).toMatchObject({ status: 'working' }) + expect(read()).not.toHaveProperty('stopping') + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('drops Stopping when the host ends the stream, and opens it again on the next connection', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + + frames[0]?.({ type: 'end' }) + + expect(read()).not.toHaveProperty('stopping') + connection('connected') + expect(client.subscribe).toHaveBeenCalledTimes(2) + }) + + it('drops Stopping, keeping the rest, once the phone loses contact', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + + connection('reconnecting') + + expect(read()).toMatchObject({ sessionId: 'session-1', status: 'working' }) + expect(read()).not.toHaveProperty('stopping') + expect(read()).not.toHaveProperty('hostExecutionOwned') + }) + + it('reads a host that refuses the method to phones as one without it, asking once per connection', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + + // As the host's mobile gate answers it, through the client's failed-opener frame. + frames[0]?.(FORBIDDEN) + connection('connected') + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + expect(client.subscribe).toHaveBeenCalledOnce() + expect(read()).toBeUndefined() + + // A new connection may reach a host updated meanwhile. + connection('reconnecting') + connection('connected') + expect(client.subscribe).toHaveBeenCalledTimes(2) + }) +}) + +/** A physical session as the logical client sees it; a finished stream is dropped before its + * listener hears the last frame, as the client's stream registry does. */ +class PhysicalSession implements RpcClient { + readonly sendRequest = vi.fn() + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + readonly streams = new Set<(value: unknown) => void>() + /** Set: the opener fails inside `subscribe`, as a send that cannot reach the socket does. */ + failOnOpen: unknown = null + readonly subscribe = vi.fn((_method, _params, listener) => { + if (this.failOnOpen !== null) { + listener(this.failOnOpen) + return () => {} + } + this.streams.add(listener) + return () => this.streams.delete(listener) + }) + getState = (): ConnectionState => 'connected' + getReconnectAttempt = (): number => 0 + getLastConnectedAt = (): number | null => null + onStateChange = (): (() => void) => () => {} + + finish(frame: unknown): void { + const finished = [...this.streams] + this.streams.clear() + for (const listener of finished) { + listener(frame) + } + } + + statusSubscribes(): number { + return this.subscribe.mock.calls.filter(([method]) => method === 'agentSession.subscribeStatus') + .length + } +} + +describe("the phone's status stream across the logical client's sessions", () => { + it('never replays a refused stream when the client moves to another session', async () => { + const first = new PhysicalSession() + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + first.finish(FORBIDDEN) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(first.statusSubscribes()).toBe(1) + expect(next.statusSubscribes()).toBe(0) + }) + + it('holds exactly one host stream after an ended stream and a move to another session', async () => { + const first = new PhysicalSession() + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + first.finish({ type: 'end' }) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(next.statusSubscribes()).toBe(1) + expect(next.streams.size).toBe(1) + }) + + it('releases a stream that failed while it was being opened', async () => { + const first = new PhysicalSession() + first.failOnOpen = { type: 'error', message: 'Connection interrupted' } + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(next.statusSubscribes()).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts new file mode 100644 index 00000000000..12db68c7177 --- /dev/null +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -0,0 +1,139 @@ +// The connected host's status stream, which every structured chat on that host reads the host's +// "Stopping…" from. One per client, opened by the first chat that reads it and kept for the +// client's life: a phone has no way to end just this stream, so one per chat would leave a host +// subscriber behind each. The client replays it after a reconnect, and the host's fresh snapshot +// then replaces what contact loss revoked. + +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive, + type AgentSessionStatusSnapshot +} from '../../../src/shared/agent-session-status-snapshot-fold' +import type { AgentSessionStatusEvent } from '../../../src/shared/agent-session-wire' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' +import type { RpcClient } from '../transport/rpc-client' + +export type MobileStructuredSessionStatusFeed = { + subscribe: (listener: () => void) => () => void + getSnapshot: () => AgentSessionStatusSnapshot +} + +const feeds = new WeakMap() + +function isStatusEvent(value: unknown): value is AgentSessionStatusEvent { + if (typeof value !== 'object' || value === null || !('type' in value)) { + return false + } + switch (value.type) { + case 'snapshot': + return 'sessions' in value && Array.isArray(value.sessions) + case 'status': + return 'session' in value && typeof value.session === 'object' && value.session !== null + case 'end': + return true + default: + return false + } +} + +/** The stream's refusal, as the client hands a failed opener to its listener. */ +function streamRefusal(value: unknown): { code?: string; message?: string } | null { + if (typeof value !== 'object' || value === null || !('type' in value) || value.type !== 'error') { + return null + } + const error = 'error' in value && typeof value.error === 'object' ? value.error : null + const code = error && 'code' in error && typeof error.code === 'string' ? error.code : undefined + const message = + 'message' in value && typeof value.message === 'string' ? value.message : undefined + return { ...(code !== undefined ? { code } : {}), ...(message !== undefined ? { message } : {}) } +} + +function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { + let snapshot: AgentSessionStatusSnapshot = new Map() + // `unavailable`: the host refused the method to phones; asked again only on a new connection. + let stream: 'closed' | 'open' | 'unavailable' = 'closed' + // Released once the host ends the stream, so the client never replays it on a later session. + let releaseStream = (): void => {} + const release = (): void => { + const pending = releaseStream + releaseStream = () => {} + pending() + } + const listeners = new Set<() => void>() + const setSnapshot = (next: AgentSessionStatusSnapshot): void => { + if (next === snapshot) { + return + } + snapshot = next + for (const listener of listeners) { + listener() + } + } + const revokeLive = (): void => setSnapshot(revokeAgentSessionStatusLive(snapshot)) + const onFrame = (raw: unknown): void => { + if (isStatusEvent(raw)) { + if (raw.type === 'end') { + release() + stream = 'closed' + revokeLive() + return + } + setSnapshot(foldAgentSessionStatusEvent(snapshot, raw)) + return + } + const refusal = streamRefusal(raw) + if (refusal) { + release() + // A host with the feed but from before phones could read it answers `forbidden`. + stream = isMobileMethodUnavailableError(refusal.code, refusal.message) + ? 'unavailable' + : 'closed' + revokeLive() + } + } + const open = (): void => { + if (stream === 'closed' && listeners.size > 0) { + stream = 'open' + const dispose = client.subscribe('agentSession.subscribeStatus', {}, onFrame) + // A stream that ended inside `subscribe` is released at once. + if (stream === 'open') { + releaseStream = dispose + } else { + dispose() + } + } + } + // Losing contact is never exit: only what a live host vouches for goes until it answers again. + client.onStateChange((state) => { + if (state === 'connected') { + open() + return + } + // The next connection may reach an updated host, so a refusal holds for one connection only. + if (stream === 'unavailable') { + stream = 'closed' + } + revokeLive() + }) + return { + subscribe: (listener) => { + listeners.add(listener) + open() + return () => { + listeners.delete(listener) + } + }, + getSnapshot: () => snapshot + } +} + +export function mobileStructuredSessionStatusFeed( + client: RpcClient +): MobileStructuredSessionStatusFeed { + let feed = feeds.get(client) + if (!feed) { + feed = createFeed(client) + feeds.set(client, feed) + } + return feed +} diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts index 792e2b66d38..847fbe3d517 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import { + mobileNativeChatComposerPlaceholder, useMobileNativeChatInputLease, useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' @@ -111,3 +112,24 @@ describe('useSettledMobileNativeChatInputLock', () => { expect(settled).toBeNull() }) }) + +describe("the phone chat composer's placeholder", () => { + it('says a message runs after the stop while the chat reads Stopping', () => { + expect(mobileNativeChatComposerPlaceholder(null, 'queue')).toBe( + 'Queue a message to run after the stop' + ) + // Where the host does not queue sends, it holds the message until the stop lands. + expect(mobileNativeChatComposerPlaceholder(null, 'send')).toBe( + 'Send a message to run after the stop' + ) + }) + + it('reads as usual otherwise', () => { + expect(mobileNativeChatComposerPlaceholder(null, undefined)).toBe('Message, @files, /commands') + }) + + it('says why the composer is locked first', () => { + expect(mobileNativeChatComposerPlaceholder('disconnected', 'queue')).toBe('Reconnecting…') + expect(mobileNativeChatComposerPlaceholder('waiting', 'queue')).toBe('Waiting for terminal…') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index 242910e8b43..02cb57849d4 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' export function useMobileNativeChatInputLease(args: { @@ -93,13 +94,23 @@ export function useSettledMobileNativeChatInputLock( return lockHeld ? (rawLockReason ?? 'waiting') : null } -/** What the composer says while the input lease blocks it, or its normal prompt. */ +/** What the composer says while the input lease blocks it, else that a message sent now runs + * after a Stop the chat reads as stopping, else its normal prompt. */ export function mobileNativeChatComposerPlaceholder( - lockReason: MobileNativeChatInputLockReason | null + lockReason: MobileNativeChatInputLockReason | null, + /** While stopping: whether a message sent now is queued, or sent for the host to hold. */ + afterStop?: 'queue' | 'send' ): string { - return lockReason === 'disconnected' - ? 'Reconnecting…' - : lockReason === 'waiting' - ? 'Waiting for terminal…' - : 'Message, @files, /commands' + if (lockReason === 'disconnected') { + return 'Reconnecting…' + } + if (lockReason === 'waiting') { + return 'Waiting for terminal…' + } + if (afterStop) { + return afterStop === 'queue' + ? NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop + : NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop + } + return 'Message, @files, /commands' } diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts index aac805a5c20..41b3eb48c8c 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.ts +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.ts @@ -31,7 +31,7 @@ export function useMobileNativeChatQueuedSlot( const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus( queuedMessages.onEdit ) - const { cards, onSend, onDelete, pause, onResume, sessionKey } = queuedMessages + const { cards, onSend, onDelete, pause, onResume, sessionKey, steerHeld } = queuedMessages return useMemo( () => ({ cards: createElement(MobileNativeChatQueuedMessages, { @@ -41,10 +41,21 @@ export function useMobileNativeChatQueuedSlot( onDelete, onEdit: editQueuedMessage, pause, - onResume + onResume, + steerHeld }), composerInputRef }), - [cards, composerInputRef, editQueuedMessage, onDelete, onResume, onSend, pause, sessionKey] + [ + cards, + composerInputRef, + editQueuedMessage, + onDelete, + onResume, + onSend, + pause, + sessionKey, + steerHeld + ] ) } diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 4c7262efc70..81c76d1401c 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -82,6 +82,7 @@ export function useMobileNativeChatTurnDisclosure({ turnJournal = null, thinking = false, activityText = null, + stopping = false, lineYields = false, scopeKey }: { @@ -97,6 +98,8 @@ export function useMobileNativeChatTurnDisclosure({ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ activityText?: string | null + /** A person's Stop is ending the live turn: the sends the host holds draw after its status. */ + stopping?: boolean /** A prompt the reader must answer replaces the live activity line. */ lineYields?: boolean /** Host/worktree/tab identity for timing and disclosure isolation. */ @@ -131,7 +134,9 @@ export function useMobileNativeChatTurnDisclosure({ }, [enabled, messages, turnJournal]) // A message waiting behind the live turn draws after that turn's live status, not in the list. const waiting = useMemo(() => { - const ids = enabled ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items) : null + const ids = enabled + ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items, stopping) + : null if (!ids?.size) { return { listMessages: rows, waitingRows: [], indexById: null } } @@ -140,7 +145,7 @@ export function useMobileNativeChatTurnDisclosure({ waitingRows: rows.flatMap((item, index) => (ids.has(item.id) ? [{ item, index }] : [])), indexById: new Map(rows.map((message, index) => [message.id, index])) } - }, [enabled, rows, turnJournal]) + }, [enabled, rows, stopping, turnJournal]) const turnStatuses = useMobileNativeChatTurnStatus({ turnKeys, liveTurnKey, @@ -167,11 +172,12 @@ export function useMobileNativeChatTurnDisclosure({ nativeChatLiveLine({ draws: enabled && isWorking && !lineYields && active !== null, thinking: active?.thinking === true, + stopping, activityText: activeActivityText, messages: rows, inLiveWorkingTurn }), - [active, activeActivityText, enabled, inLiveWorkingTurn, isWorking, lineYields, rows] + [active, activeActivityText, enabled, inLiveWorkingTurn, isWorking, lineYields, rows, stopping] ) const liveReasoningId = line?.reasoning?.message.id const liveLine = useMemo( diff --git a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx index 594a84acf5f..9ed32c49579 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx @@ -127,3 +127,43 @@ it('still queues beside a prompt this build can answer', async () => { ]) ).toBe('queue-if-active') }) + +const RUNNING_TURN: AgentJournalRenderItem = { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } +} + +/** What the composer says a send does while this phone's Stop is still in flight. */ +async function afterStop(items: AgentJournalRenderItem[]): Promise { + // The Stop's request stays in flight, so the phone keeps reading Stopping. + sendRequest.mockImplementation(async (method) => + method === 'agentSession.cancel' + ? new Promise(() => undefined) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + const event = snapshotEvent() + if (event.type === 'snapshot') { + event.page.items = [RUNNING_TURN, ...items] + } + act(() => listener?.(event)) + act(() => hook?.cancel()) + expect(hook?.turnIndicator.stopping).toBe(true) + return hook?.turnIndicator.afterStop +} + +it('says a send after a Stop is sent, not queued, while every pending prompt is one this build cannot answer', async () => { + expect(await afterStop([approval({ kind: 'diff', path: 'a.ts' })])).toBe('send') +}) + +it('says a send after a Stop is queued beside a prompt this build can answer', async () => { + expect(await afterStop([approval({ kind: 'plan', text: 'do it' })])).toBe('queue') +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 9c9ced64fe3..4c419108c7c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -125,6 +125,7 @@ function Harness({ promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported, queuedMessages: false, + statusFeed: false, quietRepeatedStop: false }, onSendError: vi.fn() diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index bbfe297ef7f..163cb20246f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -16,6 +16,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { promptCancel: false, questionAnswers: false, queuedMessages: true, + statusFeed: false, quietRepeatedStop: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 85b0d65298f..6bcc502aaf2 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -23,6 +23,8 @@ import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSession } from './use-mobile-native-chat-session' import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' +import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' +import { useMobileStructuredSessionHostStopping } from './use-mobile-structured-session-host-stopping' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' @@ -33,7 +35,9 @@ import { requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' import { + mobileStructuredSendQueues, useMobileStructuredSendWithOutcome, type StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' @@ -185,7 +189,37 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state) - const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText]) + const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + const hostStopping = useMobileStructuredSessionHostStopping({ + client, + sessionId, + enabled: enabled && connected && hostSupport?.statusFeed === true + }) + // The host's word, bridged by this phone's own press until its Stop event lands. + const stopPress = useMobileStructuredStopPress(sessionKey) + const stopping = + agentStopDisplayStatus({ + working: isWorking, + hostStopping, + stopPressed: stopPress.pressed + }) === 'stopping' + const stopRequestInFlight = isWorking && stopPress.pressed + const turnIndicator = useMemo( + () => ({ + thinking, + activityText, + stopping, + stopRequestInFlight, + ...(stopping + ? { + afterStop: mobileStructuredSendQueues(queueCapable, state.items) + ? ('queue' as const) + : ('send' as const) + } + : {}) + }), + [thinking, activityText, stopping, stopRequestInFlight, queueCapable, state.items] + ) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( () => state.items.find(pendingStructuredApproval) ?? null, @@ -244,13 +278,13 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + isWorking, turnId, turnIndicator, ...turnTiming, sendWithOutcome, cancel: () => { - void requestCancel() + void stopPress.track(() => requestCancel()) }, cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index d08e3365b0c..e1759ac4fca 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -22,6 +22,15 @@ import { pendingStructuredQuestion } from './mobile-structured-agent-prompts' +/** Whether a send made now asks the host to queue it. The host's queue waits on any pending prompt; + * one this build cannot answer would hold the send forever, so it starts a turn instead. */ +export function mobileStructuredSendQueues( + queueCapable: boolean, + items: StructuredAgentSessionState['items'] +): boolean { + return queueCapable && !pendingPromptsAllUnanswerableHere(items) +} + export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & { id?: string contentFingerprint?: string @@ -117,9 +126,7 @@ export function useMobileStructuredSendWithOutcome(args: { expectedRuntimeFence: currentFence, text, attachments: sendAttachments, - // The host's queue waits on any pending prompt; one this build cannot answer would hold - // the send forever, so it starts a turn, whose card cancel then works. - ...(queueCapable && !pendingPromptsAllUnanswerableHere(stateRef.current.items) + ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), deadline, diff --git a/mobile/src/session/use-mobile-structured-session-host-stopping.ts b/mobile/src/session/use-mobile-structured-session-host-stopping.ts new file mode 100644 index 00000000000..75456c4d9cf --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-host-stopping.ts @@ -0,0 +1,28 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +/** + * Whether the host says a person's Stop is ending session `sessionId`'s turn: the same status + * stream and field the desktop chat reads. `enabled` carries the host's status-feed capability + * and a live connection; off it, nothing is opened and nothing reads as stopping. + */ +export function useMobileStructuredSessionHostStopping(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean +}): boolean { + const { client, sessionId, enabled } = args + const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readStopping = (): boolean => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + return summary?.status === 'working' && summary.stopping === true + } + return useSyncExternalStore(subscribe, readStopping, readStopping) +} diff --git a/mobile/src/session/use-mobile-structured-stop-press.test.ts b/mobile/src/session/use-mobile-structured-stop-press.test.ts new file mode 100644 index 00000000000..927522041b1 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-press.test.ts @@ -0,0 +1,59 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' + +let renderer: ReactTestRenderer | null = null +let latest: ReturnType | null = null + +function Probe({ sessionKey }: { sessionKey: string }): null { + latest = useMobileStructuredStopPress(sessionKey) + return null +} + +function mount(sessionKey = 'session-1'): ReturnType { + act(() => { + renderer = create(createElement(Probe, { sessionKey })) + }) + return latest! +} + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + latest = null +}) + +describe("the phone's Stop press read as Stopping", () => { + it('holds while the request is in flight and clears when it answers', async () => { + let answer: (value: boolean) => void = () => undefined + let tracked: Promise = Promise.resolve(false) + const press = mount() + act(() => { + tracked = press.track(() => new Promise((resolve) => (answer = resolve))) + }) + expect(latest?.pressed).toBe(true) + + await act(async () => { + answer(true) + await tracked + }) + expect(latest?.pressed).toBe(false) + }) + + it('clears when the request fails', async () => { + let fail: (error: unknown) => void = () => undefined + let tracked: Promise = Promise.resolve(false) + const press = mount() + act(() => { + tracked = press.track(() => new Promise((_, reject) => (fail = reject))) + }) + + await act(async () => { + fail(new Error('socket closed')) + await tracked.catch(() => undefined) + }) + + expect(latest?.pressed).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-stop-press.ts b/mobile/src/session/use-mobile-structured-stop-press.ts new file mode 100644 index 00000000000..daa4be6c1d9 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-press.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState } from 'react' + +/** + * The person's own Stop press, read as "Stopping…" until its request answers, so the control + * flips on tap. Presentation only: never stored or sent. Desktop parity: + * `useStructuredAgentSessionStopPress`. + */ +export function useMobileStructuredStopPress(sessionKey: string): { + pressed: boolean + track: (stop: () => Promise) => Promise +} { + const [press, setPress] = useState<{ sessionKey: string; token: symbol } | null>(null) + const latest = useRef(null) + const track = useCallback( + async (stop: () => Promise): Promise => { + const token = Symbol('stop-press') + latest.current = token + setPress({ sessionKey, token }) + try { + return await stop() + } finally { + // Answered or failed alike: a failed request keeps its own banner. + if (latest.current === token) { + latest.current = null + setPress(null) + } + } + }, + [sessionKey] + ) + return { pressed: press?.sessionKey === sessionKey, track } +} diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 9a0fb4f3a6c..c2cdf5d5114 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent, @@ -52,7 +52,8 @@ describe('useMobileStructuredAgentSession turn indicator', () => { let renderer: ReactTestRenderer | null = null let hook: ReturnType | null = null let listener: ((value: unknown) => void) | null = null - const sendRequest = vi.fn(async (method: string) => ({ + type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } } + const sendRequest = vi.fn(async (method: string): Promise => ({ ok: true, result: method === 'agentSession.options' @@ -113,7 +114,12 @@ describe('useMobileStructuredAgentSession turn indicator', () => { listener?.(snapshot([runningTurn, reasoning], 3)) }) - expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null }) + expect(hook?.turnIndicator).toEqual({ + thinking: true, + activityText: null, + stopping: false, + stopRequestInFlight: false + }) }) it('hands the row the provider copy once real content ends the reasoning', async () => { @@ -142,7 +148,49 @@ describe('useMobileStructuredAgentSession turn indicator', () => { ) }) - expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' }) + expect(hook?.turnIndicator).toEqual({ + thinking: false, + activityText: 'Updating the plan', + stopping: false, + stopRequestInFlight: false + }) + }) + + it("reads Stopping from this phone's own Stop until its request answers", async () => { + const passthrough = sendRequest.getMockImplementation()! + type Reply = Awaited> + let answer: (value: Reply) => void = () => undefined + // The Stop's request stays in flight until the test answers it; every other call is as usual. + sendRequest.mockImplementation((method: string) => + method === 'agentSession.cancel' + ? new Promise((resolve) => (answer = resolve)) + : passthrough(method) + ) + onTestFinished(() => { + sendRequest.mockImplementation(passthrough) + }) + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + act(() => { + listener?.(snapshot([runningTurn], 3)) + }) + expect(hook?.turnIndicator.stopping).toBe(false) + + act(() => hook?.cancel()) + // This host does not queue sends: a message sent now is held by the host until the stop lands. + expect(hook?.turnIndicator).toMatchObject({ + stopping: true, + stopRequestInFlight: true, + afterStop: 'send' + }) + + await act(async () => { + const cancelled = { ok: true, value: { cancelled: true } } + answer({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) + }) + await vi.waitFor(() => expect(hook?.turnIndicator.stopping).toBe(false)) }) it('never reads a journal status row as the live activity', async () => { @@ -167,6 +215,150 @@ describe('useMobileStructuredAgentSession turn indicator', () => { ) }) - expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: null }) + expect(hook?.turnIndicator).toEqual({ + thinking: false, + activityText: null, + stopping: false, + stopRequestInFlight: false + }) + }) +}) + +/** The phone's chat reads the host's "Stopping…" from the status stream the desktop chat reads. */ +describe("useMobileStructuredAgentSession and the host's Stopping", () => { + type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } } + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let streams: Map void> + let cancelReply: ((reply: RpcReply) => void) | null + let subscribe: ReturnType + let client: RpcClient + const onSendError = vi.fn() + const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + + function hostSays(sessionId: string, stopping: boolean): void { + act(() => + streams.get('agentSession.subscribeStatus')?.({ + type: 'status', + session: { + sessionId, + workspaceId: 'workspace-a', + agent: 'codex', + status: 'working', + updatedAt: 1, + ...(stopping ? { stopping: true } : {}) + } + }) + ) + } + + let connected = true + + function Harness({ statusFeed }: { statusFeed: boolean }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected, + hostSupport: { + promptCancel: false, + questionAnswers: false, + queuedMessages: false, + quietRepeatedStop: false, + statusFeed + }, + agent: 'codex', + onSendError + }) + return null + } + + async function mount(options: { statusFeed?: boolean } = {}) { + act(() => { + renderer = create(createElement(Harness, { statusFeed: options.statusFeed ?? true })) + }) + await vi.waitFor(() => expect(streams.has('agentSession.subscribe')).toBe(true)) + act(() => streams.get('agentSession.subscribe')?.(snapshot([runningTurn], 3))) + } + + beforeEach(() => { + streams = new Map() + cancelReply = null + connected = true + subscribe = vi.fn((method: string, _params: unknown, onData: (value: unknown) => void) => { + streams.set(method, onData) + return vi.fn() + }) + const sendRequest = vi.fn((method: string): Promise => + method === 'agentSession.cancel' + ? new Promise((resolve) => (cancelReply = resolve)) + : Promise.resolve({ ok: true, result: {}, _meta: { runtimeId: 'r1' } }) + ) + // A fresh client per test: the status stream is one per client for its life. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session hook and its status feed call only these members here; a missing one would throw at once, never misread. + client = { + sendRequest, + subscribe, + getState: () => 'connected', + onStateChange: () => () => {} + } as unknown as RpcClient + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + }) + + it('reads Stopping from the host with no press of its own, and keeps Stop for a repeat', async () => { + await mount() + + hostSays('session-1', true) + + expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: false }) + }) + + it("stays Stopping after this phone's own Stop answers while the host still says so", async () => { + await mount() + act(() => hook?.cancel()) + hostSays('session-1', true) + expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: true }) + + await act(async () => { + const cancelled = { ok: true, value: { cancelled: true } } + cancelReply?.({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) + }) + + await vi.waitFor(() => expect(hook?.turnIndicator.stopRequestInFlight).toBe(false)) + expect(hook?.turnIndicator.stopping).toBe(true) + }) + + it("never reads another session's Stopping", async () => { + await mount() + + hostSays('session-2', true) + + expect(hook?.turnIndicator.stopping).toBe(false) + }) + + it('reads nothing from the host while the phone is not connected', async () => { + await mount() + hostSays('session-1', true) + expect(hook?.turnIndicator.stopping).toBe(true) + + connected = false + act(() => renderer?.update(createElement(Harness, { statusFeed: true }))) + + expect(hook?.turnIndicator.stopping).toBe(false) + }) + + it('never opens the status stream on a host without the status feed', async () => { + await mount({ statusFeed: false }) + + expect(subscribe.mock.calls.map(([method]) => method)).not.toContain( + 'agentSession.subscribeStatus' + ) + expect(hook?.turnIndicator.stopping).toBe(false) }) }) diff --git a/mobile/src/transport/mobile-method-unavailable.test.ts b/mobile/src/transport/mobile-method-unavailable.test.ts new file mode 100644 index 00000000000..3fcb2a779f5 --- /dev/null +++ b/mobile/src/transport/mobile-method-unavailable.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { isMobileMethodUnavailableError } from './mobile-method-unavailable' + +describe('isMobileMethodUnavailableError', () => { + it('detects old-desktop allowlist and missing-method failures', () => { + expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) + expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) + expect( + isMobileMethodUnavailableError( + 'some_code', + "Method 'files.readDir' is not available to mobile clients" + ) + ).toBe(true) + expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) + expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) + }) +}) diff --git a/mobile/src/transport/mobile-method-unavailable.ts b/mobile/src/transport/mobile-method-unavailable.ts new file mode 100644 index 00000000000..dd959366a2f --- /dev/null +++ b/mobile/src/transport/mobile-method-unavailable.ts @@ -0,0 +1,13 @@ +// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: +// 'forbidden' = method exists but is not mobile-allowlisted on the old +// desktop; 'method_not_found' = desktop predates the method entirely. +export function isMobileMethodUnavailableError( + code: string | undefined, + message: string | undefined +): boolean { + return ( + code === 'forbidden' || + code === 'method_not_found' || + message?.includes('not available to mobile clients') === true + ) +} diff --git a/mobile/src/transport/rpc-subscription-inventory.ts b/mobile/src/transport/rpc-subscription-inventory.ts index 32d678a1c43..e39f601ad0c 100644 --- a/mobile/src/transport/rpc-subscription-inventory.ts +++ b/mobile/src/transport/rpc-subscription-inventory.ts @@ -107,6 +107,14 @@ export const RPC_SUBSCRIPTION_SITES: readonly RpcSubscriptionSite[] = [ release: 'params', coverage: { kind: 'unwritten-scenario' } }, + // The host's status stream, one per client, which the chat reads the host's "Stopping…" from. + // Mountable: the feed guards each frame and touches no device surface. The scenario is missing. + { + file: 'src/session/mobile-structured-session-status-feed.ts', + method: 'agentSession.subscribeStatus', + release: 'params', + coverage: { kind: 'unwritten-scenario' } + }, // The session tab snapshot. Mountable behind the reconciliation controller the hook already // takes; no device surface is involved. { diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index c3fa8c84b6d..89fef0a476a 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -186,6 +186,15 @@ describe('agentDisplayLabel', () => { ).toBe('Monitoring background tasks') }) + it("says Stopping while the host says a person's Stop is ending the turn", () => { + const stopping = row({ + lastAssistantMessage: 'hello there', + mainAgent: { state: 'working', stopping: true, stateStartedAt: 0 } + }) + expect(agentDisplayLabel(stopping, 0)).toBe('Stopping…') + expect(agentDotState(stopping, 0)).toBe('working') + }) + it('falls back to the decayed state label when stale', () => { expect( agentDisplayLabel( diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 87b07abfbfb..ef17ab39895 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -123,10 +123,18 @@ export function agentStateLabel(state: AgentDotState): string { } } -// Primary row text: prefer the agent's last message, then the user prompt, then -// a human-readable state label so a row is never blank. Matches the desktop -// DashboardAgentRow displayLabel fallback chain. +// Mirrors NATIVE_CHAT_TURN_STATUS_COPY.stopping. The cross-version suite copies this file alone +// (`importWorkingTreeModuleCopy`), so a value import from another file would not resolve there. +const STOPPING_LABEL = 'Stopping…' + +// Primary row text: "Stopping…" while the host says a person's Stop is ending the turn, else the +// agent's last message, then the user prompt, then a human-readable state label so a row is never +// blank. Matches the desktop DashboardAgentRow displayLabel fallback chain. The Stopping test +// mirrors agentStopDisplayStatus (src/shared), inline for the same single-file copy. export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): string { + if (row.state === 'working' && row.mainAgent?.stopping === true) { + return STOPPING_LABEL + } const message = row.lastAssistantMessage?.trim() if (message) { return message diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index 25c9ecbe852..b575007b002 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -192,6 +192,23 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(at(1), at(1))).toBe(true) }) + // The host drops Stopping on letting go of a session without moving any clock the row carries. + it("detects the host's Stopping coming and going with nothing else moved", () => { + const stopping = (value: boolean) => [ + worktree({ + agents: [ + agent({ + mainAgent: { state: 'working', stateStartedAt: 1, ...(value ? { stopping: true } : {}) } + }) + ] + }) + ] + + expect(areWorktreeListsEqual(stopping(false), stopping(true))).toBe(false) + expect(areWorktreeListsEqual(stopping(true), stopping(false))).toBe(false) + expect(areWorktreeListsEqual(stopping(true), stopping(true))).toBe(true) + }) + it('detects monitoring mode changes within working', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index 250c871493f..3fe50cec69a 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -133,6 +133,7 @@ function areMainAgentsEqual( return ( left.state === right.state && left.outcome === right.outcome && - left.stateStartedAt === right.stateStartedAt + left.stateStartedAt === right.stateStartedAt && + left.stopping === right.stopping ) } diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 101297a6cbb..0adc02d7eaa 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -104,9 +104,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. const journal = { snapshot: () => ({ items, submissions: [] }), + stopMarks: { latest: () => null, revision: () => 0 }, lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal @@ -192,9 +193,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. const journal = { lastActivityAt: () => 1, + stopMarks: { latest: () => null, revision: () => 0 }, cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ items: [ diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 6255c57b5ca..e455f77782e 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -49,7 +49,8 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, childWork: this.canonicalStatusStore.getChildren(parsed), - turnOutcome: summary.turnOutcome + turnOutcome: summary.turnOutcome, + ...(summary.stopping ? { stopping: summary.stopping } : {}) }) const { state, workingMode } = agentStatus // The main agent's own clock keeps continuity the same way the combined row's does below, dated diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 7eb09546e72..43201a125a1 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -32,6 +32,8 @@ async function wiredSession() { const journal = { cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }), lastActivityAt: () => 1, + // No Stop was ever pressed here. + stopMarks: { latest: () => null, revision: () => 0 }, snapshot: () => ({ items: [...run.journalItems.values()] .sort((a, b) => a.sequence - b.sequence) @@ -45,7 +47,7 @@ async function wiredSession() { [ parent.sessionId, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, activity clock and snapshot served here. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, activity clock, Stop marks and snapshot served here. journal: journal as unknown as Journal, params: { location: { diff --git a/src/main/codex/codex-structured-rewind.ts b/src/main/codex/codex-structured-rewind.ts index b222694422d..38b13fcad03 100644 --- a/src/main/codex/codex-structured-rewind.ts +++ b/src/main/codex/codex-structured-rewind.ts @@ -304,5 +304,6 @@ export function observeCodexRewindActivity( } if (turnId && method === 'turn/completed') { session.activeTurnIds?.delete(turnId) + session.abortedTurnIds?.delete(turnId) } } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 189a0e9eb5c..b34aa335cfc 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -239,6 +239,7 @@ export async function acquireCodexStructuredSession(input: { threadId: opened.threadId, historyMode: opened.historyMode, activeTurnIds: new Set(), + abortedTurnIds: new Set(), prompts: acquisition.prompts, options, reportedOptions: reportedCodexThreadOptions(opened), diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 21383cb821f..4400335030e 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -130,6 +130,9 @@ export type CodexSession = { /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ activeTurnIds?: Set + /** Of those, the turns whose interrupt Codex answered. It answers as the turn aborts, ahead of + * that turn's `turn/completed`, so none of them can take a steer any more. */ + abortedTurnIds?: Set /** Stops waiting for the turn Codex answered a send into to open. */ turnOpenWaits: CodexTurnOpenWaits dispatchPending?: boolean diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 4330ebed0fb..b66b158ba3f 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -48,6 +48,9 @@ export async function interruptCodexTurn(input: { } } } + if (threadId === session.threadId) { + session.abortedTurnIds?.add(turnId) + } const promptAdmission = input.onConfirmed?.() if (promptAdmission && !promptAdmission.accepted) { throw new Error( diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index c180eb045e4..817d6aa2d7e 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -61,6 +61,8 @@ export type CodexTurnHost = { catalogAccess?: CodexSessionCatalogAccess dispatchEchoes: CodexDispatchEchoes activeTurnIds?: ReadonlySet + /** Running turns whose interrupt Codex already answered: aborted, so never steered. */ + abortedTurnIds?: ReadonlySet turnOpenWaits: Pick } @@ -154,11 +156,15 @@ export async function startCodexTurn( if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { return false } + // A turn whose interrupt Codex answered has aborted, though its end may still be on the wire: + // the send opens its own turn. + const steerable = (turnId: string | null | undefined): turnId is string => + typeof turnId === 'string' && turnId !== '' && !host.abortedTurnIds?.has(turnId) const runningTurnId = await codexRunningOrOpeningTurn(host) - let steered = runningTurnId ? await steerCodexTurn(host, runningTurnId, input) : null + let steered = steerable(runningTurnId) ? await steerCodexTurn(host, runningTurnId, input) : null // Refused because a turn Orca heard of meanwhile is running: steer that one, once. const runningSince = steered ? undefined : [...(host.activeTurnIds ?? [])].at(-1) - if (runningSince && runningSince !== runningTurnId) { + if (steerable(runningSince) && runningSince !== runningTurnId) { steered = await steerCodexTurn(host, runningSince, input) } if (steered) { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index e2d1b1ef3ca..3f9f3115404 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -30,6 +30,7 @@ import { isJournalStopOrResumeRow, type JournalRow } from './journal-row-schema' import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectJournalStopNote } from './journal-stop-note-projection' import { createJournalQueuePauseMarks, foldJournalQueuePauseMark, @@ -233,7 +234,9 @@ function resolveItemId(state: JournalReducerState, itemId: string): string { export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { // The journal position is the sole ordering key; map insertion order is not, // because a re-created item re-enters the map after the items that followed it. - const items = [...state.items.values()].sort(compareAgentJournalItems) + const items = [...state.items.values()] + .map((item) => projectJournalStopNote(item, state.items)) + .sort(compareAgentJournalItems) return { sessionId: state.sessionId, cursor: { epoch: state.epoch, sequence: state.lastSequence }, diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index baba17c0058..18c4becacbe 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -9,15 +9,30 @@ import { personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' -import type { JournalStopSettle } from './queued-message-pause' +import type { JournalStopFailedOn, JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { + // Bumped on each settle edge: readers cached per commit see an edge that wrote no row. + private settleRevision = 0 + private onSettleEdge: (() => void) | null = null + constructor(private readonly deps: { state: () => JournalReducerState }) {} + /** Told of each settle edge, which writes no row, so it is no commit: it moves only what reads + * the settle. One listener: a later call replaces it. It must not throw. */ + observeSettleEdges(listener: () => void): void { + this.onSettleEdge = listener + } + latest(): JournalLatestStop | null { return this.deps.state().queuePauseMarks.latestStop } + /** Changes whenever a settle opens or closes. */ + revision(): number { + return this.settleRevision + } + /** `latestAcceptedSendUnopened`: the latest accepted send's turn row may still be on its way. */ latestAcceptedSendUnopened(): boolean { return latestAcceptedSendUnopened(this.deps.state()) @@ -30,17 +45,30 @@ export class JournalStopMarks { /** `beginJournalStopSettle`: a turn that ends from here until `settled` is the Stop's. */ beginSettle(): JournalStopSettle | null { - return beginJournalStopSettle(this.deps.state()) + const settle = beginJournalStopSettle(this.deps.state()) + if (settle) { + this.edge() + } + return settle } - /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one. */ - settled(settle: JournalStopSettle | null, turnId?: string): void { + /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one, or marking + * `failedOn` (display only) when it failed to stop that turn. */ + settled(settle: JournalStopSettle | null, turnId?: string, failedOn?: JournalStopFailedOn): void { if (!settle) { return } settle.settling = false if (turnId !== undefined) { settle.turnId = turnId + } else if (failedOn !== undefined) { + settle.failedOn = failedOn } + this.edge() + } + + private edge(): void { + this.settleRevision += 1 + this.onSettleEdge?.() } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts new file mode 100644 index 00000000000..896b279dc26 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts @@ -0,0 +1,22 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isStructuredAgentSessionStopNote } from '../agent-session-wire/structured-agent-session-command-turn' +import { STOP_NOTE_CANCELLATION_REQUESTED } from '../agent-session-wire/structured-agent-session-turn-stop-notes' + +/** The recorded turn end supersedes the earlier unconfirmed Stop for display only. */ +export function projectJournalStopNote( + item: AgentJournalRenderItem, + items: ReadonlyMap +): AgentJournalRenderItem { + // Field checks before the key parse: this runs for every item of every snapshot. + if ( + item.body.kind === 'status' && + item.body.failure?.kind === 'cancelUnconfirmed' && + item.turnScope?.kind === 'turn' && + isStructuredAgentSessionStopNote(item.itemId) && + readAgentJournalTurn(items.get(item.turnScope.turnItemId)?.body)?.state === 'interrupted' + ) { + return { ...item, body: { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } + } + return item +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 6b71477f74e..0d6b9c07ee0 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -23,7 +23,7 @@ import type { JournalQueuePauseMarks, JournalStopSettle } from './queued-message export type JournalLatestStop = NonNullable /** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ -function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { +export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { switch (reason) { case 'user-stop': case 'user-close': diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index de3b702067c..5dd79502d69 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -17,8 +17,17 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' /** What a person's Stop that named no turn binds, held in memory and never read from a row, so a - * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ -export type JournalStopSettle = { settling: boolean; turnId?: string } + * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. + * `failedOn`: the turn a Stop that failed could not stop, which reads "Stopping…" until it ends: + * the one running when it failed, or with none, the first that opens after the journal position + * it failed at. Display only: no turn-end rule reads it, so that turn's own end stays its own. */ +export type JournalStopSettle = { + settling: boolean + turnId?: string + failedOn?: JournalStopFailedOn +} + +export type JournalStopFailedOn = { turnId: string } | { openedAfter: number } /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 42af5f83042..dc63bc0aebc 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -13,6 +13,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' import { findSequenceGap } from '../agent-session-journal/journal-cursor' import { isJournalStopOrResumeRow, @@ -70,6 +71,12 @@ export function projectJournalBatch(input: { } const live = liveItemsById(input.snapshot.items) + const notes = stopNotesByTurn(input.snapshot.items) + for (const itemId of touchedItemIds) { + for (const noteId of notes.get(itemId) ?? []) { + touchedItemIds.add(noteId) + } + } const items = [...touchedItemIds] .map((itemId) => live.get(itemId)) .filter((item) => item !== undefined) @@ -87,7 +94,7 @@ export function projectJournalBatch(input: { } } -// Both indexes are keyed on the snapshot arrays themselves, which the reducer +// The indexes are keyed on the snapshot arrays themselves, which the reducer // rebuilds on every change, so a paged catch-up over one snapshot pays for them // once instead of once per page — including the byte-shrink loop's re-projections. const liveItemsByTimeline = new WeakMap< @@ -98,6 +105,36 @@ const aliasesBySubmissions = new WeakMap< readonly AgentJournalSubmission[], ReadonlyMap >() +const stopNotesByTimeline = new WeakMap< + readonly AgentJournalRenderItem[], + ReadonlyMap +>() + +function stopNotesByTurn( + items: readonly AgentJournalRenderItem[] +): ReadonlyMap { + const cached = stopNotesByTimeline.get(items) + if (cached) { + return cached + } + const notes = new Map() + for (const item of items) { + // Every Stop note is a status row. Not filtered on its failure: a note already projected + // without one must still re-emit when its turn row is removed. + if ( + item.body.kind === 'status' && + item.turnScope?.kind === 'turn' && + isStructuredAgentSessionStopNote(item.itemId) + ) { + const turnItemId = item.turnScope.turnItemId + const ids = notes.get(turnItemId) ?? [] + ids.push(item.itemId) + notes.set(turnItemId, ids) + } + } + stopNotesByTimeline.set(items, notes) + return notes +} function liveItemsById( items: readonly AgentJournalRenderItem[] diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts new file mode 100644 index 00000000000..d63ce1c4d1c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts @@ -0,0 +1,389 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../agent-session-journal/journal-reducer' +import { projectJournalBatch } from './agent-session-journal-batch' +import { readAgentSessionHistory } from './agent-session-history-page' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from './agent-session-history-page-bounds' + +const journals = createTrackedJournalOpener() +const turnIdentity = { provider: 'orca', clientMessageId: 'turn-1' } as const +const turnItemId = agentJournalItemKey(turnIdentity) +const noteIdentity = structuredAgentSessionStopNoteIdentity('turn-1') +const noteId = agentJournalItemKey(noteIdentity) +const scope = { kind: 'turn', turnItemId } as const +const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }), + tone: 'error', + presentation: 'error' +} +const ordinary = { kind: 'status', text: 'Cancellation requested.' } +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-note-projection-')) + journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: root + }) +}) +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function turn(state: AgentJournalTurnLifecycle['state'], legacy = false) { + const lifecycle: AgentJournalTurnLifecycle = { turnId: 'turn-1', state } + await journal.appendItem( + turnIdentity, + legacy + ? { kind: 'status', text: 'Turn ended', turnLifecycle: lifecycle } + : { kind: 'turn', ...lifecycle }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} +async function note(body: AgentJournalItemBody = unconfirmed) { + await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) +} +function readNote() { + return journal.snapshot().items.find((item) => item.itemId === noteId)! +} +function rows() { + const result = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!result.ok) { + throw new Error(result.reset) + } + return result.rows +} + +it.each([false, true])( + 'derives an interrupted turn note without changing any stored row (legacy=%s)', + async (legacy) => { + await turn('running', legacy) + await note() + const prior = readNote() + const raw = journal.itemBody(noteId) + const held = journal.cursor() + await turn('interrupted', legacy) + const stored = rows() + const projected = readNote() + expect(projected.body).toEqual(ordinary) + expect(projected).toEqual({ ...prior, body: ordinary }) + expect(projected).not.toBe(prior) + expect(readNote()).not.toBe(projected) + expect(journal.itemBody(noteId)).toBe(raw) + expect(rows()).toEqual(stored) + expect(stored.filter((row) => row.kind === 'item' && row.itemId === noteId)).toHaveLength(1) + const replay = createJournalReducerState('session-1', journal.epoch) + stored.forEach((row) => applyJournalRow(replay, row)) + expect(replay.items.get(noteId)?.body).toEqual(unconfirmed) + expect(renderJournalState(replay).items.find((item) => item.itemId === noteId)?.body).toEqual( + ordinary + ) + const batch = projectJournalBatch({ + rows: stored.filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ + ok: true, + batch: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ], + removedItemIds: [] + } + }) + } +) + +it.each(['running', 'completed', 'unverifiable', 'missing'] as const)( + 'leaves a %s turn note unchanged', + async (state) => { + if (state !== 'missing') { + await turn(state) + } + await note() + expect(readNote().body).toBe(journal.itemBody(noteId)) + expect(readNote().body).toEqual(unconfirmed) + } +) + +it('leaves unrelated failures and non-Stop notes unchanged', async () => { + await turn('interrupted') + const failed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { surface: 'row' }) + } + await note(failed) + await journal.appendItem({ provider: 'orca', clientMessageId: 'other' }, unconfirmed, { + fence: 1, + turnScope: scope + }) + expect(readNote().body).toBe(journal.itemBody(noteId)) + expect(journal.snapshot().items.at(-1)?.body).toEqual(unconfirmed) +}) + +it('derives the op-keyed note after the retained association write re-keys it onto the turn', async () => { + const op = structuredAgentSessionStopNoteIdentity('operation-1') + await journal.appendItem(op, unconfirmed, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await turn('running') + const held = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'stop-note-on-turn', + fence: 1, + mutations: [ + { kind: 'tombstone', identity: op }, + { kind: 'item', identity: noteIdentity, body: unconfirmed, turnScope: scope } + ] + }) + await turn('interrupted') + expect(readNote().body).toEqual(ordinary) + expect(journal.itemBody(noteId)).toEqual(unconfirmed) + const batch = projectJournalBatch({ + rows: rows().filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ ok: true, batch: { removedItemIds: [agentJournalItemKey(op)] } }) +}) + +it('keeps an operation note unconfirmed when no turn opened before the kill failed', async () => { + await journal.appendItem(noteIdentity, unconfirmed, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await turn('interrupted') + expect(readNote().body).toEqual(unconfirmed) +}) + +it('re-emits the note for lifecycle turn mutations and restores the raw wording for a turn tombstone', async () => { + await turn('running') + await note() + const held = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'ended', + fence: 1, + mutations: [ + { + kind: 'item', + identity: turnIdentity, + body: { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + const batch = projectJournalBatch({ + rows: rows().filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ + ok: true, + batch: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ] + } + }) + const ended = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'removed', + fence: 1, + mutations: [{ kind: 'tombstone', identity: turnIdentity }] + }) + const removed = projectJournalBatch({ + rows: rows().filter((row) => row.seq > ended.sequence), + snapshot: journal.snapshot(), + afterSequence: ended.sequence + }) + expect(removed).toMatchObject({ + ok: true, + batch: { + items: [expect.objectContaining({ itemId: noteId, body: unconfirmed })], + removedItemIds: [turnItemId] + } + }) +}) + +it('pages the end with its earlier note and advances by the consumed end row', async () => { + await turn('running') + await note() + const held = journal.cursor() + await turn('interrupted') + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 1 + }) + expect(page).toMatchObject({ + ok: true, + page: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ], + window: { nextCursor: journal.cursor() }, + hasNewer: false + } + }) + const notePage = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'tail', + limit: 1 + }) + expect(notePage).toMatchObject({ + ok: true, + page: { items: [expect.objectContaining({ itemId: noteId, body: ordinary })] } + }) +}) + +it('derives a note page with its ended turn on an earlier page, in either paging direction', async () => { + await turn('interrupted') + const held = journal.cursor() + await note() + const forward = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 1 + }) + expect(forward).toMatchObject({ + ok: true, + page: { + items: [expect.objectContaining({ itemId: noteId, body: ordinary })], + window: { nextCursor: journal.cursor() } + } + }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'Later' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const backward = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: journal.cursor(), + limit: 1 + }) + expect(backward).toMatchObject({ + ok: true, + page: { + items: [expect.objectContaining({ itemId: noteId, body: ordinary })], + window: { nextCursor: { sequence: readNote().sequence } } + } + }) +}) + +it('shrinks the triggering row window without dropping the dependent note', async () => { + await turn('running') + await note() + const held = journal.cursor() + const large = { + kind: 'message', + role: 'assistant', + blocks: [ + { type: 'text', text: 'x'.repeat(Math.floor(HISTORY_PAGE_CONTENT_BUDGET_BYTES * 0.6)) } + ] + } as const + for (const clientMessageId of ['large-1', 'large-2']) { + await journal.appendItem( + { provider: 'orca', clientMessageId }, + { ...large, blocks: [...large.blocks] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('interrupted') + const first = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 3 + }) + if (!first.ok) { + throw new Error(first.reset) + } + expect(first.page.hasNewer).toBe(true) + const next = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: first.page.window.nextCursor, + limit: 3 + }) + expect(next).toMatchObject({ + ok: true, + page: { + items: expect.arrayContaining([expect.objectContaining({ itemId: noteId, body: ordinary })]), + window: { nextCursor: journal.cursor() } + } + }) +}) + +it('refreshes at the exact tip using bounded frames and no durable writes', async () => { + await turn('interrupted') + for (let index = 0; index < 205; index += 1) { + await journal.appendItem(structuredAgentSessionStopNoteIdentity(`op-${index}`), unconfirmed, { + fence: 1, + turnScope: scope + }) + } + const stored = rows() + const events: AgentSessionSubscribeEvent[] = [] + new AgentSessionSubscribers().open({ + id: 'reconnect', + sessionId: 'session-1', + journal, + fence: 1, + cursor: journal.cursor(), + emit: (event) => events.push(event) + }) + const batches = events.flatMap((event) => (event.type === 'batch' ? [event.batch] : [])) + expect(batches.flatMap((batch) => batch.items)).toHaveLength(205) + expect( + batches.every( + (batch) => + batch.items.length <= 200 && + Buffer.byteLength(JSON.stringify(batch), 'utf8') < HISTORY_PAGE_CONTENT_BUDGET_BYTES && + batch.cursor.sequence === journal.cursor().sequence + ) + ).toBe(true) + expect( + batches + .flatMap((batch) => batch.items) + .every( + (item) => + item.body.kind === 'status' && item.body.text === ordinary.text && !item.body.failure + ) + ).toBe(true) + expect(rows()).toEqual(stored) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts new file mode 100644 index 00000000000..5f86048af0c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts @@ -0,0 +1,77 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + HISTORY_PAGE_CONTENT_BUDGET_BYTES, + oversizedHistoryItem +} from './agent-session-history-page-bounds' +import { emptyAgentSessionBatch } from './agent-session-empty-batch' +import type { SubscriberDeliveryPort } from './agent-session-subscriber-catch-up' +import type { Subscriber } from './structured-agent-session-subscribers' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' + +/** A stale in-flight history page can land after its note's live correction was out of window. */ +export function refreshDerivedStopNotes( + port: Pick, + subscriber: Subscriber, + journal: AgentSessionJournal, + hostNow: number +): void { + let hasUnconfirmedStop = false + journal.visitItems((itemId, _sequence, body) => { + if ( + body.kind === 'status' && + body.failure?.kind === 'cancelUnconfirmed' && + isStructuredAgentSessionStopNote(itemId) + ) { + hasUnconfirmedStop = true + } + }) + if (!hasUnconfirmedStop) { + return + } + const snapshot = journal.snapshot() + let pending: AgentJournalRenderItem[] = [] + let bytes = 0 + const emit = (): void => { + port.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { ...emptyAgentSessionBatch(subscriber.cursor), items: pending }, + fence: subscriber.fence, + hostNow + }) + pending = [] + bytes = 0 + } + for (const item of snapshot.items) { + if (!port.isActive(subscriber)) { + return + } + const raw = journal.itemBody(item.itemId) + if ( + raw?.kind !== 'status' || + raw.failure?.kind !== 'cancelUnconfirmed' || + item.body.kind !== 'status' || + item.body.failure !== undefined + ) { + continue + } + const projectedBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + const bounded = + projectedBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item) : item + const itemBytes = Buffer.byteLength(JSON.stringify(bounded), 'utf8') + 1 + if ( + pending.length > 0 && + (pending.length === AGENT_SESSION_HISTORY_MAX_LIMIT || + bytes + itemBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) + ) { + emit() + } + pending.push(bounded) + bytes += itemBytes + } + if (pending.length > 0 && port.isActive(subscriber)) { + emit() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index c410c7d3f0a..79d6850c559 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -52,7 +52,7 @@ function channelOver( ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. const session = { - journal: { observeCommits: () => {} }, + journal: { observeCommits: () => {}, stopMarks: { observeSettleEdges: () => {} } }, params: { provider: 'claude' } } as unknown as StructuredAgentSessionHostSession return { sessions, republished, channel, session } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 834d4f569fa..edfe42f6d85 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -30,10 +30,9 @@ import { } from './structured-agent-session-send-preparation' import { endStoppedStructuredAgentSession, - isMainAgentWorking, - performCancel, type StructuredAgentSessionStopWindDown -} from './structured-agent-session-turns-cancel' +} from './structured-agent-session-stop-wind-down' +import { isMainAgentWorking, performCancel } from './structured-agent-session-turns-cancel' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -55,7 +54,9 @@ export function mutateWithChatStop( const { envelope, turnId } = params const { sessionId } = envelope // Set by the Stop's step only when its provider's session ends; a replay leaves it unset. - let windDown: StructuredAgentSessionStopWindDown | undefined + let windDown: + | { owed: StructuredAgentSessionStopWindDown; ctx: AgentSessionTurnContext } + | undefined // The Stop's event, still landing when its session ends: the next step holds the lane for it. let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} @@ -151,7 +152,7 @@ export function mutateWithChatStop( // The host drops its child only once the exit is proven, and nothing else runs meanwhile. childReleased: () => context.sessions.get(sessionId)?.child !== child, endSession: (owed) => { - windDown = owed + windDown = { owed, ctx } }, withdrewQueued: withdrew, ...(ownsLatestStop ? { opensSettle: true as const } : {}) @@ -182,9 +183,10 @@ export function mutateWithChatStop( // Queued in the mutation's own tick, so a send made meanwhile lands behind the child's end. void context.serialize(sessionId, async () => { if (windDown) { + const { owed, ctx } = windDown await endStoppedStructuredAgentSession( - { sessionId, adapter: context.deps.adapter }, - windDown, + { ...ctx, adapter: context.deps.adapter }, + owed, stopChild, (error) => context.deps.logger.warn("ending a stopped chat's provider session failed", { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts new file mode 100644 index 00000000000..cd4da3055b5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -0,0 +1,350 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +// Stop notes follow interrupted ends written by the shipping Claude translator. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let events: AgentSessionSubscribeEvent[] +let unconfirmedNote: AgentJournalRenderItem | undefined +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let log: ReturnType +let persistHandle: ReturnType Promise>> +let childWork: string[] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-exit-ends-record-')) + resetHostTestOperationIds() + events = [] + unconfirmedNote = undefined + log = recordingStructuredAgentSessionLogger() + persistHandle = vi.fn(async () => undefined) + childWork = [] + claude = fakeClaude({ + replayUuid: null, + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: () => persistHandle(), + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence) => { + childWork.push(...evidence.map((edge) => edge.type)) + host.publishChildWorkEvidence(sessionId, evidence) + }, + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // Ticked by hand only, and with no idle window: a tick reaps whatever is at rest. + idleSweep: { intervalMs: 3_600_000, idleMs: 0 }, + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) + await host.subscribe({ + id: 'stop-note-live', + sessionId: SESSION, + emit: (event) => events.push(event) + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: + | 'agentSession.send' + | 'agentSession.cancel' + | 'agentSession.setOption' + | 'agentSession.queuedMessageSend', + // The fingerprint's own field shape, as the sibling host tests type it. + fields: Parameters[0]['fields'] +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) + } + return sent.value.clientMessageId +} + +async function submission(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function wrote(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} + +async function openTurn(connection: FakeConnection): Promise { + const text = 'Write a long reply.' + const clientMessageId = await send(text) + await eventually(() => expect(wrote(connection, text)).toBe(true)) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const written = connection.sent.at(-1)! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => + expect((await submission(clientMessageId))?.dispatchState).toBe('accepted') + ) + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).not.toBeNull() +} + +function stop() { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +/** As the real connection: once a close begins it refuses every write, proven or not. */ +function closeUnprovenFor(connection: FakeConnection, failures: number): void { + const close = connection.close + let left = failures + connection.close = async () => { + if (left === 0) { + return close() + } + left -= 1 + connection.closeCount += 1 + connection.closed = true + return false + } + refuseWritesOnceClosed(connection) +} + +function refuseWritesOnceClosed(connection: FakeConnection): void { + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +const INTERRUPTED_RESULT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' +} + +async function notesAndTurn() { + await host.flushStreamedEvents(SESSION) + const items = (await host.journalSnapshot(SESSION)).items + return { + notes: items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)), + turn: items.find((item) => item.body.kind === 'turn') + } +} + +async function unconfirmedStop(): Promise { + const connection = claude.connections[0]! + await openTurn(connection) + closeUnprovenFor(connection, 1) + await expect(stop()).resolves.toMatchObject({ ok: true }) + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(turn?.body).toMatchObject({ state: 'running' }) + unconfirmedNote = notes[0] + }) + return connection +} + +async function confirmedNote(): Promise { + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'interrupted' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(notes[0]?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect(notes[0]).toMatchObject({ + itemId: unconfirmedNote?.itemId, + revision: unconfirmedNote?.revision, + sequence: unconfirmedNote?.sequence + }) + const journal = host['sessions'].get(SESSION)!.journal + expect(journal.itemBody(notes[0]!.itemId)).toMatchObject({ + failure: { kind: 'cancelUnconfirmed' } + }) + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.items.some( + (item) => + item.itemId === notes[0]?.itemId && + item.body.kind === 'status' && + item.body.text === 'Cancellation requested.' && + !item.body.failure + ) && + event.batch.items.some( + (item) => + item.itemId === turn?.itemId && + item.body.kind === 'turn' && + item.body.state === 'interrupted' + ) + ) + ).toBe(true) + }) +} + +it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { + const connection = await unconfirmedStop() + connection.handlers.onExit?.(new Error('claude exited'), { expected: true }) + await laneDrained() + await confirmedNote() +}) + +it('projects the note when a second Stop joins the close and proves the exit', async () => { + await unconfirmedStop() + await expect(stop()).resolves.toMatchObject({ ok: true }) + await confirmedNote() +}) + +it('projects the note when a send joins the close and proves the exit', async () => { + const connection = await unconfirmedStop() + await send('Carry on.') + await eventually(() => { + const resumed = claude.connections.at(-1)! + expect(resumed).not.toBe(connection) + expect(wrote(resumed, 'Carry on.')).toBe(true) + }) + await confirmedNote() +}) + +it("projects the note when Claude's own interrupted result ends the turn", async () => { + const connection = await unconfirmedStop() + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + await confirmedNote() +}) + +it('keeps the note unconfirmed when the turn completes', async () => { + const connection = await unconfirmedStop() + frame(connection, { + type: 'result', + subtype: 'success', + is_error: false, + uuid: 'completed-result' + }) + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'completed' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 6e6859db28f..024331fbe8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -50,7 +50,8 @@ export class StructuredAgentSessionClientDelivery { this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now, - readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal) + readStatusState: (sessionId, journal) => + this.statusFeed.journalProjection(sessionId, journal)?.state ?? null }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) @@ -88,6 +89,12 @@ export class StructuredAgentSessionClientDelivery { publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) + /** What the feed publishes as `stopping`: only a working session is still being stopped. */ + readStopping = (sessionId: string): boolean => { + const projection = this.statusFeed.journalProjection(sessionId) + return projection?.stopping === true && projection.state.summary.status === 'working' + } + readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => this.statusFeed.readChildWork(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts new file mode 100644 index 00000000000..3ee2b83a412 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts @@ -0,0 +1,254 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +// What a Stop's row says when Codex took the interrupt, against the real host, journal and Codex +// adapter. Codex answers a turn's interrupt as the turn aborts and sends the turn's end right after +// the answer, so the end reaches Orca a moment after the Stop has its answer. + +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { CodexAppServerTimeoutError } from '../../codex/codex-app-server-session' +import { + THREAD_ID as THREAD, + adapterFor, + fakeCodex +} from '../../codex/codex-structured-session-adapter-fixture' +import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import type { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' + +const CALLER = { callerKey: 'client-1' } + +let events: AgentSessionSubscribeEvent[] +let unconfirmedNote: AgentJournalRenderItem | undefined +let root: string +let host: StructuredAgentSessionHost +let turns: ReturnType +let codex: ReturnType +let notify: (method: string, params: unknown) => void +/** Read at each start, so a test can say what the next start resumes. */ +let launch: { resumeThreadId?: string | null } +let adapter: CodexStructuredSessionAdapter + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-stop-row-')) + resetHostTestOperationIds() + events = [] + unconfirmedNote = undefined + codex = fakeCodex() + notify = (method, params) => codex.connections.at(-1)?.handlers.onNotification?.(method, params) + turns = codexTurnLifecycleFake(THREAD, () => notify) + codex.routes['turn/start'] = turns.routes['turn/start'] + codex.routes['turn/interrupt'] = () => { + const turnId = turns.turnId + // The answer first, then the turn's end on a later read of Codex's output. + setTimeout( + () => + notify('turn/completed', { threadId: THREAD, turn: { id: turnId, status: 'interrupted' } }), + 4 + ) + return {} + } + const store = await openTestAgentSessionRecordStore(root) + // The runtime's wiring: an echo accepts its send, and every exit reaches the host. + launch = {} + adapter = adapterFor(codex, launch, [], { + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onEvent: (event) => { + if (event.type === 'ended' && 'cause' in event) { + void host.handleAdapterEvent(event) + } + } + }) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: recordingStructuredAgentSessionLogger().logger, + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + await host.subscribe({ + id: 'stop-note-live', + sessionId: SESSION, + emit: (event) => events.push(event) + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function send(text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) +} + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +async function runningTurn(): Promise { + const sent = await send('count to 40') + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) + turns.start() + turns.echo(sent.value.clientMessageId) + await vi.waitFor(async () => + expect((await notesAndTurn()).turn?.body).toMatchObject({ state: 'running' }) + ) +} + +async function notesAndTurn() { + await host.flushStreamedEvents(SESSION) + const items = (await host.journalSnapshot(SESSION)).items + return { + notes: items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)), + turn: items.find((item) => item.body.kind === 'turn') + } +} + +async function unconfirmedStop() { + await runningTurn() + codex.routes['turn/interrupt'] = () => { + throw new CodexAppServerTimeoutError('codex app-server turn/interrupt exceeded 30000ms') + } + const connection = codex.connections[0]! + const close = connection.close + connection.close = async () => { + connection.close = close + connection.closeCount += 1 + return false + } + await expect(stop()).resolves.toMatchObject({ ok: true }) + const { notes, turn } = await notesAndTurn() + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(turn?.body).toMatchObject({ state: 'running' }) + unconfirmedNote = notes[0] + return connection +} + +async function confirmedNote(): Promise { + await vi.waitFor(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'interrupted' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(notes[0]?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect(notes[0]).toMatchObject({ + itemId: unconfirmedNote?.itemId, + revision: unconfirmedNote?.revision, + sequence: unconfirmedNote?.sequence + }) + const journal = host['sessions'].get(SESSION)!.journal + expect(journal.itemBody(notes[0]!.itemId)).toMatchObject({ + failure: { kind: 'cancelUnconfirmed' } + }) + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.items.some( + (item) => + item.itemId === notes[0]?.itemId && + item.body.kind === 'status' && + item.body.text === 'Cancellation requested.' && + !item.body.failure + ) && + event.batch.items.some( + (item) => + item.itemId === turn?.itemId && + item.body.kind === 'turn' && + item.body.state === 'interrupted' + ) + ) + ).toBe(true) + }) +} + +it('projects the note on a late self-exit after the kill timed out', async () => { + const connection = await unconfirmedStop() + connection.handlers.onExit?.(new Error('codex exited'), { expected: true }) + await confirmedNote() +}) + +it('projects the note when a second Stop joins the close and proves the exit', async () => { + await unconfirmedStop() + await expect(stop()).resolves.toMatchObject({ ok: true }) + await confirmedNote() +}) + +it('projects the note when a send joins the close and proves the exit', async () => { + const connection = await unconfirmedStop() + await expect(send('Carry on.')).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => { + expect(codex.connections.at(-1)).not.toBe(connection) + expect(connection.closeCount).toBe(2) + }) + await confirmedNote() +}) + +it("projects the note when Codex's own interrupted result ends the turn", async () => { + await unconfirmedStop() + turns.end('interrupted') + await confirmedNote() +}) + +it('keeps the note unconfirmed when the turn completes', async () => { + await unconfirmedStop() + turns.end('completed') + await vi.waitFor(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'completed' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index 02d1d356aa3..b4ee124597f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -22,6 +22,8 @@ export class StructuredAgentSessionConversations extends Map< constructor( private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void + /** A person's Stop settle opened or closed: no row, so neither a publish nor activity. */ + deliverSettleEdge?: (sessionId: string, journal: AgentSessionJournal) => void logger: StructuredAgentSessionLogger /** A conversation became held: state that waited on it (queued drafts) re-derives. */ onOpened?: (sessionId: string) => void @@ -55,6 +57,22 @@ export class StructuredAgentSessionConversations extends Map< } }) }) + journal.stopMarks.observeSettleEdges(() => { + queueMicrotask(() => { + if (this.get(sessionId)?.journal !== journal) { + return + } + try { + this.delivery.deliverSettleEdge?.(sessionId, journal) + } catch (error) { + this.delivery.logger.warn("delivering a Stop's settle edge failed", { + scope: 'stop-settle-delivery', + sessionId, + error + }) + } + }) + }) this.activity.set(sessionId, this.delivery.now()) const adopted = super.set(sessionId, session) this.delivery.onOpened?.(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 3595ff6b0f8..071de4b4157 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -195,7 +195,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { }) } } - mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) + const turnEnds = runningTurnLifecycleRevisions(items, input.verdict) + mutations.push(...turnEnds) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 3d5d3705043..c9a3a4a997a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -69,6 +69,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { logger: StructuredAgentSessionLogger record: (sessionId: string) => AgentSessionRecord | null readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + /** A person's Stop is still ending the session's work: the status feed's own reading. */ + stopping: (sessionId: string) => boolean now: () => number } @@ -233,6 +235,12 @@ export class StructuredAgentSessionDeliveryLoop { { failure: startFailure ?? agentSessionFailureFact('startFailed') } }) } + // Never steer into a turn a person's Stop is ending: the message runs after it, as its own + // turn, and the turn's end is a commit that wakes the loop again. Read here, at the handover, + // because a Stop can land while this step waits on the child's start. + if (this.deps.stopping(sessionId)) { + return this.stop(sessionId) + } const next = oldestQueuedSubmission(session) if (!next) { return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index d1bb1cfa794..4e4247c316f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -34,6 +34,9 @@ export type StructuredAgentSessionConversationDelivery = { * queue. Enqueued through the session's serialize, never read here, so a commit that lands while * a step is deciding to stop wakes the loop after that step rather than being lost to it. */ afterCommit: (sessionId: string, journal: AgentSessionJournal) => void + /** A person's Stop settle opened or closed. It writes no row, so only what reads the settle + * moves: the session's status row and the steer hold's handover. Never activity. */ + afterSettleEdge: (sessionId: string, journal: AgentSessionJournal) => void /** Stops the loop and the resettle on a proof of death; quit's first step. */ dispose: () => void /** Indexes a conversation some other open produced, as `open` would have. */ @@ -53,7 +56,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise - clientDelivery: Pick + clientDelivery: Pick< + StructuredAgentSessionClientDelivery, + 'publishRestored' | 'readChildWork' | 'readStopping' | 'publishStatus' + > }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input const loop = new StructuredAgentSessionDeliveryLoop({ @@ -79,6 +85,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, + stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now() }) const adoptOpened = async ( @@ -139,6 +146,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { return { loop, afterCommit, + afterSettleEdge: (sessionId, journal) => { + input.clientDelivery.publishStatus(sessionId) + afterCommit(sessionId, journal) + }, adoptOpened, dispose: () => { loop.dispose() 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 91ae48ea173..d8daaa65953 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 @@ -67,6 +67,7 @@ export class StructuredAgentSessionHost { this.subscribers.publish(sessionId, journal) this.conversationDelivery.afterCommit(sessionId, journal) }, + deliverSettleEdge: (id, journal) => this.conversationDelivery.afterSettleEdge(id, journal), logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index cbcad01350a..6dfcb4f898e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -102,7 +102,7 @@ async function sessionWithRunningTurn() { const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock, - readStatusState: (sessionId, source) => feed.statusState(sessionId, source) + readStatusState: (sessionId, source) => feed.journalProjection(sessionId, source)?.state ?? null }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts new file mode 100644 index 00000000000..f3664c3166f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts @@ -0,0 +1,110 @@ +import { expect, vi, type Mock } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_SESSION, + HOST_TEST_THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +export async function expectRawStopNoteRewindRecovery({ + host, + store, + rewind +}: { + host: StructuredAgentSessionHost + store: AgentSessionRecordStore + rewind: Mock> +}): Promise { + const caller = { callerKey: 'desktop' } + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const journal = host['sessions'].get(HOST_TEST_SESSION)!.journal + const turn = { provider: 'orca', clientMessageId: 'kept-turn' } as const + const note = { provider: 'orca', clientMessageId: 'stop:kept-turn' } as const + const noteId = agentJournalItemKey(note) + const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + await journal.appendItem( + turn, + { kind: 'turn', turnId: 'kept-turn', state: 'interrupted' }, + { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await journal.appendItem(note, unconfirmed, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } + }) + const targetIdentity = { + provider: 'codex', + threadId: HOST_TEST_THREAD, + turnId: 'drop', + ordinal: 0 + } as const + await journal.appendItem(targetIdentity, hostTestMessage('drop'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const target = agentJournalItemKey(targetIdentity) + expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ + kind: 'status', + text: 'Cancellation requested.' + }) + rewind.mockImplementation(async () => { + expect( + store.getRecord(HOST_TEST_SESSION)?.rewind?.retained.find((item) => item.itemId === noteId) + ?.body + ).toEqual(unconfirmed) + return { ok: true } + }) + const replace = journal.replaceEpochItems.bind(journal) + const crash = vi.spyOn(journal, 'replaceEpochItems').mockImplementationOnce(async (...args) => { + await replace(...args) + throw new Error('crashed after commit') + }) + await expect( + host.rewind(caller, { + itemId: target, + expectedEpoch: journal.epoch, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: HOST_TEST_SESSION, + fields: { itemId: target, expectedEpoch: journal.epoch } + }) + } + }) + ).rejects.toThrow('crashed after commit') + crash.mockRestore() + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + expect(journal.itemBody(noteId)).toEqual(unconfirmed) + expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ + kind: 'status', + text: 'Cancellation requested.' + }) + expect(rewind).toHaveBeenCalledTimes(1) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 1d2cfeba589..3cd2417eb72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,3 +1,4 @@ +import { expectRawStopNoteRewindRecovery } from './structured-agent-session-rewind-stop-note.test-fixture' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody @@ -187,6 +188,10 @@ async function params(itemId: string, epoch?: string) { } describe('host rewind', () => { + it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => { + await expectRawStopNoteRewindRecovery({ host, store, rewind }) + }) + it('resolves accepted codex user submissions to provider targets', async () => { const target = await seed(true) expect(target.startsWith('orca:')).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 6fe719c8cbd..58003abee52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -121,13 +121,19 @@ export async function rewindStructuredAgentSession( } const retained = snapshot.items .slice(0, boundary) - .map(({ itemId, body, observedAt, turnScope, ...linkage }) => ({ - itemId: providerKey(itemId), - body: withRenamedTurnOpener(body, providerKey), - observedAt, - ...(turnScope ? { turnScope } : {}), - ...agentJournalLinkageFields(linkage) - })) + .map(({ itemId, observedAt, turnScope, ...linkage }) => { + const body = ctx.journal.itemBody(itemId) + if (!body) { + throw new Error('agent_session_rewind:missing-retained-item') + } + return { + itemId: providerKey(itemId), + body: withRenamedTurnOpener(body, providerKey), + observedAt, + ...(turnScope ? { turnScope } : {}), + ...agentJournalLinkageFields(linkage) + } + }) if ( retained.length > 10_000 || Buffer.byteLength(JSON.stringify(retained), 'utf8') > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts new file mode 100644 index 00000000000..d409aeb98df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts @@ -0,0 +1,151 @@ +// The status feed's Stopping is live state, like the child it rides beside: when the host lets go of +// a session, nothing there is ending the work, so the retained summary no longer says Stopping. + +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const SESSION = 'status-session' + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-feed-stopping-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("the status feed's Stopping", () => { + it('publishes it while the stopped turn runs, and drops it when the host lets go', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const session = indexedStatusFeedSession({ + journal, + child: { phase: 'ready', generation: 'child-1', fence } + }) + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, session]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + feed.publish(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence) + feed.publish(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working', stopping: true }) + + feed.revokeLive(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) + + it('reads a turn waiting on the person as attention, never Stopping', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1' } as const + await journal.appendItem( + { ...turn, ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence) + await journal.appendItem( + { ...turn, ordinal: 1 }, + { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + feed.publish(SESSION) + + expect(published.at(-1)).toMatchObject({ status: 'attention' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) + + it('never says Stopping for a stop that was not a person’s', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + await journal.appendStopEvent({ reason: 'host-stop', turnId: 'turn-1' }, fence) + feed.publish(SESSION) + + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index de27ebbe8c6..a943f3e3a78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -26,7 +26,7 @@ import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-r import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, - type StructuredAgentSessionStatusState + type StructuredAgentSessionJournalProjection } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' import { @@ -169,7 +169,8 @@ export class StructuredAgentSessionStatusFeed { } } - /** Revoke live execution authority while retaining the last projection for reload history. */ + /** Revoke live execution authority while retaining the last projection for reload history. A + * Stop still ending work is live state too: with the host gone, nothing here is ending it. */ revokeLive(sessionId: string): void { const previous = this.published.get(sessionId) if (!previous) { @@ -178,6 +179,7 @@ export class StructuredAgentSessionStatusFeed { const { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, + stopping: _stopping, ...retained } = previous this.published.set(sessionId, retained) @@ -188,15 +190,15 @@ export class StructuredAgentSessionStatusFeed { }) } - /** The projection behind the session's row and the latest request it read, cached per commit, - * so the completion feed follows the same request without snapshotting the journal again. */ - statusState( + /** The projection behind the session's row, cached per commit: the latest request it read, so + * the completion feed follows it without snapshotting the journal again, and its `stopping`, + * which the steer hold reads instead of deriving it again. */ + journalProjection( sessionId: string, journal?: AgentSessionJournal - ): StructuredAgentSessionStatusState | null { - const session = this.deps.sessions.get(sessionId) - const source = journal ?? session?.journal - return source ? this.projections.read(source, this.deps.getRecord(sessionId)).state : null + ): StructuredAgentSessionJournalProjection | null { + const source = journal ?? this.deps.sessions.get(sessionId)?.journal + return source ? this.projections.read(source, this.deps.getRecord(sessionId)) : null } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -209,7 +211,7 @@ export class StructuredAgentSessionStatusFeed { const record = this.deps.getRecord(sessionId) const projection = this.projections.read(source, record) this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) - const summary = this.summaryFor(sessionId, session, source, record, projection.state) + const summary = this.summaryFor(sessionId, session, source, record, projection) const previous = this.published.get(sessionId) if (previous && structuredStatusSummariesEqual(previous, summary)) { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { @@ -258,7 +260,7 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, journal: AgentSessionJournal, record: AgentSessionRecord | null, - state: StructuredAgentSessionStatusState + { state, stopping }: Pick ): AgentSessionStatusSummary { const projected = state.summary const providerSession = structuredAgentSessionProviderSessionMetadata(record) @@ -277,6 +279,8 @@ export class StructuredAgentSessionStatusFeed { } : {}), ...projected, + // Only a working session is still being stopped; any other status already ended that work. + ...(stopping && projected.status === 'working' ? { stopping: true as const } : {}), ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index 87b440ef37a..13aa4633483 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -5,6 +5,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' +import { structuredAgentSessionStopping } from './structured-agent-session-stopping' export type StructuredAgentSessionStatusState = ReturnType< typeof projectStructuredAgentSessionStatusState @@ -14,8 +15,12 @@ export type StructuredAgentSessionJournalProjection = { epoch: string sequence: number fence: number | undefined + /** The Stop marks' settle revision: a settle edge writes no row, so it is a key of its own. */ + stopRevision: number state: StructuredAgentSessionStatusState acceptedSendKey: string + /** A person's Stop is still ending the work it stopped (`structuredAgentSessionStopping`). */ + stopping: boolean } export class StructuredAgentSessionJournalProjections { @@ -32,12 +37,14 @@ export class StructuredAgentSessionJournalProjections { const cursor = journal.cursor() // The conversation's fence, which a child's end moves: its unanswered sends stop counting. const fence = record?.lease.runtimeFence + const stopRevision = journal.stopMarks.revision() let projection = this.byJournal.get(journal) if ( !projection || projection.epoch !== cursor.epoch || projection.sequence !== cursor.sequence || - projection.fence !== fence + projection.fence !== fence || + projection.stopRevision !== stopRevision ) { // A journalled submission bumps `lastSequence`, so the send-time working // signal reaches the cache; the lease fence does not, hence the extra key. @@ -45,12 +52,14 @@ export class StructuredAgentSessionJournalProjections { projection = { ...cursor, fence, + stopRevision, state: projectStructuredAgentSessionStatusState( snapshot.items, snapshot.submissions, fence ), - acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions) + acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions), + stopping: structuredAgentSessionStopping(journal, snapshot.items, snapshot.submissions) } this.byJournal.set(journal, projection) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 68f8e06a40c..17b4a63dc4b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -35,6 +35,7 @@ export function structuredStatusSummariesEqual( a.lastAssistantMessage === b.lastAssistantMessage && a.launchDirectory === b.launchDirectory && a.turnOutcome === b.turnOutcome && + a.stopping === b.stopping && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && structuredStatusChildrenEqual(a.children, b.children) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts new file mode 100644 index 00000000000..1bc687e31e3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -0,0 +1,220 @@ +// Nothing steers into a turn a person's Stop is ending: a card's Send-now or a send made then waits +// for the turn to end, and runs after it as its own turn. The host owns the rule, so a client of +// any version gets it. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, + { + kind: 'turn', + turnId, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { + fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) +} + +function watchStatus(): () => AgentSessionStatusSummary | undefined { + const events: AgentSessionStatusEvent[] = [] + rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) + return () => { + for (const event of events.toReversed()) { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + return event.session + } + if (event.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) + } + } + return undefined + } +} + +/** The session's lane, after every step queued on it so far, the delivery loop's included. */ +async function laneDrained(): Promise { + for (let step = 0; step < 5; step += 1) { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + } +} + +/** Turn `turn-1` running, a person's Stop ending it, and what was dispatched by then. */ +async function stoppingTurn(options: { card?: true; refused?: true } = {}) { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + let cardId: string | undefined + if (options.card) { + await rig.send('queued card', 'queue-if-active').result + await eventually(async () => expect(await rig.drafts()).toHaveLength(1)) + cardId = (await rig.drafts())[0]?.messageId + } + if (options.refused) { + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) + } + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + return { sent, status, cardId, dispatched: rig.dispatch.mock.calls.length } +} + +describe("a message sent while a person's Stop ends the turn", () => { + it.each([ + ['a queued card sent now', true, false], + ['a send', false, false], + ['a send, after a Stop the agent declined', false, true] + ])('waits for the turn to end, then runs as its own turn: %s', async (_, fromCard, refused) => { + const { sent, status, cardId, dispatched } = await stoppingTurn({ + ...(fromCard ? { card: true as const } : {}), + ...(refused ? { refused: true as const } : {}) + }) + + const result = cardId ? await rig.sendNow(cardId) : await rig.send('one more thing').result + expect(result).toMatchObject({ ok: true }) + // Held on the host: the delivery loop has run its steps and handed nothing over. + await laneDrained() + const held = await rig.submission(result.ok ? result.value.clientMessageId : '') + expect(held).toMatchObject({ dispatchState: 'pending' }) + expect(held?.handedOverAt).toBeUndefined() + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + expect(status()).toMatchObject({ stopping: true }) + + await turn('turn-1', sent, 'interrupted') + + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + }) + + // A Stop pressed before the turn showed holds a later send only while it settles; settling + // having stopped nothing hands it over, and the turn that then opens is not the Stop's. + it('hands over a send made after a Stop that stopped nothing, with no Stopping flip', async () => { + rig = await createQueuedMessageTestRig() + const first = await rig.workingSend() + const seen: (true | undefined)[] = [] + rig.host.subscribeStatus({ + id: 'flips', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + seen.push(event.session.stopping) + } + } + }) + const answer = Promise.withResolvers<{ cancelled: boolean }>() + rig.cancelTurn.mockImplementationOnce(() => answer.promise) + const stopped = rig.stop() + await eventually(() => expect(seen.at(-1)).toBe(true)) + const dispatched = rig.dispatch.mock.calls.length + + // Accepted on the session's lane behind the Stop, so it lands once the Stop settles. + const later = rig.send('sent before the turn opened') + answer.resolve({ cancelled: false }) + expect(await stopped).toMatchObject({ ok: true }) + expect(await later.result).toMatchObject({ ok: true }) + + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + await rig.settleAccepted(first, 'first') + await turn('turn-1', first, 'running') + await laneDrained() + expect(seen.at(-1)).toBeUndefined() + // Once Stopping ended it never came back. + expect(seen.slice(seen.lastIndexOf(true) + 1)).not.toContain(true) + }) + + // A settle edge writes no row, so nothing else wakes the handover once it closes. + it('hands a held send over when the Stop settles with no row after it', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await journal().appendStopEvent({ reason: 'user-stop' }, 1) + const settle = journal().stopMarks.beginSettle() + const later = rig.send('sent while the Stop settles') + expect(await later.result).toMatchObject({ ok: true }) + await laneDrained() + const dispatched = rig.dispatch.mock.calls.length + expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() + + journal().stopMarks.settled(settle) + + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + }) + + // The delivery step that judged the send waits on the child's start outside the session's lane. + it('holds a send that a Stop overtook while its delivery step waited on the agent', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + const started = Promise.withResolvers() + const awaited = rig.awaitStarted.mock.calls.length + rig.awaitStarted.mockImplementationOnce(() => started.promise) + const first = rig.send('steer this in') + expect(await first.result).toMatchObject({ ok: true }) + await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBe(awaited + 1)) + const dispatched = rig.dispatch.mock.calls.length + + // The Stop withdraws the first send; a second one arrives before the step resumes. + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + const second = rig.send('and this one') + expect(await second.result).toMatchObject({ ok: true }) + started.resolve(undefined) + await laneDrained() + + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + expect((await rig.submission(second.id))?.handedOverAt).toBeUndefined() + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + }) + + // The hold reads the status feed's own projection for the commit, never a journal read of its own. + it('reads Stopping once per commit while a send waits on it', async () => { + const { cardId } = await stoppingTurn({ card: true }) + expect(await rig.sendNow(cardId ?? '')).toMatchObject({ ok: true }) + await laneDrained() + const reads = vi.spyOn(journal(), 'snapshot') + + for (let row = 0; row < 3; row += 1) { + await journal().appendItem( + { provider: 'orca', clientMessageId: `streamed-${row}` }, + { kind: 'status', text: `still streaming ${row}` }, + { + fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await laneDrained() + } + + expect(reads.mock.calls.length).toBeLessThanOrEqual(3) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts new file mode 100644 index 00000000000..8750fe909a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -0,0 +1,107 @@ +// A session-ending Stop's second step: the provider's wind-down, then the child's end, and what +// the Stop's note says when that end fails while the work runs on. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { isMainAgentWorking } from './structured-agent-session-turns-cancel' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' +import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { structuredAgentSessionNamedTurnScope } from './structured-agent-session-turn-stop-notes' + +/** What a Stop that ends the provider's session leaves its next serialized step: whether the + * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ +export type StructuredAgentSessionStopWindDown = { + waitsForProvider: boolean + stoppedAt: number + /** The note the Stop wrote, which a failed wind-down revises. */ + stopNote: AgentJournalItemIdentity + /** Closes the Stop's settle once the wind-down finishes, whether or not the child's exit was + * proven, marking the turn running on after an end that failed (`JournalStopSettle.failedOn`): + * until then, what ends is the Stop's. */ + settled?: (failedOn?: JournalStopFailedOn) => void +} + +/** + * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent + * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported, and while + * the work runs on its note is revised to say the Stop went unconfirmed. A close it could not prove + * keeps the child on record, and the next operation that reaches the agent joins that close. + */ +export async function endStoppedStructuredAgentSession( + ctx: Pick, + windDown: StructuredAgentSessionStopWindDown, + stopChild: () => Promise, + onError: (error: unknown) => void +): Promise { + let failedOn: JournalStopFailedOn | undefined + try { + if (windDown.waitsForProvider) { + await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) + } + await stopChild() + } catch (error) { + onError(error) + failedOn = structuredAgentSessionFailedStopMark(ctx.journal) + await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError) + } finally { + windDown.settled?.(failedOn) + } +} + +/** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost + * interrupt's says. Work that ended took the Stop, whatever became of the child. */ +async function reviseStopNoteUnconfirmed( + ctx: Pick, + identity: AgentJournalItemIdentity +): Promise { + if (!isMainAgentWorking(ctx)) { + return + } + const itemId = agentJournalItemKey(identity) + let written: { turnScope?: AgentJournalTurnScope } | undefined + ctx.journal.visitItemsWithLinkage((id, _sequence, _body, linkage) => { + if (id === itemId) { + written = linkage + } + }) + if (written === undefined) { + return + } + const body: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + // The note now speaks of the work running on, so it moves onto that work's turn, keyed by it as a + // Stop of that turn writes it: its proven end finds it there. A row keeps the scope it was created + // with, so a note a Stop wrote before the turn showed is re-keyed, in one batch. Known limit: with + // no turn open yet, the note keeps its key and scope, and no turn's end revises it. + const running = ctx.journal.activeTurnId() + const turnScope = + running !== null ? structuredAgentSessionNamedTurnScope(ctx.journal, running) : null + const onTurn = running !== null ? structuredAgentSessionStopNoteIdentity(running) : identity + if (turnScope && agentJournalItemKey(onTurn) !== itemId) { + await ctx.journal.appendLifecycleBatch({ + settlementId: `stop-note-on-turn:${itemId}`, + fence: ctx.fence, + mutations: [ + { kind: 'tombstone', identity }, + { kind: 'item', identity: onTurn, body, turnScope } + ] + }) + return + } + await ctx.journal.appendItem(identity, body, { + fence: ctx.fence, + turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts new file mode 100644 index 00000000000..ba026d93a0d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts @@ -0,0 +1,151 @@ +// "Stopping…" read straight off a journal: it holds while a person's Stop settles, and then while +// the turn it named or its settle bound still runs, whatever the Stop's answer. It reads only the +// journal's tail to tell. + +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +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 { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalRenderItem, + type AgentJournalStatusItem +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { structuredAgentSessionStopping } from './structured-agent-session-stopping' + +const FENCE = 1 +const journals = createTrackedJournalOpener() +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stopping-journal-')) + journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, 'session-1') + }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function turnIdentity(turnId: string): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 } +} + +function turn(turnId: string, state: 'running' | 'completed') { + return journal.appendItem( + turnIdentity(turnId), + { + kind: 'turn', + turnId, + state, + startedAt: 1, + ...(state === 'running' ? {} : { completedAt: 2 }) + }, + { fence: FENCE, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +const unconfirmed: AgentJournalStatusItem = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) +} + +function stopping(): boolean { + return structuredAgentSessionStopping(journal, journal.snapshot().items) +} + +describe("a person's Stop's Stopping", () => { + // The Stop is the person's whatever the agent answered; only the turn's end clears it. + it('holds through an answer that says the Stop stopped nothing, until the turn ends', async () => { + await turn('turn-1', 'running') + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) + await journal.appendItem(structuredAgentSessionStopNoteIdentity('turn-1'), unconfirmed, { + fence: FENCE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(stopping()).toBe(true) + + await turn('turn-1', 'completed') + + expect(stopping()).toBe(false) + }) + + it('reads only the journal from the running turn on, however long the chat before it', async () => { + for (let index = 0; index < 40; index += 1) { + await journal.appendItem( + { provider: 'orca', clientMessageId: `earlier-${index}` }, + { kind: 'status', text: `earlier row ${index}` }, + { fence: FENCE, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('turn-1', 'running') + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) + const items = journal.snapshot().items + const turnIndex = items.findIndex((item) => item.body.kind === 'turn') + let lowestRead = items.length + const counted: AgentJournalRenderItem[] = [] + items.forEach((item, index) => + Object.defineProperty(counted, index, { + enumerable: true, + get: () => { + lowestRead = Math.min(lowestRead, index) + return item + } + }) + ) + expect(structuredAgentSessionStopping(journal, counted)).toBe(true) + expect(lowestRead).toBe(turnIndex) + }) + + it('holds while a Stop that named no turn settles, and after only for the turn it bound', async () => { + await journal.appendStopEvent({ reason: 'user-stop' }, FENCE) + const settle = journal.stopMarks.beginSettle() + expect(stopping()).toBe(true) + + journal.stopMarks.settled(settle) + expect(stopping()).toBe(false) + await turn('turn-1', 'running') + expect(stopping()).toBe(false) + + journal.stopMarks.settled(journal.stopMarks.beginSettle(), 'turn-1') + expect(stopping()).toBe(true) + }) + + it('tells its settle readers of each edge, which writes no row and so is no commit', async () => { + await journal.appendStopEvent({ reason: 'user-stop' }, FENCE) + const before = journal.stopMarks.revision() + let commits = 0 + let edges = 0 + journal.observeCommits(() => { + commits += 1 + }) + journal.stopMarks.observeSettleEdges(() => { + edges += 1 + }) + const cursor = journal.cursor() + + journal.stopMarks.settled(journal.stopMarks.beginSettle()) + + expect(journal.stopMarks.revision()).toBe(before + 2) + expect(edges).toBe(2) + expect(commits).toBe(0) + expect(journal.cursor()).toEqual(cursor) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts new file mode 100644 index 00000000000..92cd6f57433 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -0,0 +1,594 @@ +// The host's "Stopping…": published on the session's status while a person's Stop is still +// settling, and then while the turn it stopped, or failed to stop, still runs, until that turn ends. +// A Stop that settles having stopped nothing ends it. Driven through the real host and its status +// feed, with turn rows named as Codex writes them. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { + isStructuredAgentSessionStopNote, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + +function turnIdentity(turnId: string): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 } +} + +/** Turn `turnId`, opened by send `clientMessageId`, running or ended as the provider cut it. */ +async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { + await journal().appendItem( + turnIdentity(turnId), + { + kind: 'turn', + turnId, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +/** The session's summaries as a session list receives them. */ +function watchStatus(): () => AgentSessionStatusSummary | undefined { + const events: AgentSessionStatusEvent[] = [] + rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) + return () => { + for (const event of events.toReversed()) { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + return event.session + } + if (event.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) + } + } + return undefined + } +} + +/** The Stop as the phone sends it, naming the turn its journal shows running. */ +function namedStop(turnId: string) { + return rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId }, 'agentSession.cancel', hostTestOperationId()), + turnId + }) +} + +/** What each Stop answered, oldest first. */ +function stopAnswers(): (string | undefined)[] { + return journal() + .snapshot() + .items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) + .map((item) => (item.body.kind === 'status' ? (item.body.failure?.kind ?? 'took') : undefined)) +} + +/** A send whose turn `turn-1` is running, and the session's status. */ +async function runningTurn(options: { stopEndsSession?: true } = {}) { + rig = await createQueuedMessageTestRig(options) + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + return { sent, status } +} + +describe("a person's Stop reads Stopping until the work it stopped ends", () => { + it('reads Stopping once the Stop takes effect, and clears when the turn ends', async () => { + const { sent, status } = await runningTurn() + + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('reads Stopping before the agent answers the interrupt', async () => { + const { status } = await runningTurn() + let answer: (outcome: AgentSessionCancelOutcome) => void = () => undefined + rig.cancelTurn.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + + const stopped = rig.stop() + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + + answer({ cancelled: true }) + expect(await stopped).toMatchObject({ ok: true }) + expect(status()).toMatchObject({ stopping: true }) + }) + + const refused = async (): Promise => ({ cancelled: false }) + const unconfirmed = async (): Promise => { + throw new Error('the interrupt request timed out') + } + it.each([ + ['refused', 'names no turn', refused], + ['left unconfirmed', 'names no turn', unconfirmed], + ['left unconfirmed', 'names the turn', unconfirmed] + ])( + 'keeps Stopping until the turn ends after the agent %s a Stop that %s', + async (_, naming, cancel) => { + const { sent, status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(cancel) + if (cancel === unconfirmed) { + // A failed interrupt ends the agent's process; this one cannot be ended either. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + } + + const stopped = naming === 'names the turn' ? namedStop('turn-1') : rig.stop() + expect(await stopped).toMatchObject({ ok: true }) + + // The answer says it stopped nothing, yet the Stop is still the person's: a repeat escalates. + await eventually(() => expect(stopAnswers()).toHaveLength(1)) + expect(stopAnswers()[0]).not.toBe('took') + expect(status()).toMatchObject({ status: 'working', stopping: true }) + + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + } + ) + + it.each([ + [ + 'refuses', + async (): Promise => ({ cancelled: false, refusal: {} }) + ], + ['never answers', unconfirmed] + ])( + 'ends the agent when it %s a Stop naming the running turn, so Stopping ends with the turn', + async (_, cancel) => { + const { status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(cancel) + + expect(await namedStop('turn-1')).toMatchObject({ ok: true }) + + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + await eventually(() => expect(status()?.status).not.toBe('working')) + expect(status()).not.toHaveProperty('stopping') + } + ) + + it('keeps Stopping through a repeat press of a refused Stop, which writes no second event', async () => { + const { status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(async () => ({ cancelled: false })) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) + const refusedStop = journal().stopMarks.latest() + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(journal().stopMarks.latest()).toEqual(refusedStop) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + }) + + // The newest turn record is the stopped one until the next send's turn opens; it no longer runs. + it.each(['names its turn', 'names no turn'])( + 'reads a send made after a Stop that %s as Working before its turn opens', + async (naming) => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const status = watchStatus() + if (naming === 'names its turn') { + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'running') + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(await namedStop('turn-1')).toMatchObject({ ok: true }) + } else { + // The provider's answer names the turn the send opened, which the Stop waited for. + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-1' }) + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'running') + } + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + await turn('turn-1', stopped, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + await rig.workingSend() + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + } + ) + + it('never marks a turn that opened after the stopped one ended', async () => { + const { sent, status } = await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true }) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + const next = await rig.workingSend() + await rig.settleAccepted(next, 'next') + await turn('turn-2', next, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + +describe('a Stop that failed before the turn it meant to stop opened', () => { + /** Its end, with no verdict of its own, read off the journal. */ + function turnEnd(turnId: string) { + return journal() + .snapshot() + .items.map((item) => item.body) + .find((body) => body.kind === 'turn' && body.turnId === turnId) + } + + async function failedBeforeTheTurn(options: { stopEndsSession?: true; restartable?: true } = {}) { + rig = await createQueuedMessageTestRig(options) + const sent = await rig.workingSend() + const status = watchStatus() + if (!options.stopEndsSession) { + // Codex could not reach a turn still able to open. + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + } + // The process could not be ended either. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + // A Stop that ends its session ends the child on the session's next step. + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + expect(journal().activeTurnId()).toBeNull() + return { sent, status } + } + + it.each([ + ['Codex, refused, then its kill failed', {}], + ['Claude-like, whose kill failed before the echo', { stopEndsSession: true as const }] + ])( + 'reads Stopping through the turn that then opens, whose own end stays its own: %s', + async (_, options) => { + const { sent, status } = await failedBeforeTheTurn(options) + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + expect(status()).not.toHaveProperty('stopping') + expect(turnEnd('turn-1')).toMatchObject({ state: 'interrupted' }) + expect(turnEnd('turn-1')).not.toHaveProperty('outcome') + } + ) + + it("never reads Stopping on a send's turn handed over after it", async () => { + // A later send needs a child again once the failed end is retried. + const { sent, status } = await failedBeforeTheTurn({ restartable: true }) + await rig.settleRejected(sent, 'no turn for this one') + await eventually(() => expect(status()?.status).not.toBe('working')) + + const later = await rig.workingSend() + await rig.settleAccepted(later, 'later') + await turn('turn-2', later, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + +describe("a Stop's settle edge", () => { + // It writes no row: it republishes the status and wakes the handover, and is no activity. + it('republishes Stopping without counting as activity', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const status = watchStatus() + await journal().appendStopEvent({ reason: 'user-stop' }, fence()) + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + const sessions = rig.host.collaboratorsForTests().sessions + const touch = vi.spyOn(sessions, 'touch') + + const settle = journal().stopMarks.beginSettle() + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + journal().stopMarks.settled(settle) + await eventually(() => expect(status()).not.toHaveProperty('stopping')) + + expect(touch).not.toHaveBeenCalled() + }) +}) + +describe('a Stop pressed before its send opened a turn', () => { + /** A Stop pressed before the send's turn showed, which opens while the Stop waits for it. */ + async function stopAsTheTurnOpens(answer: AgentSessionCancelOutcome, killFails?: true) { + rig = await createQueuedMessageTestRig() + if (killFails) { + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + } + const sent = await rig.workingSend() + const status = watchStatus() + rig.cancelTurn.mockImplementationOnce(async () => { + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + return answer + }) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + return { sent, status } + } + + it('ends the turn its interrupt took at the settle, and Stopping with it', async () => { + const { status } = await stopAsTheTurnOpens({ cancelled: true, turnId: 'turn-1' }) + + expect(journal().activeTurnId()).toBeNull() + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('holds Stopping through the turn a Stop that failed could not stop, until that turn ends', async () => { + // The agent refused, and its process could not be ended either. + const { sent, status } = await stopAsTheTurnOpens({ cancelled: false, refusal: {} }, true) + + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + // The Stop never stopped it: its own end, with no verdict, reads as a failure, not theirs. + const ended = journal() + .snapshot() + .items.map((item) => item.body) + .find((body) => body.kind === 'turn' && body.turnId === 'turn-1') + expect(ended).toMatchObject({ state: 'interrupted' }) + expect(ended).not.toHaveProperty('outcome') + }) + + it('reads Stopping while the Stop settles, and Working once it settles having stopped nothing', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + const status = watchStatus() + const answer = Promise.withResolvers() + rig.cancelTurn.mockImplementationOnce(() => answer.promise) + + const stopped = rig.stop() + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + answer.resolve({ cancelled: false }) + expect(await stopped).toMatchObject({ ok: true }) + + // The settle's close writes no row of its own; it still reaches the status. + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('reads a send made after the Stop as Working', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const status = watchStatus() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + const later = await rig.workingSend() + await rig.settleAccepted(later, 'later') + await turn('turn-2', later, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + +describe('a Stop whose provider ends its session', () => { + it("revises the note a Stop pressed before its turn showed wrote, once that turn opened and the child's end failed", async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + const status = watchStatus() + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + kill.reject(new Error('the kill timed out')) + + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + }) + + it("says the Stop went unconfirmed once the child's end fails, and that it took once the next Stop's joined close proves the exit", async () => { + const { status } = await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + // Still the person's Stop while the work runs on: Stop stays enabled for the retry. + expect(status()).toMatchObject({ status: 'working', stopping: true }) + + // The retry's child end is held, so the status can be read while it runs. The retry joins the + // child's close, which runs the stop again since the last one came back unproven. + const retried = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => retried.promise) + const retry = rig.stop() + await eventually(() => expect(rig.closeSession).toHaveBeenCalledTimes(2)) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + retried.resolve(true) + expect(await retry).toMatchObject({ ok: true }) + expect(stopAnswers()).toEqual(['took']) + }) + + it('keeps the note unconfirmed when the next Stop joins a close that fails again', async () => { + const { status } = await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(rig.closeSession).toHaveBeenCalledTimes(2) + expect(stopAnswers()).toEqual(['cancelUnconfirmed']) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + }) + + // The close lives on the child, in memory, and dies with the host. The new host's settlement, + // which ends the turn on a proof of the old owner's death, is what says the Stop took. + async function crashAfterUnconfirmedStop(): Promise { + await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + rig.crashRestartHostProcess() + } + + function turnOneState(): string | undefined { + return journal() + .snapshot() + .items.map((item) => (item.body.kind === 'turn' ? item.body : undefined)) + .find((turn) => turn?.turnId === 'turn-1')?.state + } + + /** What the host's restart reconciliation or recovery writes once a probe finds the old pid gone. */ + async function proveOldOwnerGone(): Promise { + const record = rig.store.getRecord(HOST_TEST_SESSION)! + await rig.store.evictProvenDeadOwner({ + sessionId: HOST_TEST_SESSION, + expectedFence: record.lease.runtimeFence, + probe: { outcome: 'pid-absent' }, + now: Date.now() + }) + } + + it("says the Stop took once the new host proves the old child's exit after a crash", async () => { + await crashAfterUnconfirmedStop() + // Proven before the chat opens, as the restart's reconciliation does on most machines. + await proveOldOwnerGone() + await rig.queuePause() + + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + }) + + it('keeps the note unconfirmed while the old exit stays unverifiable, then says it took once proven', async () => { + await crashAfterUnconfirmedStop() + await rig.queuePause() + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'unverifiable', + notes: ['cancelUnconfirmed'] + }) + + // A later proof naming the old owner, as recovery writes it, revises the open chat. + await proveOldOwnerGone() + await eventually(() => + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + ) + }) + + // A Stop pressed before its turn showed keys its note by itself, with no turn to sit on; once it is + // unconfirmed while that turn runs, the note moves onto the turn, so the turn's proven end finds it. + it('moves a note a Stop wrote before its turn showed onto that turn, which then says the Stop took', async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + kill.reject(new Error('the kill timed out')) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + // A join that fails again neither adds a note nor brings the first one back. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(stopAnswers()).toEqual(['cancelUnconfirmed']) + + // The next Stop's joined close proves the exit. + expect(await rig.stop()).toMatchObject({ ok: true }) + + await eventually(() => expect(stopAnswers()).toEqual(['took'])) + const turnRecord = journal() + .snapshot() + .items.find((item) => item.body.kind === 'turn' && item.body.turnId === 'turn-1') + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + const notes = page.ok + ? page.page.items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) + : [] + // What a client loads: one note, on that turn. + expect(notes.map((item) => [item.itemId, item.turnScope])).toEqual([ + [ + agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')), + { kind: 'turn', turnItemId: turnRecord?.itemId } + ] + ]) + }) + + // The kill timed out, then the agent's process exits on its own: the adapter reports the end of + // the close Orca began, and nothing else asks to stop. + it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { + await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + reason: 'claude session closed', + cause: 'requested-close', + fence: fence(), + acquisitionGeneration: 'generation-1', + observedAt: Date.now() + }) + + await eventually(() => + expect( + rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null + ).toBeNull() + ) + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts new file mode 100644 index 00000000000..284a474b9c6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -0,0 +1,90 @@ +// Whether a chat reads "Stopping…": a person's Stop is still settling, or the turn it stopped, or +// failed to stop, still runs. The host reads it on each journal commit and each settle edge from +// the Stop's event and what its settle bound (`journal-stop-turn-end.ts`), so nothing is stored +// and it clears when that turn ends. A Stop that settled having stopped nothing binds no turn, so +// a later one never reads Stopping. Clients only present it. + +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { stopIsAPersons } from '../agent-session-journal/journal-stop-turn-end' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' + +/** Whether the latest person's Stop is still settling, or is bound to the turn running now: the + * one it named, the one its settle bound, or the one it failed to stop. */ +export function structuredAgentSessionStopping( + journal: Pick, + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +): boolean { + const stop = journal.stopMarks.latest() + if (stop === null || !stopIsAPersons(stop.event.reason)) { + return false + } + if (stop.settle?.settling === true) { + return true + } + const failed = stop.settle?.failedOn + const bound = + stop.event.turnId ?? + stop.settle?.turnId ?? + (failed && 'turnId' in failed ? failed.turnId : undefined) + const live = activeStructuredAgentSessionTurnId(items) + if (bound !== undefined) { + return bound === live + } + if (failed && 'openedAfter' in failed) { + // Only what was in flight when it failed: a send handed over since opens its own turn. + const sentSince = firstSendHandedOverAfter(submissions, failed.openedAfter) + const first = firstTurnOpenedAfter(items, failed.openedAfter, sentSince) + return first === null ? sentSince === Infinity : first === live + } + return false +} + +/** Where the first send handed over after `sequence` was, or Infinity when none was. */ +function firstSendHandedOverAfter( + submissions: readonly AgentJournalSubmission[], + sequence: number +): number { + let first = Infinity + for (const submission of submissions) { + const at = submission.acceptedSequence + if (submission.dispatchState !== 'rejected' && at !== undefined && at > sequence) { + first = Math.min(first, at) + } + } + return first +} + +/** Where a Stop that failed leaves "Stopping…" (display only): the turn running now, or, with + * none, the first that opens after this position. */ +export function structuredAgentSessionFailedStopMark( + journal: Pick +): JournalStopFailedOn { + const live = journal.activeTurnId() + return live !== null ? { turnId: live } : { openedAfter: journal.cursor().sequence } +} + +/** The first turn whose row was created after `sequence` and before `before`, read from the tail + * back to `sequence`. */ +function firstTurnOpenedAfter( + items: readonly AgentJournalRenderItem[], + sequence: number, + before: number +): string | null { + let first: string | null = null + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if (!item || item.sequence <= sequence) { + break + } + const turnId = item.sequence < before ? readAgentJournalTurn(item.body)?.turnId : undefined + first = turnId ?? first + } + return first +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index e6bffe75d60..dcd3e07c4f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -21,6 +21,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { rememberSessionActivity } from './structured-agent-session-activity-retention' +import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void export type AgentSessionSubscribeInput = { @@ -92,6 +93,15 @@ export class AgentSessionSubscribers { const hostNow = this.now() if (input.cursor) { this.deliver(subscriber, input.journal, hostNow, true) + refreshDerivedStopNotes( + { + emit: (target, event) => this.emit(target, event), + isActive: (target) => this.isActive(target) + }, + subscriber, + input.journal, + hostNow + ) } else { const page = readAgentSessionHydrationPage(input.journal, input.fence) this.emit(subscriber, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 904ab8521d3..d8871314342 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -27,6 +27,9 @@ import { } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' +import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -88,41 +91,6 @@ function stopRefusedNote( } } -/** What a Stop that ends the provider's session leaves its next serialized step: whether the - * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went - * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. - * `settled` closes the Stop's settle once the wind-down finishes, whether or not the child's exit - * was proven: until then, what ends is the Stop's. */ -export type StructuredAgentSessionStopWindDown = { - waitsForProvider: boolean - stoppedAt: number - settled?: () => void -} - -/** - * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent - * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. A close - * it could not prove keeps the child on record, and the next operation that reaches the agent - * joins that close. - */ -export async function endStoppedStructuredAgentSession( - ctx: Pick, - windDown: StructuredAgentSessionStopWindDown, - stopChild: () => Promise, - onError: (error: unknown) => void -): Promise { - try { - if (windDown.waitsForProvider) { - await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) - } - await stopChild() - } catch (error) { - onError(error) - } finally { - windDown.settled?.() - } -} - /** A turn the Stop's interrupt took that still reads running: the Stop ends it, once, while it * still binds it. The provider's stream lands as it arrives, so the fold already holds any end it * sent. Bookkeeping: a failure is reported, never the Stop's. */ @@ -152,7 +120,11 @@ async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: stri } /** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ -type StopSettleBinding = { turnId?: string; closedByWindDown?: true } +type StopSettleBinding = { + turnId?: string + failedOn?: JournalStopFailedOn + closedByWindDown?: true +} export async function performCancel( ctx: AgentSessionTurnContext, @@ -167,7 +139,9 @@ export async function performCancel( // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null const binding: StopSettleBinding = {} - const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) + // A wind-down that failed with work running on marks that work's turn, as a failed Stop does. + const close = (failedOn?: JournalStopFailedOn): void => + ctx.journal.stopMarks.settled(settle, binding.turnId, binding.failedOn ?? failedOn) try { return await cancelAndNote(ctx, input, binding, close) } finally { @@ -208,7 +182,7 @@ async function cancelAndNote( ctx: AgentSessionTurnContext, input: PerformCancelInput, binding: StopSettleBinding, - closeSettle: () => void + closeSettle: (failedOn?: JournalStopFailedOn) => void ): Promise> { let cancelled = false let note: AgentJournalStatusItem | null = { @@ -300,13 +274,23 @@ async function cancelAndNote( ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) } } + // Keyed by the turn it stopped, as the provider names it, so another Stop of that turn rewrites + // this row, never adds one. + const noteIdentity = structuredAgentSessionStopNoteIdentity( + stoppedTurn ?? stoppedTurnId ?? input.clientOperationId + ) // A Stop naming a turn that has since ended keeps the session only when the provider declined // it: an interrupt, answered or not, can stop a follow-up whose turn has not opened. if (endsSession && (!namesTurnNotLive || taken !== false)) { // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. binding.closedByWindDown = true - input.endSession?.({ waitsForProvider: taken === true, stoppedAt, settled: closeSettle }) + input.endSession?.({ + waitsForProvider: taken === true, + stoppedAt, + stopNote: noteIdentity, + settled: closeSettle + }) cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { @@ -352,6 +336,9 @@ async function cancelAndNote( } if (cancelled) { binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined + } else if (interruptFailed) { + // A Stop that failed reads "Stopping…" through the turn it could not stop; its end stays its own. + binding.failedOn = structuredAgentSessionFailedStopMark(ctx.journal) } if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { await endStoppedTurnAtSettle(ctx, stoppedTurn) @@ -366,11 +353,6 @@ async function cancelAndNote( (stoppedTurn !== undefined ? structuredAgentSessionNamedTurnScope(ctx.journal, stoppedTurn) : null) ?? turnScope - // Keyed by the turn it stopped, so another Stop of that turn rewrites this row, never adds one. - await ctx.journal.appendItem( - structuredAgentSessionStopNoteIdentity(stoppedTurn ?? stoppedTurnId ?? input.clientOperationId), - note, - { fence: ctx.fence, turnScope: noteScope } - ) + await ctx.journal.appendItem(noteIdentity, note, { fence: ctx.fence, turnScope: noteScope }) return { ok: true, value } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index d3bb13bafa3..56515bbe6c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -11,7 +11,11 @@ import { type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -475,6 +479,8 @@ it('leaves the command to the provider when it takes the Stop, and ends it as ca it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { await attach() + const statuses: AgentSessionStatusEvent[] = [] + state.host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) @@ -484,13 +490,25 @@ it('ends the command by stopping the child at a second Stop the provider never a // The provider takes the interrupt and then never answers it. await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).not.toHaveBeenCalled() + // The chat reads Stopping, yet only the next Stop ends the command: clients keep Stop enabled. + expect(latestSummary(statuses)).toMatchObject({ status: 'working', stopping: true }) await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) expect(state.cancelTurn).toHaveBeenCalledOnce() expect(closeSession).toHaveBeenCalledOnce() expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + expect(latestSummary(statuses)).not.toHaveProperty('stopping') }) +/** The session's newest summary in what a session list received. */ +function latestSummary(events: AgentSessionStatusEvent[]): AgentSessionStatusSummary | undefined { + return events + .flatMap((event) => + event.type === 'status' ? [event.session] : event.type === 'snapshot' ? event.sessions : [] + ) + .findLast((summary) => summary.sessionId === SESSION) +} + it('ends the command by stopping the child when the provider cannot take the Stop (B4)', async () => { await attach() // Codex before it opened the command's turn, or Claude refusing the interrupt. diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index a45b821e465..6c9dfaf4782 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -152,7 +152,10 @@ export async function recoverStructuredRewind( if ( alreadyReplaced && !isDeepStrictEqual( - journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })), + journal.snapshot().items.map(({ itemId }) => ({ + itemId, + body: journal.itemBody(itemId) + })), replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body })) ) ) { diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 841c22ae925..1842d0a8421 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { ALL_RPC_METHODS } from './rpc/methods' +import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist' const MOBILE_DYNAMIC_RPC_METHODS = [ // Why: computed sendRequest method names do not appear as literals in the @@ -100,16 +101,8 @@ function mobileRpcMethods(): string[] { return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort() } -function mobileRpcAllowlist(): Set { - const source = readFileSync( - join(process.cwd(), 'src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts'), - 'utf8' - ) - const allowlist = source.match(/const MOBILE_RPC_METHOD_ALLOWLIST = new Set\(\[([\s\S]*?)\]\)/) - if (!allowlist) { - throw new Error('MOBILE_RPC_METHOD_ALLOWLIST not found') - } - return new Set([...allowlist[1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!)) +function mobileRpcAllowlist(): ReadonlySet { + return MOBILE_RPC_METHOD_ALLOWLIST } function registeredRuntimeMethods(): Set { @@ -176,6 +169,7 @@ describe('mobile RPC allowlist', () => { 'agentSession.history', 'agentSession.subscribe', 'agentSession.unsubscribe', + 'agentSession.subscribeStatus', 'agentSession.hold', 'agentSession.release' ]) 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 227654cb022..a3d11f57ba8 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 @@ -101,10 +101,11 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal. journal: { cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, + stopMarks: { latest: () => null, revision: () => 0 }, snapshot: () => ({ items: STATUS_ITEMS, submissions: [] }) } as unknown as AgentSessionJournal, params: { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts new file mode 100644 index 00000000000..53c0a871703 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts @@ -0,0 +1,30 @@ +/** The structured agent-session methods a phone may call; spread into the mobile allowlist. */ +export const MOBILE_AGENT_SESSION_RPC_METHODS = [ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.reveal', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', + 'agentSession.close', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.setOption', + 'agentSession.handoffStatus', + 'agentSession.options', + 'agentSession.modelCatalog', + 'agentSession.conversationCommand', + 'agentSession.commands', + 'agentSession.history', + 'agentSession.subscribe', + 'agentSession.unsubscribe', + // Every session's status on one stream: the phone's chat reads the host's "Stopping…" from it. + 'agentSession.subscribeStatus', + // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the + // mobile builds that still call them. + 'agentSession.hold', + 'agentSession.release' +] as const diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index d544dc6720b..0b2192751c5 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -1,3 +1,5 @@ +import { MOBILE_AGENT_SESSION_RPC_METHODS } from './runtime-rpc-mobile-agent-session-methods' + export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.list', 'accounts.consumeCodexResetCredit', @@ -212,31 +214,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', 'session.tabs.unsubscribeAll', - 'agentSession.createSupport', - 'agentSession.create', - 'agentSession.ensure', - 'agentSession.reveal', - 'agentSession.send', - 'agentSession.cancel', - 'agentSession.queuedMessageSend', - 'agentSession.queuedMessageDelete', - 'agentSession.queuedMessagesResume', - 'agentSession.close', - 'agentSession.respondToApproval', - 'agentSession.respondToQuestion', - 'agentSession.setOption', - 'agentSession.handoffStatus', - 'agentSession.options', - 'agentSession.modelCatalog', - 'agentSession.conversationCommand', - 'agentSession.commands', - 'agentSession.history', - 'agentSession.subscribe', - 'agentSession.unsubscribe', - // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the - // mobile builds that still call them. - 'agentSession.hold', - 'agentSession.release', + ...MOBILE_AGENT_SESSION_RPC_METHODS, 'nativeChat.readSession', 'nativeChat.subscribe', 'nativeChat.unsubscribe', diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 9116bbb6605..3b30f932a1e 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -82,6 +82,16 @@ beforeEach(() => { }) describe('worktree ps reports structured sessions', () => { + it("lists a person's Stop still ending the turn, and drops it once the host does", () => { + expect(attach([summary({ stopping: true })]).agents[0]).toMatchObject({ + state: 'working', + mainAgent: { state: 'working', stopping: true } + }) + const ended = attach([summary({ stopping: true }), summary({ updatedAt: 1_757_030_401_000 })]) + expect(ended.agents[0]?.state).toBe('working') + expect(ended.agents[0]?.mainAgent).not.toHaveProperty('stopping') + }) + it('a busy structured session is not reported idle', () => { const row = attach([summary()]) expect(row.agents).toHaveLength(1) diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index de09e72b7e5..9c592779757 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -13,9 +13,11 @@ import type { CodexAppServerConnectionHandlers, openCodexAppServerConnection } from '../codex/codex-app-server-connection' +import { readCodexTurnId } from '../codex/codex-structured-thread-facts' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { HOST_TEST_SESSION as SESSION, @@ -52,7 +54,13 @@ let handlers: CodexAppServerConnectionHandlers | undefined let answers: number let interrupts: number let turns: ReturnType +let statuses: AgentSessionStatusSummary[] let opened: Set +let steers: number +/** The turn each turn/start answered into, in order. */ +let startedTurns: string[] +/** When set, Codex answers the interrupt only once it resolves. */ +let interruptAnswer: Promise | null let operations = 0 /** The durable ledger stamps its own clock and refuses an id far from it. */ @@ -110,7 +118,8 @@ function turnEndRows(): AgentJournalTurnItem[] { }) } -/** The Stop has answered with Codex's end still to come; then Codex ends the turn. */ +/** The Stop has answered with Codex's end still to come; then Codex ends the turn. Stopping + * showed while the Stop settled, ended with the turn, and no status read the turn as failed. */ async function expectInterruptedThroughout(): Promise { expect(interrupts).toBe(1) expect(turnEndRows()).toEqual([ @@ -125,6 +134,15 @@ async function expectInterruptedThroughout(): Promise { for (const end of ends) { expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) } + await vi.waitFor(() => + expect(statuses.at(-1)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + ) + expect(statuses.some((summary) => summary.stopping === true)).toBe(true) + expect(statuses.at(-1)).not.toHaveProperty('stopping') + const otherVerdicts = statuses.filter( + (summary) => summary.turnOutcome !== undefined && summary.turnOutcome !== 'cancellation' + ) + expect(otherVerdicts).toEqual([]) } async function owesWork(): Promise { @@ -137,6 +155,9 @@ beforeEach(async () => { answers = 0 interrupts = 0 opened = new Set() + steers = 0 + startedTurns = [] + interruptAnswer = null turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { if (method === 'turn/started') { opened.add(turns.turnId ?? '') @@ -151,7 +172,7 @@ beforeEach(async () => { const connection: CodexAppServerConnection = { pid: 4321, closed: false, - request: async (method) => { + request: async (method, params) => { if (method === 'thread/start' || method === 'thread/resume') { return { thread: { id: THREAD } } } @@ -160,7 +181,13 @@ beforeEach(async () => { } if (method === 'turn/start') { answers += 1 - return turns.routes['turn/start']() + const answer = await turns.routes['turn/start']() + startedTurns.push(readCodexTurnId(answer) ?? '') + return answer + } + if (method === 'turn/steer') { + steers += 1 + return turns.routes['turn/steer'](params) } if (method === 'turn/interrupt') { // Taken: answered now; the test sends the turn's end once the Stop has answered. A turn @@ -169,6 +196,9 @@ beforeEach(async () => { if (turns.turnId !== null && !opened.has(turns.turnId)) { turns.start() } + await interruptAnswer + // Codex drops the turn as its active one before it answers; turn/completed comes later. + turns.takeInterrupt() return {} } return {} @@ -199,6 +229,15 @@ beforeEach(async () => { throw new Error(JSON.stringify(attached.refusal)) } fence = attached.value.fence + statuses = [] + host.subscribeStatus({ + id: 'interrupt-order', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + statuses.push(event.session) + } + } + }) }) afterEach(async () => { @@ -236,3 +275,38 @@ describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () await expectInterruptedThroughout() }) }) + +// A message sent while the Stop ends the turn goes out once that turn has ended. Codex answered the +// interrupt, so the turn has aborted even while its turn/completed is still on the wire: the +// message opens its own turn, never a steer Codex would refuse. +describe('a send behind a Codex Stop answered before its turn ends', () => { + it('opens its own turn with turn/start, never turn/steer', async () => { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + const answer = Promise.withResolvers() + interruptAnswer = answer.promise + const stopping = stop() + await vi.waitFor(() => expect(interrupts).toBe(1)) + + // Accepted behind the Stop on the session's lane, so it reaches Codex only once the Stop has + // answered and the turn reads ended, while Codex's turn/completed is still to come. + const sent = send('run this after the stop') + await vi.waitFor(() => expect(statuses.some((summary) => summary.stopping === true)).toBe(true)) + expect(answers).toBe(1) + answer.resolve() + await stopping + await sent + + await vi.waitFor(() => expect(answers).toBe(2)) + expect(steers).toBe(0) + // Its own turn, not folded into the aborted one, and the message is not lost. + expect(startedTurns).toEqual(['turn-1', 'turn-2']) + turns.end('interrupted') + const clientMessageId = await sent + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + expect(submission?.dispatchState).not.toBe('rejected') + }) +}) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 5ac74802797..0631251d849 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -380,6 +380,23 @@ describe('DashboardAgentRow', () => { expect(activeToolMarkup).toContain('ListDir') }) + it("says Stopping in place of the tool line while a person's Stop ends the turn", () => { + const markup = renderRow( + makeAgent( + {}, + { + toolName: 'Bash', + toolInput: 'pnpm test', + mainAgent: { state: 'working', stopping: true, stateStartedAt: 60_000 } + } + ) + ) + + expect(markup).toContain('Stopping…') + expect(markup).not.toContain('lucide-wrench') + expect(markup).toContain('data-agent-spinner') + }) + it('renders monitoring without a spinner or active tool line', () => { const markup = renderRow( makeAgent( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 2cacdfb5474..5770ab66cdf 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -8,6 +8,7 @@ import { DashboardAgentRowMessage } from './DashboardAgentRowMessage' import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls' import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' import { agentRowDisplayDotState, agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' import { @@ -151,7 +152,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // Why: 'working' names the running tool and 'waiting' names what an approval is blocked on; // anywhere else a leftover tool line reads as still-running. See showsAgentToolPreview. // Monitoring is excluded too: the lead turn is over, so its last tool line is stale. - const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring + const stoppingLabel = agentRowStoppingLabel(agent.entry, agent.state) + const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring && stoppingLabel === null const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' // Why: a child row's message line is the model's, so a child that ended without an outcome says so. @@ -314,6 +316,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ reservesHeight={isWorking} toolName={toolName} toolInput={toolInput} + statusLabel={stoppingLabel} /> + {statusLabel} + + ) + } if (!showsTool || (!toolName && !reservesHeight)) { return null } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 7aa3c8ebf1c..853309887d8 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -59,6 +59,8 @@ const NativeChatComposerPane = forwardRef cleanup()) describe('NativeChatComposerActions', () => { + it("disables the Stop control and names it Stopping while a person's Stop is ending the turn", () => { + const props = { + attachDisabled: false, + dictationDisabled: false, + isWorking: true, + isDictating: false, + isDictationHoldMode: false, + onAttach: vi.fn(), + onDictationToggle: vi.fn(), + onDictationHoldStart: vi.fn(), + onDictationHoldEnd: vi.fn(), + onSend: vi.fn(), + sessionOptionsSurface: null, + sessionOptionsSnapshot: [] + } + const { rerender } = render() + expect(screen.getByRole('button', { name: 'Stop the agent' })).toBeTruthy() + + rerender() + + expect(screen.getByRole('button', { name: 'Stopping…' }).hasAttribute('disabled')).toBe(true) + }) + it('places session option pickers immediately beside dictation', () => { render( void @@ -45,6 +47,7 @@ export function NativeChatComposerActions({ sendBlockedReason, draftNotSaved, isWorking, + isStopping = false, isDictating, isDictationHoldMode, onAttach, @@ -81,10 +84,12 @@ export function NativeChatComposerActions({ data-native-chat-critical-action={isWorking ? 'stop' : undefined} aria-label={ isWorking - ? translate('components.native-chat.stop', 'Stop the agent') + ? isStopping + ? translate('components.native-chat.status.stopping', 'Stopping…') + : translate('components.native-chat.stop', 'Stop the agent') : (sendReason ?? translate('components.native-chat.composer.send', 'Send')) } - disabled={sendDisabled} + disabled={sendDisabled || (isWorking && isStopping)} onClick={handleCriticalAction} variant={isWorking ? 'secondary' : 'default'} size="icon" diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 5296dd6c43d..c67af9fbedc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -10,7 +10,10 @@ import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-c import { NativeChatMentionHint, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { NativeChatComposerActions } from './NativeChatComposerActions' import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary' -import { nativeChatComposerPlaceholder } from './native-chat-composer-target' +import { + nativeChatComposerPlaceholder, + type NativeChatAfterStopSend +} from './native-chat-composer-target' import type { SessionOptionDescriptor, SessionOptionsSurface @@ -40,6 +43,11 @@ export type NativeChatComposerFieldProps = { /** Why the send button is disabled, when the user can do something about it. */ sendBlockedReason?: string | null isWorking: boolean + /** This client's Stop request is in flight: the Stop control is disabled and says so. */ + isStopping?: boolean + /** The chat reads Stopping: the placeholder says a message runs after the stop, queued as a + * card where the host holds sends as cards (`queue`), else sent and held by the host (`send`). */ + afterStop?: NativeChatAfterStopSend attachDisabled: boolean dictationDisabled: boolean isDictating: boolean @@ -118,6 +126,8 @@ export function NativeChatComposerField({ sendButtonDisabled, sendBlockedReason, isWorking, + isStopping = false, + afterStop, attachDisabled, dictationDisabled, isDictating, @@ -277,7 +287,7 @@ export function NativeChatComposerField({ 'components.native-chat.goal.placeholder', 'Describe your goal, define measurable outcomes for best results' ) - : nativeChatComposerPlaceholder(hasPty, canSend) + : nativeChatComposerPlaceholder(hasPty, canSend, afterStop) } // Why: coarse-pointer min-height follows the app's touch target convention. // Editable content grows naturally; the 8lh cap (plus @@ -298,6 +308,7 @@ export function NativeChatComposerField({ sendBlockedReason={sendBlockedReason} draftNotSaved={draftNotSaved} isWorking={isWorking} + isStopping={isStopping} isDictating={isDictating} isDictationHoldMode={isDictationHoldMode} onAttach={onAttach} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx new file mode 100644 index 00000000000..65e7f530396 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' + +const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } + +function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const session: NativeChatLiveSession = { + messages: [ + { + id: 'user-stop', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: Date.now(), + source: 'transcript' + } + ], + status: 'working', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' +} + +// The turn still runs while a Stop ends it, so its bar keeps the running clock; only the tail line +// says the turn is stopping, and the bar settles to "Interrupted after" once the turn ends. +describe("the turn bar while a person's Stop ends the turn", () => { + it('keeps the running clock above the Stopping tail line', () => { + render( + + ) + + const bar = screen.getByText('Working for 0s') + const stopping = screen.getByText('Stopping…') + expect(bar.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getAllByText(/Stopping/)).toHaveLength(1) + }) +}) + +// Where the host does not queue sends, one made while Stopping is held until the turn ends: it is +// drawn after the Stopping line, not inside the turn being stopped. +describe('a message sent while Stopping, before the host has handed it over', () => { + const pending = { + id: 'pending-send', + role: 'user' as const, + sentWhileStopping: true as const, + blocks: [{ type: 'text' as const, text: 'Run this after the stop' }], + timestamp: Date.now(), + source: 'transcript' as const + } + + function renderList(stopping: boolean): void { + render( + + ) + } + + it('draws after the Stopping line', () => { + renderList(true) + + const stopping = screen.getByText('Stopping…') + const sent = screen.getByText('Run this after the stop') + expect(stopping.compareDocumentPosition(sent)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + it('stays in the turn while nothing is stopping', () => { + renderList(false) + + const sent = screen.getByText('Run this after the stop') + const working = screen.getByText('Working…') + expect(sent.compareDocumentPosition(working)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + // Sent just before the Stop, the host steers it into the turn: it stays there, never jumping. + it('keeps a send made before the Stop in the turn', () => { + const { sentWhileStopping: _made, ...beforeStop } = pending + render( + + ) + + const sent = screen.getByText('Run this after the stop') + const stopping = screen.getByText('Stopping…') + expect(sent.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index a442d18ddf2..594c289882f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -83,6 +83,7 @@ export function NativeChatMessageList({ deliveryNotices, awaitingInput = null, turnActivity, + stopping = false, runtimeContext }: { session: NativeChatLiveSession @@ -109,6 +110,8 @@ export function NativeChatMessageList({ /** Set while the turn waits on the reader; the live activity line yields to it. */ awaitingInput?: NativeChatAwaitingInput | null turnActivity?: NativeChatTurnActivity | null + /** A person's Stop is ending the live turn: its tail line reads "Stopping…". */ + stopping?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { const [navigationRequest, setNavigationRequest] = useState( @@ -202,13 +205,14 @@ export function NativeChatMessageList({ line: { draws: tailRow === 'activity', thinking: turnStatuses.active?.thinking === true, + stopping, activityText: turnActivity?.text } }) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( - () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems), - [allSlots, journalItems] + () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems, stopping), + [allSlots, journalItems, stopping] ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index a5471b1318d..47d9440955d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -115,6 +115,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, showsSteerShortcut, + steerHeld = false, onSteer, onDelete, onEdit, @@ -123,6 +124,8 @@ export function NativeChatQueuedMessageCard({ card: QueuedMessageCard /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean + /** The chat reads Stopping: the card waits for the stop (`NativeChatQueuedMessageList`). */ + steerHeld?: boolean onSteer: () => void onDelete: () => void onEdit: () => void @@ -162,7 +165,7 @@ export function NativeChatQueuedMessageCard({ - diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 84d6dc4480a..e9b60f0da27 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -118,6 +118,21 @@ describe('NativeChatQueuedMessageList', () => { expect(owner.remove).toHaveBeenCalledWith('draft-2') }) + it("holds every card's Steer while a person's Stop ends the turn; Delete still works", () => { + const owner = controller([card({ messageId: 'draft-1', position: 1 })]) + render( + + + + ) + const steer = screen.getByRole('button', { name: 'Steer' }) + expect(steer).toHaveProperty('disabled', true) + fireEvent.click(steer) + expect(owner.steer).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(owner.remove).toHaveBeenCalledWith('draft-1') + }) + it.each(['Delete', 'Steer'])( '%s hands focus to the composer once the focused card is gone', async (name) => { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index b0960a4410b..4c236d87284 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -13,9 +13,12 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, + steerHeld = false, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController + /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ + steerHeld?: boolean /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ focusComposer?: () => void }): React.JSX.Element { @@ -65,6 +68,7 @@ export function NativeChatQueuedMessageList({ key={card.messageId} card={card} showsSteerShortcut={controller.queueCapable && card === newest} + steerHeld={steerHeld} onSteer={() => refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx new file mode 100644 index 00000000000..7d5437b2e67 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) +const hostStatus = vi.hoisted((): { emit: ((event: AgentSessionStatusEvent) => void) | null } => ({ + emit: null +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + ...moduleFactories.structuredAgentSessionClient(), + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + onEvent: (event: AgentSessionStatusEvent) => void + ) => { + hostStatus.emit = onEvent + return { unsubscribe: () => {} } + } +})) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +function renderPane(): void { + render( + + + + ) +} + +/** The host's summary for the pane's session, as its status feed publishes it. */ +function hostSays(stopping: boolean): void { + act(() => + hostStatus.emit?.({ + type: 'status', + session: { + sessionId: 'session-1', + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + latestPrompt: 'work on this', + updatedAt: Date.now(), + ...(stopping ? { stopping: true as const } : {}) + } + }) + ) +} + +describe("the chat pane while a person's Stop ends the turn", () => { + afterEach(() => { + hostSays(false) + cleanup() + resetStructuredSessionMocks() + }) + + it("reads Stopping from the host's word and keeps Stop for the repeat that escalates", async () => { + mocks.turnId = 'turn-1' + mocks.isWorking = true + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + expect(mocks.composerProps).toMatchObject({ isWorking: true, isStopping: false }) + + hostSays(true) + + // A Stop the provider took and never answered (a Codex command) ends only at a second Stop. + await waitFor(() => expect(mocks.messageListProps).toMatchObject({ stopping: true })) + // The queue is dark here: the message is sent, and the host holds it until the stop lands. + expect(mocks.composerProps).toMatchObject({ isStopping: false, afterStop: 'send' }) + // Nothing steers into a turn a Stop is ending. + expect(mocks.composerProps?.steerQueued).toBeUndefined() + mocks.composerProps?.onStop?.() + expect(mocks.stop).toHaveBeenCalledOnce() + }) + + it('says a message is queued to run after the stop only where the host queues sends', async () => { + mocks.turnId = 'turn-1' + mocks.sendsQueue = true + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + + hostSays(true) + + await waitFor(() => expect(mocks.composerProps).toMatchObject({ afterStop: 'queue' })) + }) + + it("holds a queued card's Steer while the host says Stopping", async () => { + mocks.turnId = 'turn-1' + mocks.queuedCards = [ + { messageId: 'draft-1', position: 1, text: 'one more thing', state: 'waiting', hold: 'turn' } + ] + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + const steer = (): HTMLElement | null => + document.querySelector('[data-queued-message-id="draft-1"] button') + expect(steer()).toHaveProperty('disabled', false) + + hostSays(true) + + await waitFor(() => expect(steer()).toHaveProperty('disabled', true)) + }) + + it('reads Stopping from its own press, and holds Stop until that request answers', () => { + mocks.turnId = 'turn-1' + mocks.stopPressed = true + renderPane() + + expect(mocks.composerProps).toMatchObject({ isStopping: true }) + expect(mocks.messageListProps).toMatchObject({ stopping: true }) + mocks.composerProps?.onStop?.() + expect(mocks.stop).not.toHaveBeenCalled() + }) + + it('stops normally while nothing is stopping', () => { + mocks.turnId = 'turn-1' + renderPane() + + expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.composerProps?.afterStop).toBeUndefined() + expect(mocks.composerProps?.steerQueued).toBe(mocks.queuedSteerNewest) + mocks.composerProps?.onStop?.() + expect(mocks.stop).toHaveBeenCalledOnce() + }) + + it('reads nothing once the chat has nothing left to stop', async () => { + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + + hostSays(true) + + expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.messageListProps).toMatchObject({ stopping: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index cf990808189..66127bd2347 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -99,6 +99,7 @@ type StructuredSessionMessageListProps = { onLinkClick?: (...args: unknown[]) => void awaitingInput?: 'shown' | 'unshown' | null isWorking?: boolean + stopping?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } deliveryNotices?: ReadonlyMap @@ -139,6 +140,9 @@ export function createStructuredSessionMocks() { launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean + isStopping?: boolean + afterStop?: 'queue' | 'send' + steerQueued?: () => boolean onStop?: () => void }>(), approvalCardProps: initialApprovalCardProps, @@ -157,6 +161,8 @@ export function createStructuredSessionMocks() { turnId: null as string | null, // Unset: Stop follows the turn, as against an older host. canStop: nullable(), + stopPressed: false, + sendsQueue: false, supportsBackgroundTaskStop: false, supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], @@ -247,6 +253,8 @@ export function createStructuredSessionMocks() { epoch: 'epoch-1', rewind: { surface: undefined }, canStop: mocks.canStop ?? mocks.turnId !== null, + stopPressed: mocks.stopPressed, + sendsQueue: mocks.sendsQueue, stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, @@ -385,6 +393,8 @@ export function createStructuredSessionMocks() { mocks.isWorking = false mocks.turnId = null mocks.canStop = null + mocks.stopPressed = false + mocks.sendsQueue = false mocks.supportsBackgroundTaskStop = false mocks.supportsBackgroundTaskStopAll = true mocks.stopBackgroundTask.mockReset() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6990ebad88d..dae1e61a27f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -28,10 +28,12 @@ import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSession import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge' +import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' import { useNativeChatRewindHost } from './use-native-chat-rewind-host' import { NativeChatRewindContext } from './native-chat-rewind-context' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' +import { nativeChatStructuredStopControls } from './native-chat-structured-stop-controls' +import { chatApprovalFromJournal } from './native-chat-interactive-prompt' import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' @@ -47,7 +49,7 @@ export function NativeChatStructuredSession( const provisionalLaunch = useNativeChatProvisionalLaunch(ownerWorktreeId, props.sessionId) const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. - const startupPhase = useStructuredAgentSessionHostExecutionPhase(props.sessionId, props.target) + const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) const paneKey = useMemo( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] @@ -61,11 +63,13 @@ export function NativeChatStructuredSession( // Why: Stop and a queued card's Edit give text back to the conversation's draft, as the composer keeps it. composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, - providerStarting: startupPhase === 'starting', + hostStopping: hostExecution.stopping, + providerStarting: hostExecution.phase === 'starting', rewind: rewindHost, transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) + const stopControls = nativeChatStructuredStopControls(controller, hostExecution.stopping) const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, agent: props.agent, @@ -155,23 +159,7 @@ export function NativeChatStructuredSession( // cancel then works. const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally - const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null - const approval = approvalBody - ? { - title: approvalBody.title, - ...(approvalBody.displayName ? { displayName: approvalBody.displayName } : {}), - ...(approvalBody.description ? { description: approvalBody.description } : {}), - ...(approvalBody.decisionReason ? { decisionReason: approvalBody.decisionReason } : {}), - ...(approvalBody.blockedPath ? { blockedPath: approvalBody.blockedPath } : {}), - ...(approvalBody.matchedAskRule ? { matchedAskRule: approvalBody.matchedAskRule } : {}), - ...(approvalBody.subject ? { subject: approvalBody.subject } : {}), - ...(approvalBody.detail ? { detail: approvalBody.detail } : {}), - options: approvalBody.options.map((option) => ({ - label: option.label, - send: option.id - })) - } - : null + const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null const cancelPrompt = () => { if (controller.turnId && prompt) { void controller.cancel(controller.turnId, { @@ -293,6 +281,7 @@ export function NativeChatStructuredSession( settledTurns={controller.settledTurns} awaitingInput={prompt === null ? null : 'shown'} turnActivity={controller.turnActivity} + stopping={stopControls.stopping} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} runtimeContext={imageRuntimeContext} @@ -312,6 +301,7 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} void controller.stop()} - steerQueued={controller.queuedMessages.steerNewest} + {...stopControls.composer} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx new file mode 100644 index 00000000000..9c0b2657cf8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatLiveLine } from '../../../../shared/native-chat-live-line' +import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' + +afterEach(() => cleanup()) + +describe('NativeChatTurnActivityLine', () => { + it("reads Stopping over the provider's activity while a person's Stop ends the turn", () => { + const line: NativeChatLiveLine = { + thinking: false, + stopping: false, + activityText: 'Running pnpm test', + reasoning: null + } + const { rerender } = render() + expect(screen.getByText('Running pnpm test')).toBeTruthy() + + rerender() + + expect(screen.getByText('Stopping…')).toBeTruthy() + expect(screen.queryByText('Running pnpm test')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index 4a3d62a2f8f..b8fe66479be 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -5,15 +5,31 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { translate } from '@/i18n/i18n' import type { NativeChatLiveLine } from '../../../../shared/native-chat-live-line' import { nativeChatReasoningDisclosureKey } from '../../../../shared/native-chat-reasoning-row' -import { describeNativeChatActiveTurnLabel } from '../../../../shared/native-chat-turn-status' +import { + describeNativeChatActiveTurnLabel, + type NativeChatActiveTurnLabel +} from '../../../../shared/native-chat-turn-status' import { NativeChatReasoningBody, NativeChatReasoningChevron } from './NativeChatReasoningDisclosure' import { useNativeChatDisclosure } from './native-chat-disclosure-store' +// Literal keys with literal fallbacks: a dynamic key registers no catalog reference. +function statusLabel(key: Extract['key']): string { + switch (key) { + case 'thinking': + return translate('components.native-chat.status.thinking', 'Thinking') + case 'stopping': + return translate('components.native-chat.status.stopping', 'Stopping…') + case 'working': + return translate('components.native-chat.status.working', 'Working…') + } +} + /** The live turn's tail line: a spinner plus what the turn is doing right now — - * the provider's activity text, else that it is reasoning, else plain "Working…". + * "Stopping…" once the person's Stop is ending it, else the provider's activity text, else that + * it is reasoning, else plain "Working…". * The clock lives in the turn bar under the user's message, not here. While the * agent's open reasoning block has text, the line is that block's disclosure. */ export function NativeChatTurnActivityLine({ @@ -26,12 +42,7 @@ export function NativeChatTurnActivityLine({ allowFileUriLinks?: boolean }): React.JSX.Element { const resolved = describeNativeChatActiveTurnLabel(line) - const label = - resolved.source === 'activity' - ? resolved.text - : resolved.key === 'thinking' - ? translate('components.native-chat.status.thinking', 'Thinking') - : translate('components.native-chat.status.working', 'Working…') + const label = resolved.source === 'activity' ? resolved.text : statusLabel(resolved.key) const { reasoning } = line // The finished row reads this key too, so a block opened here lands open once it ends. const disclosure = useNativeChatDisclosure( diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 1c60187910c..981d287cb68 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -66,7 +66,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { getStructuredAgentSessionTabs, StructuredAgentSessionStatusBridge, - useStructuredAgentSessionHostExecutionPhase + useStructuredAgentSessionHostExecutionPhase, + useStructuredAgentSessionHostStopping } from './StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' @@ -747,6 +748,29 @@ describe('StructuredAgentSessionStatusBridge', () => { act(() => feed().emit({ type: 'status', session: summary({ hostExecutionPhase: 'starting' }) })) expect(phases.at(-1)).toBe('starting') }) + + it('re-renders a Stopping reader only when the host starts or stops saying so', async () => { + const stops: boolean[] = [] + function StoppingProbe(): null { + stops.push(useStructuredAgentSessionHostStopping('session-1', { kind: 'local' })) + return null + } + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => feed().emit({ type: 'status', session: summary({ stopping: true }) })) + expect(stops.at(-1)).toBe(true) + const rendersWhileStopping = stops.length + act(() => + feed().emit({ + type: 'status', + session: summary({ stopping: true, latestPrompt: 'next', updatedAt: 2 }) + }) + ) + expect(stops).toHaveLength(rendersWhileStopping) + act(() => feed().emit({ type: 'status', session: summary({}) })) + expect(stops.at(-1)).toBe(false) + }) }) describe('the main agent fact the bridge writes', () => { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index f711fff8044..56e93c60e60 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -77,6 +77,31 @@ export function useStructuredAgentSessionHostExecutionPhase( ) } +/** Whether the host says a person's Stop is still ending the work; an older host never does. */ +export function useStructuredAgentSessionHostStopping( + sessionId: string, + target: RuntimeClientTarget +): boolean { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => feed.getSnapshot().get(sessionId)?.stopping === true, + () => false + ) +} + +/** The host's startup phase and its word on a Stop, each re-rendering the chat only on a change. */ +export function useStructuredAgentSessionHostExecution( + sessionId: string, + target: RuntimeClientTarget +): { phase: ReturnType; stopping: boolean } { + return { + phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target), + stopping: useStructuredAgentSessionHostStopping(sessionId, target) + } +} + /** Only the host's rewind recovery latch, so a chat re-renders when that changes, not on every status. */ export function useStructuredAgentSessionRewindBlockedReason( sessionId: string, @@ -173,7 +198,8 @@ function projectStatus( const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, childWork: children ?? summary.backgroundTasks, - turnOutcome: summary.turnOutcome + turnOutcome: summary.turnOutcome, + ...(summary.stopping ? { stopping: summary.stopping } : {}) }) const current = store.agentStatusByPaneKey?.[paneKey] // Same continuity rule as the host ingest, on the main agent's own clock. diff --git a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts new file mode 100644 index 00000000000..0fbb8a9cec7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) + +import { nativeChatComposerPlaceholder } from './native-chat-composer-target' + +describe("the composer's placeholder", () => { + it('says a message runs after the stop while the chat reads Stopping', () => { + expect(nativeChatComposerPlaceholder(true, true, 'queue')).toBe( + 'Queue a message to run after the stop' + ) + // Where the host does not queue sends, it holds the message until the stop lands. + expect(nativeChatComposerPlaceholder(true, true, 'send')).toBe( + 'Send a message to run after the stop' + ) + }) + + it('reads as usual otherwise', () => { + expect(nativeChatComposerPlaceholder(true, true)).toBe('Send a message…') + }) + + // A held input or a lost terminal says why first: nothing can be queued from here then. + it('keeps the reasons nothing can be sent ahead of it', () => { + expect(nativeChatComposerPlaceholder(true, false, 'queue')).toBe( + 'Input is held by another device.' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index c88882f7ced..ff4e64bc6ab 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' @@ -11,7 +12,15 @@ export type NativeChatResolvedTarget = { * pathological clipboard can't stall the round-trip. */ export const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024 -export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string { +/** How a message sent while the chat reads Stopping goes out: as a queued card, or sent for the + * host to hold until the stop lands. */ +export type NativeChatAfterStopSend = 'queue' | 'send' + +export function nativeChatComposerPlaceholder( + hasPty: boolean, + canSend: boolean, + afterStop?: NativeChatAfterStopSend +): string { if (!hasPty) { return translate( 'components.native-chat.composer.noPty', @@ -21,6 +30,18 @@ export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean) if (!canSend) { return translate('components.native-chat.composer.locked', 'Input is held by another device.') } + if (afterStop === 'queue') { + return translate( + 'components.native-chat.status.queueAfterStop', + NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop + ) + } + if (afterStop === 'send') { + return translate( + 'components.native-chat.status.sendAfterStop', + NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop + ) + } return translate('components.native-chat.composer.placeholder', 'Send a message…') } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 3f05435679e..caaed691637 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -9,6 +9,7 @@ import type { } from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import type { NativeChatAfterStopSend } from './native-chat-composer-target' import type { NativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' export type NativeChatOptionPickerRequest = { @@ -61,6 +62,11 @@ export type NativeChatComposerProps = { canSend?: boolean /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ isWorking?: boolean + /** This client's Stop request is in flight: the Stop control is disabled and says so. */ + isStopping?: boolean + /** The chat reads Stopping: the placeholder says a message runs after the stop, queued as a + * card where the host holds sends as cards (`queue`), else sent and held by the host (`send`). */ + afterStop?: NativeChatAfterStopSend /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 6454dd8257b..ca17ac65bb1 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,6 +1,7 @@ import { nativeChatApprovalAcceptKey } from '../../../../shared/native-chat-agent-support' import { translate } from '@/i18n/i18n' import type { + AgentJournalApprovalItem, AgentJournalApprovalMatchedAskRule, AgentJournalApprovalSubject } from '../../../../shared/agent-session-journal-types' @@ -46,6 +47,21 @@ export type ChatApproval = { options: { label: string; send: string }[] } +/** A journal approval prompt as its card reads it; each option sends its id. */ +export function chatApprovalFromJournal(body: AgentJournalApprovalItem): ChatApproval { + return { + title: body.title, + ...(body.displayName ? { displayName: body.displayName } : {}), + ...(body.description ? { description: body.description } : {}), + ...(body.decisionReason ? { decisionReason: body.decisionReason } : {}), + ...(body.blockedPath ? { blockedPath: body.blockedPath } : {}), + ...(body.matchedAskRule ? { matchedAskRule: body.matchedAskRule } : {}), + ...(body.subject ? { subject: body.subject } : {}), + ...(body.detail ? { detail: body.detail } : {}), + options: body.options.map((option) => ({ label: option.label, send: option.id })) + } +} + export type InteractivePromptCard = | { kind: 'question'; prompt: AskPrompt } | { kind: 'approval'; approval: ChatApproval } diff --git a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts new file mode 100644 index 00000000000..423e114647f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts @@ -0,0 +1,44 @@ +import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' +import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { NativeChatAfterStopSend } from './native-chat-composer-target' + +type StopController = Pick< + ReturnType, + 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' | 'sendsQueue' +> + +/** + * The chat pane's Stop state. It reads "Stopping…" from the host's word, bridged by this client's + * own press until its Stop event lands. Only that press in flight holds Stop, since a repeat is how + * a stuck stop escalates. While it reads Stopping nothing steers into the turn: a message sent then + * runs after it. + */ +export function nativeChatStructuredStopControls( + controller: StopController, + hostStopping: boolean +): { + stopping: boolean + composer: { + isStopping: boolean + onStop: () => void + steerQueued: (() => boolean) | undefined + afterStop: NativeChatAfterStopSend | undefined + } +} { + const stopping = + agentStopDisplayStatus({ + working: controller.canStop, + hostStopping, + stopPressed: controller.stopPressed + }) === 'stopping' + const stopInFlight = controller.canStop && controller.stopPressed + return { + stopping, + composer: { + isStopping: stopInFlight, + onStop: () => void (stopInFlight || controller.stop()), + steerQueued: stopping ? undefined : controller.queuedMessages.steerNewest, + afterStop: stopping ? (controller.sendsQueue ? 'queue' : 'send') : undefined + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index e07682b15a5..dc5b53fdbd4 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -272,11 +272,13 @@ export function nativeChatSlotIndexOf( * that the journal holds no place for: they draw after the live activity, not inside it. */ export function splitNativeChatSlotsWaitingBehindLiveTurn( slots: readonly NativeChatTranscriptSlot[], - journalItems: readonly AgentJournalRenderItem[] | undefined + journalItems: readonly AgentJournalRenderItem[] | undefined, + stopping = false ): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { const waiting = nativeChatMessagesWaitingBehindLiveTurn( slots.flatMap((slot) => (slot.kind === 'message' ? [slot.message] : [])), - journalItems + journalItems, + stopping ) const isWaiting = (slot: NativeChatTranscriptSlot): boolean => slot.kind === 'message' && diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts new file mode 100644 index 00000000000..08bb1fa0c75 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +// A send made while a Stop was ending a turn waits behind that turn. Retrying it later is a new +// send: it must not wait behind some later, unrelated turn a Stop is ending. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' +import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' + +const SESSION = 'session-1' + +beforeEach(() => localStorage.clear()) +afterEach(() => localStorage.clear()) + +function sentWhileStopping(state: 'unconfirmed' | 'rejected') { + writeOutbox(SESSION, [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'held', + sessionId: SESSION, + text: 'follow-up', + attachments: [], + queuedAt: 1 + }), + lastAttemptAt: 5, + outlivedStop: true, + sentWhileStopping: true, + state + } + ]) +} + +function retry(submissions: AgentJournalSubmission[] = []): void { + retryStructuredAgentSessionOutboxEntry({ + clientMessageId: 'held', + sessionId: SESSION, + submissions, + setError: vi.fn(), + createOperationId: () => 'retried' + }) +} + +describe('Retry on a send made while a Stop was ending a turn', () => { + it('sends it as a new send, no longer waiting behind a Stop', () => { + sentWhileStopping('unconfirmed') + + retry() + + expect(readOutbox(SESSION)).toEqual([ + expect.objectContaining({ clientMessageId: 'held', state: 'queued' }) + ]) + expect(readOutbox(SESSION)[0]).not.toHaveProperty('sentWhileStopping') + expect(readOutbox(SESSION)[0]).not.toHaveProperty('outlivedStop') + }) + + it('does the same when the Retry rotates the id of a send the host refused', () => { + sentWhileStopping('rejected') + + retry([ + { + clientMessageId: 'held', + fence: 1, + payloadFingerprint: 'held', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_WRITE_FAILED, + submittedAt: 1, + resolvedAt: 2 + } + ]) + + expect(readOutbox(SESSION)).toEqual([ + expect.objectContaining({ clientMessageId: 'retried', state: 'queued' }) + ]) + expect(readOutbox(SESSION)[0]).not.toHaveProperty('sentWhileStopping') + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts index d431cccb036..b61f17dfe9b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts @@ -74,10 +74,12 @@ export function retryStructuredAgentSessionOutboxEntry(args: { } } -/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. */ +/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. It + * is a new send, so it no longer waits behind the turn a Stop was ending when it was first sent. */ function retriedByUser({ outlivedStop: _retried, lastFailure: _sentAgain, + sentWhileStopping: _sentNow, ...entry }: StructuredAgentSessionOutboxEntry): StructuredAgentSessionOutboxEntry { return entry diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 02b34787cec..343923e7e05 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -200,7 +200,8 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + source?: 'launch', + sentWhileStopping = false ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -210,7 +211,8 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}) + ...(source ? { source } : {}), + ...(sentWhileStopping ? { sentWhileStopping: true as const } : {}) } return commitStructuredAgentSessionOutbox( sessionId, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx new file mode 100644 index 00000000000..fc1fc8430c3 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +// The sidebar row follows the host's Stopping both ways, even when nothing else on the summary +// moved: a Stop that settles, then binds no turn, changes only that field. + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' + +type TestStore = { + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void +} + +const mocks = vi.hoisted(() => { + const hoisted: { store: TestStore | null; subscribeStatus: Mock; unsubscribe: Mock } = { + store: null, + subscribeStatus: vi.fn(), + unsubscribe: vi.fn() + } + return hoisted +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ?? null, + getExecutionHostIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ? `runtime:${state.testRuntimeOwner}` : 'local' +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' + +const structuredTab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' +} satisfies Tab + +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'work on this', + updatedAt: 5_000, + ...overrides + } +} + +function row(): AgentStatusEntry { + const [entry] = Object.values(mocks.store?.getState().agentStatusByPaneKey ?? {}) + if (!entry) { + throw new Error('status row missing') + } + return entry +} + +describe("the sidebar row's Stopping", () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('takes Stopping on and off when it is the only field that moved', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const emit: (event: AgentSessionStatusEvent) => void = mocks.subscribeStatus.mock.calls[0]?.[1] + + act(() => emit({ type: 'status', session: summary({ stopping: true }) })) + expect(row().mainAgent).toMatchObject({ state: 'working', stopping: true }) + + act(() => emit({ type: 'status', session: summary() })) + expect(row().state).toBe('working') + expect(row().mainAgent).not.toHaveProperty('stopping') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts index c6b5a26812f..c0a8d8b05e1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts @@ -16,7 +16,7 @@ export function useNativeChatTranscriptSlots({ line, ...input }: Omit & { - line: { draws: boolean; thinking: boolean; activityText?: string | null } + line: { draws: boolean; thinking: boolean; stopping?: boolean; activityText?: string | null } }): { slots: NativeChatTranscriptSlot[]; liveLine: NativeChatLiveLine | null } { const { messages, @@ -32,12 +32,13 @@ export function useNativeChatTranscriptSlots({ subagentSections, subagentChoices } = input - const { draws, thinking, activityText } = line + const { draws, thinking, stopping = false, activityText } = line const liveLine = useMemo( () => nativeChatLiveLine({ draws, thinking, + stopping, activityText, messages, inLiveWorkingTurn: (index) => @@ -47,7 +48,17 @@ export function useNativeChatTranscriptSlots({ isWorking || lifecycleWorking ) }), - [activityText, draws, isWorking, lifecycleWorking, liveTurnKey, messages, thinking, turnKeys] + [ + activityText, + draws, + isWorking, + lifecycleWorking, + liveTurnKey, + messages, + stopping, + thinking, + turnKeys + ] ) const liveReasoningId = liveLine?.reasoning?.message.id ?? null const slots = useMemo( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 46f2f482e52..2a70730b3fc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -77,6 +77,8 @@ export function useStructuredAgentSessionOutbox(args: { * its entry here under the draft's own id; once the host visibly holds the draft, the * entry retires so the same text can never come back twice. */ queuedMessageIds?: readonly string[] + /** The chat reads Stopping: a send made now is marked as made while stopping. */ + stopping?: boolean }) { const { composerScopeKey, @@ -85,6 +87,7 @@ export function useStructuredAgentSessionOutbox(args: { queueDelivery = NO_QUEUE_DELIVERY, queuedMessageIds, sessionId, + stopping: sentWhileStopping = false, submissions, target } = args @@ -271,14 +274,22 @@ export function useStructuredAgentSessionOutbox(args: { return false } // Whether it asks to be queued is decided when it first goes out. - if (!appendStructuredAgentSessionOutboxMessage(sessionId, text, attachments)) { + if ( + !appendStructuredAgentSessionOutboxMessage( + sessionId, + text, + attachments, + undefined, + sentWhileStopping + ) + ) { setError(agentSessionWriteNoticeText(STRUCTURED_AGENT_SESSION_OUTBOX_NOT_SAVED)) return false } setError(null) return true }, - [sessionId] + [sentWhileStopping, sessionId] ) const { withdrawUnsent } = useStructuredAgentSessionOutboxOwnership({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx new file mode 100644 index 00000000000..e4fc26e36bd --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useStructuredAgentSessionStopPress } from './use-structured-agent-session-stop-press' + +afterEach(() => cleanup()) + +function deferred() { + let resolve: (value: T) => void = () => undefined + let reject: (error: unknown) => void = () => undefined + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('a Stop press read as Stopping', () => { + it('holds while the request is in flight and clears when it answers', async () => { + const { result } = renderHook(() => useStructuredAgentSessionStopPress('session-1')) + const answer = deferred() + + let tracked: Promise = Promise.resolve(null) + act(() => { + tracked = result.current.track(() => answer.promise) + }) + expect(result.current.pressed).toBe(true) + + await act(async () => { + answer.resolve(null) + await tracked + }) + expect(result.current.pressed).toBe(false) + }) + + it('clears when the request fails', async () => { + const { result } = renderHook(() => useStructuredAgentSessionStopPress('session-1')) + const answer = deferred() + + let tracked: Promise = Promise.resolve(null) + act(() => { + tracked = result.current.track(() => answer.promise) + }) + await act(async () => { + answer.reject(new Error('transport closed')) + await tracked.catch(() => undefined) + }) + + expect(result.current.pressed).toBe(false) + }) + + it("never reads another session's press", () => { + const { result, rerender } = renderHook( + ({ sessionId }) => useStructuredAgentSessionStopPress(sessionId), + { initialProps: { sessionId: 'session-1' } } + ) + act(() => { + void result.current.track(() => new Promise(() => undefined)) + }) + + rerender({ sessionId: 'session-2' }) + + expect(result.current.pressed).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts new file mode 100644 index 00000000000..fb60493da0f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState } from 'react' + +/** + * The person's own Stop press, read as "Stopping…" until its request answers, so the control + * flips on click while the host's Stop event is still on its way. Presentation only: never + * stored or sent. Once it answers, the host's own `stopping` is what the chat shows. + */ +export function useStructuredAgentSessionStopPress(sessionId: string): { + pressed: boolean + track: (stop: () => Promise) => Promise +} { + const [press, setPress] = useState<{ sessionId: string; token: symbol } | null>(null) + const latest = useRef(null) + const track = useCallback( + async (stop: () => Promise): Promise => { + const token = Symbol('stop-press') + latest.current = token + setPress({ sessionId, token }) + try { + return await stop() + } finally { + // Answered or failed alike: a failed request's notice is the existing one. + if (latest.current === token) { + latest.current = null + setPress(null) + } + } + }, + [sessionId] + ) + return { pressed: press?.sessionId === sessionId, track } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts new file mode 100644 index 00000000000..474d1934249 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts @@ -0,0 +1,54 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { useStructuredAgentSessionHostStopsConversation } from '@/runtime/structured-agent-session-host-capability' +import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionStopPress } from './use-structured-agent-session-stop-press' +import type { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' + +/** This session's Stop: whether the chat reads "Stopping…", and the request itself. */ +export function useStructuredAgentSessionStop(input: { + sessionId: string + target: RuntimeClientTarget + transportState: Pick< + ReturnType, + 'fence' | 'isWorking' + > + /** The host says a person's Stop is still ending this session's work. */ + hostStopping: boolean + mutate: StructuredAgentSessionMutate +}): { + stopping: boolean + /** This client's Stop request is in flight. */ + pressed: boolean + /** A host that takes a Stop naming no turn gets Stop from the send until the work settles; every + * Stop before a turn opens needs that form. An older host can stop only a turn it has opened. */ + stopsConversation: boolean + stop: (turnId: string | null, withdrawUnsent: () => void) => Promise +} { + const { sessionId, target, transportState, hostStopping, mutate } = input + const press = useStructuredAgentSessionStopPress(sessionId) + const stopsConversation = + useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null + return { + stopping: + agentStopDisplayStatus({ + working: transportState.isWorking, + hostStopping, + stopPressed: press.pressed + }) === 'stopping', + pressed: press.pressed, + stopsConversation, + stop: (turnId, withdrawUnsent) => { + if (stopsConversation) { + // Unsent text this client still owns goes back to its composer — a local move. + // Host-held drafts are never withdrawn by a Stop: the host pauses them and + // they stay visible as cards, on every device, until the user acts on one. + withdrawUnsent() + return press.track(() => mutate('agentSession.cancel', 'agentSession.cancel', {})) + } + return turnId + ? press.track(() => mutate('agentSession.cancel', 'agentSession.cancel', { turnId })) + : Promise.resolve(null) + } + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx new file mode 100644 index 00000000000..397c3a422bf --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +// A message sent while the chat reads Stopping, through the chat's own send and its real outbox: +// where the host does not queue sends, it goes out plain, for the host to hold until the stop lands, +// and is marked as sent while stopping, so it draws after the Stopping line. + +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) +let items: AgentJournalRenderItem[] = [] +const submissions: AgentJournalSubmission[] = [] + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { fence: 3, items, submissions, status: 'ready', error: null, hasOlder: false }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache' +import { useStructuredAgentSession } from './use-structured-agent-session' + +const RUNNING_TURN: AgentJournalRenderItem = { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } +} + +function sends(): { delivery?: string; body?: unknown }[] { + return mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.send') + .map(([, , params]) => params) +} + +beforeEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() + items = [RUNNING_TURN] + // A host that does not queue sends, as every shipped host does not yet. + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) + // The host has not answered yet: the send is still on its way, as while a Stop holds the lane. + mocks.call.mockImplementation(() => new Promise(() => {})) +}) + +afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + mocks.call.mockReset() +}) + +function renderStopping() { + return renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1', + queueFollowUps: false, + hostStopping: true + }) + ) +} + +it('sends plain where the host does not queue sends, marked as sent while stopping', async () => { + const { result } = renderStopping() + + expect(result.current.send('run this after the stop')).toBe(true) + + await waitFor(() => expect(sends()).toHaveLength(1)) + expect(sends()[0]).not.toHaveProperty('delivery') + expect( + result.current.messages.find((message) => + JSON.stringify(message.blocks).includes('run this after the stop') + ) + ).toMatchObject({ role: 'user', sentWhileStopping: true }) +}) + +// Sent before the Stop, the host steers it into the turn: it is not one held behind the Stop. +it('does not mark a send made before the chat read Stopping', async () => { + const { result, rerender } = renderHook( + ({ hostStopping }: { hostStopping: boolean }) => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1', + queueFollowUps: false, + hostStopping + }), + { initialProps: { hostStopping: false } } + ) + expect(result.current.send('sent before the stop')).toBe(true) + + rerender({ hostStopping: true }) + + await waitFor(() => expect(sends()).toHaveLength(1)) + const sent = result.current.messages.find((message) => + JSON.stringify(message.blocks).includes('sent before the stop') + ) + expect(sent).toBeDefined() + expect(sent).not.toHaveProperty('sentWhileStopping') +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index bef56bf7f81..a76a6c008e8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -74,6 +74,7 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useStructuredAgentSession } from './use-structured-agent-session' +import { nativeChatStructuredStopControls } from './native-chat-structured-stop-controls' const RUNNING_TURN: AgentJournalRenderItem = { itemId: 'turn-1', @@ -83,6 +84,28 @@ const RUNNING_TURN: AgentJournalRenderItem = { body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } } +/** A pending approval; one of a subject kind this build does not know cannot be answered here. */ +function approval(subject: Record): AgentJournalRenderItem { + return JSON.parse( + JSON.stringify({ + itemId: `approval-${String(subject.kind)}`, + revision: 1, + sequence: 2, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review', + detail: null, + subject, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) +} + +const newerApproval = (): AgentJournalRenderItem => approval({ kind: 'diff', path: 'a.ts' }) + function draft(id: string): AgentSessionQueuedMessage { return { messageId: id, @@ -158,31 +181,30 @@ describe('against a capable host', () => { }) }) + // While a Stop runs, the composer says what a send made now does: queued after the stop, or sent. + describe('the words for a send after a Stop', () => { + const afterStop = (queueFollowUps?: boolean) => { + const { result } = render(queueFollowUps) + return nativeChatStructuredStopControls(result.current, true).composer.afterStop + } + + it('say it queues while the setting is on', () => { + expect(afterStop()).toBe('queue') + }) + + it('say it is sent while the setting is off, though the host queues', () => { + expect(afterStop(false)).toBe('send') + }) + + it('say it is sent while every pending prompt is one this build cannot answer', () => { + items = [RUNNING_TURN, newerApproval()] + expect(afterStop()).toBe('send') + }) + }) + // The host's queue would hold a send behind a prompt nothing here can settle. it('sends immediately while every pending prompt is one this build cannot answer', () => { - const approval = (subject: Record): AgentJournalRenderItem => - JSON.parse( - JSON.stringify({ - itemId: `approval-${String(subject.kind)}`, - revision: 1, - sequence: 2, - observedAt: 1, - body: { - kind: 'approval', - title: 'Review', - detail: null, - subject, - options: [{ id: 'allow', label: 'Approve' }], - resolution: { - state: 'pending', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null - } - } - }) - ) - const newer = approval({ kind: 'diff', path: 'a.ts' }) + const newer = newerApproval() items = [newer] render() expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ 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 dd7d06d6e5a..6b9f13c6132 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 @@ -13,10 +13,7 @@ import { supportsStructuredAgentSessionPromptCancel, supportsStructuredAgentSessionQuestionAnswers } from '@/runtime/structured-agent-session-client' -import { - useStructuredAgentSessionHostQueuesMessagesState, - useStructuredAgentSessionHostStopsConversation -} from '@/runtime/structured-agent-session-host-capability' +import { useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import { legacyAgentSessionSelectedOptionId, @@ -28,6 +25,7 @@ import { } from './structured-agent-session-message-projection' import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' +import { useStructuredAgentSessionStop } from './use-structured-agent-session-stop' import { useStructuredAgentSessionTransport } from './use-structured-agent-session-transport' import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' @@ -38,6 +36,7 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { structuredAgentSessionNewSendsQueue } from '../../../../shared/structured-agent-session-outbox-delivery' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { useStructuredAgentSessionRewind } from './use-native-chat-rewind' @@ -61,12 +60,15 @@ export function useStructuredAgentSession(args: { composerScopeKey?: string /** The chat-wide "queue follow-ups" setting; off keeps mid-turn sends immediate. */ queueFollowUps?: boolean + /** The host says a person's Stop is still ending this session's work. */ + hostStopping?: boolean /** The host's rewind latch and what follows a message returned by a rewind. */ rewind?: NativeChatRewindHost }) { const { agent, composerScopeKey, + hostStopping = false, isVisible, launch, providerStarting = false, @@ -121,6 +123,13 @@ export function useStructuredAgentSession(args: { () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] ) + const stopControl = useStructuredAgentSessionStop({ + sessionId, + target, + transportState, + hostStopping, + mutate + }) const prompts = pendingStructuredSessionPrompts(transportState.journalItems) // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot // answer: the send must start a turn, after which the card's cancel works. @@ -138,7 +147,8 @@ export function useStructuredAgentSession(args: { journalItems: transportState.journalItems, composerScopeKey, queueDelivery, - queuedMessageIds + queuedMessageIds, + stopping: stopControl.stopping }) const threadGoal = useStructuredAgentSessionThreadGoal({ @@ -177,13 +187,9 @@ export function useStructuredAgentSession(args: { blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, write }) - // A host that takes a Stop naming no turn gets Stop from the send until the work settles; every - // Stop before a turn opens needs that form. An older host can stop only a turn it has opened. - const stopsConversation = - useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null const canStop = transportState.turnId !== null || - (stopsConversation && + (stopControl.stopsConversation && (transportState.isWorking || hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. @@ -266,19 +272,12 @@ export function useStructuredAgentSession(args: { backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, canStop, - stop: () => { - if (stopsConversation) { - // Unsent text this client still owns goes back to its composer — a local move. - // Host-held drafts are never withdrawn by a Stop: the host pauses them and - // they stay visible as cards, on every device, until the user acts on one. - outboxController.withdrawUnsent() - return mutate('agentSession.cancel', 'agentSession.cancel', {}) - } - return transportState.turnId - ? mutate('agentSession.cancel', 'agentSession.cancel', { turnId: transportState.turnId }) - : Promise.resolve(null) - }, + stopPressed: stopControl.pressed, + stop: () => stopControl.stop(transportState.turnId, outboxController.withdrawUnsent), queuedMessages: queuedController, + /** A send made now while the agent works is held as a queued card: the host queues, and this + * send asks it to (the setting is on and no pending prompt blocks the queue). */ + sendsQueue: structuredAgentSessionNewSendsQueue(queueDelivery), cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { // Capability negotiation must complete before mutate fingerprints the payload: // older hosts reject the strict prompt field. diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts new file mode 100644 index 00000000000..d228ca31bb6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts @@ -0,0 +1,19 @@ +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' +import type { getAgentDotState } from './worktree-card-agent-summary' + +/** The row's one line, lead first. A short status word the dot cannot carry leads, so the + * row's truncation cuts the prompt instead: monitoring, and a person's Stop ending the turn. */ +export function getCompactAgentLineOrder( + agent: DashboardAgentRowData, + dotState: ReturnType, + primary: string, + secondary: string +): { leadingText: string; trailingText: string } { + const statusLeads = + dotState === 'monitoring' || secondary === agentRowStoppingLabel(agent.entry, agent.state) + if (!statusLeads) { + return { leadingText: primary, trailingText: secondary } + } + return { leadingText: secondary, trailingText: primary === secondary ? '' : primary } +} diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts new file mode 100644 index 00000000000..ea6227d4228 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makeTab } from '../../store/slices/store-test-helpers' +import { buildWorktreeAgentRows } from './worktree-agent-rows' +import { getAgentDotState } from './worktree-card-agent-summary' +import { getCompactAgentLineOrder } from './worktree-card-compact-agent-line-order' +import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' + +const NOW = new Date('2026-05-04T12:00:00.000Z').getTime() +const TAB_ID = 'tab-1' + +function row(mainAgent: AgentStatusEntry['mainAgent']) { + const [agent] = buildWorktreeAgentRows({ + tabs: [makeTab({ id: TAB_ID, worktreeId: 'wt-1' })], + entries: [ + { + paneKey: `${TAB_ID}:11111111-1111-4111-8111-111111111111`, + state: 'working', + prompt: 'run the long build', + toolName: 'Bash', + toolInput: 'pnpm test', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + mainAgent + } + ], + retained: [], + ptyIdsByTabId: { [TAB_ID]: ['pty-1'] }, + now: NOW + }) + if (!agent) { + throw new Error('expected one agent row') + } + return agent +} + +describe("the sidebar row while a person's Stop ends the turn", () => { + it('says Stopping in place of the last tool line, and keeps the working spinner', () => { + const stopping = row({ state: 'working', stopping: true, stateStartedAt: NOW }) + + expect(getCompactAgentSecondary(stopping, NOW)).toBe('Stopping…') + expect(getAgentDotState(stopping)).toBe('working') + }) + + it('shows the tool line otherwise', () => { + expect(getCompactAgentSecondary(row({ state: 'working', stateStartedAt: NOW }), NOW)).not.toBe( + 'Stopping…' + ) + }) + + // The row truncates its one line from the end, and the model and time keep their room. + it('leads with Stopping so a narrow sidebar cuts the chat name, not the status', () => { + const stopping = row({ state: 'working', stopping: true, stateStartedAt: NOW }) + const secondary = getCompactAgentSecondary(stopping, NOW) + + expect( + getCompactAgentLineOrder(stopping, getAgentDotState(stopping), 'Codex Chat', secondary) + ).toEqual({ leadingText: 'Stopping…', trailingText: 'Codex Chat' }) + }) + + it('leads with the chat name otherwise', () => { + const working = row({ state: 'working', stateStartedAt: NOW }) + const secondary = getCompactAgentSecondary(working, NOW) + + expect( + getCompactAgentLineOrder(working, getAgentDotState(working), 'Codex Chat', secondary) + ).toEqual({ leadingText: 'Codex Chat', trailingText: secondary }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 4f3a34a2b05..469e4a2ecc8 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -15,6 +15,8 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' +import { getCompactAgentLineOrder } from './worktree-card-compact-agent-line-order' function getCompactAgentPrimary( agent: DashboardAgentRowData, @@ -38,6 +40,10 @@ export function getCompactAgentSecondary( if (agent.state === 'unverifiable') { return agentNoUpdateLabel(agent.entry, now) } + const stoppingLabel = agentRowStoppingLabel(agent.entry, agent.state) + if (stoppingLabel) { + return stoppingLabel + } // Why: the lead turn is over in monitoring, so its last tool line is stale; name the state instead. if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') { return agentStateLabel('monitoring') @@ -132,10 +138,12 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const stableMessage = turnHoldable && !currentMessage && held?.turn === turn ? held.message : undefined const secondary = getCompactAgentSecondary(agent, now, stableMessage) - // Why: sidebar truncation must preserve the passive-vs-active distinction. - const leadingText = dotState === 'monitoring' ? secondary : primary - const trailingText = - dotState === 'monitoring' ? (primary === secondary ? '' : primary) : secondary + const { leadingText, trailingText } = getCompactAgentLineOrder( + agent, + dotState, + primary, + secondary + ) const rowTitle = `${leadingText}${trailingText ? ` - ${trailingText}` : ''}` const model = agent.entry.model?.trim() ?? '' const shortTime = getCompactAgentTime(agent, now) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 9abb0410a1d..0390376562d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2848,7 +2848,9 @@ "status": { "failedAfter": "Failed after {{value0}}", "interruptedAfter": "Interrupted after {{value0}}", + "queueAfterStop": "Queue a message to run after the stop", "responding": "Agent is responding", + "sendAfterStop": "Send a message to run after the stop", "toggleDetails": "Toggle turn details", "workedFor": "Worked for {{value0}}", "workingFor": "Working for {{value0}}" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 28d852e580a..d58131e098b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18093,6 +18093,9 @@ "status": { "responding": "Agent is responding", "working": "Working…", + "stopping": "Stopping…", + "queueAfterStop": "Queue a message to run after the stop", + "sendAfterStop": "Send a message to run after the stop", "thinking": "Thinking", "workingFor": "Working for {{value0}}", "workedFor": "Worked for {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index bcfbd7393ec..c5df8232fea 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17829,6 +17829,9 @@ }, "status": { "responding": "El agente está respondiendo", + "stopping": "Deteniendo…", + "queueAfterStop": "Pon en cola un mensaje para que se ejecute después de detenerse", + "sendAfterStop": "Envía un mensaje para que se ejecute después de detenerse", "interruptedAfter": "Interrumpido tras {{value0}}", "failedAfter": "Falló tras {{value0}}", "working": "Trabajando…", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 79e64d93bda..133c89d94e1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17929,6 +17929,9 @@ "status": { "responding": "L'agent répond", "working": "Fonctionnement…", + "stopping": "Arrêt en cours…", + "queueAfterStop": "Mettez un message en file d'attente pour l'exécuter après l'arrêt", + "sendAfterStop": "Envoyez un message pour l'exécuter après l'arrêt", "thinking": "Réflexion", "workingFor": "En cours depuis {{value0}}", "workedFor": "A travaillé pendant {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 047d9cfea6b..65968827443 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17865,6 +17865,9 @@ "status": { "responding": "Agent が応答中です", "working": "処理中…", + "stopping": "停止中…", + "queueAfterStop": "停止後に実行するメッセージをキューに追加", + "sendAfterStop": "停止後に実行するメッセージを送信", "thinking": "考え", "workingFor": "{{value0}} 処理中", "workedFor": "{{value0}} で完了", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ca3692cacd2..a82dc0900bc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17865,6 +17865,9 @@ "status": { "responding": "Agent이 응답 중입니다.", "working": "작업 중…", + "stopping": "중지하는 중…", + "queueAfterStop": "중지 후에 실행할 메시지를 대기열에 추가", + "sendAfterStop": "중지 후에 실행할 메시지 보내기", "thinking": "생각", "workingFor": "{{value0}} 작업 중", "workedFor": "{{value0}} 작업 완료", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a698cd4b791..4bb136cb328 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17830,6 +17830,9 @@ "status": { "responding": "智能体正在回复", "working": "处理中…", + "stopping": "正在停止…", + "queueAfterStop": "将消息加入队列,在停止后运行", + "sendAfterStop": "发送消息,在停止后运行", "thinking": "思维", "workingFor": "已工作 {{value0}}", "workedFor": "工作了 {{value0}}", diff --git a/src/renderer/src/lib/agent-row-stopping-label.ts b/src/renderer/src/lib/agent-row-stopping-label.ts new file mode 100644 index 00000000000..66c1800d83d --- /dev/null +++ b/src/renderer/src/lib/agent-row-stopping-label.ts @@ -0,0 +1,19 @@ +import { agentStopDisplayStatus } from '../../../shared/agent-stop-display-status' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import type { AgentRowState } from './agent-row-decay-state' + +/** "Stopping…" while a person's Stop ends a working row's turn, else null. Every agent row says it + * in place of the last tool line, which no longer says what comes next. */ +export function agentRowStoppingLabel( + entry: Pick, + state: AgentRowState | null | undefined +): string | null { + const display = agentStopDisplayStatus({ + working: state === 'working', + hostStopping: entry.mainAgent?.stopping === true + }) + return display === 'stopping' + ? translate('components.native-chat.status.stopping', 'Stopping…') + : null +} diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts index 1d801521bff..ea238e7616e 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -86,6 +86,27 @@ describe('structured status feed execution authority lifecycle', () => { expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true) }) + it('drops a Stop the host was ending once contact is lost, owned or not', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + const unowned = { ...owned, sessionId: 'unowned', hostExecutionOwned: undefined } + subscription().emit({ + type: 'snapshot', + sessions: [ + { ...owned, stopping: true }, + { ...unowned, stopping: true } + ] + }) + expect(feed.getSnapshot().get('running')?.stopping).toBe(true) + + subscription().emit({ type: 'end' }) + + expect(feed.getSnapshot().get('running')).not.toHaveProperty('stopping') + expect(feed.getSnapshot().get('unowned')).not.toHaveProperty('stopping') + expect(feed.getSnapshot().get('unowned')?.status).toBe('working') + }) + it('retains history without ownership while stopped and until remount receives fresh evidence', async () => { const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) const deactivate = feed.activate() diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 84bbcacdecc..e0c14dc5772 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -5,10 +5,12 @@ // keeps the cached summaries and reconnects; a fresh snapshot merges over them. // Which sessions are listed is the tab map's decision, so the feed never retracts a summary. -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary -} from '../../../shared/agent-session-wire' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive, + type AgentSessionStatusSnapshot +} from '../../../shared/agent-session-status-snapshot-fold' import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { runtimeEnvironmentSupportsCapability, @@ -16,7 +18,7 @@ import { } from './runtime-rpc-client' import { subscribeStructuredAgentSessionStatus } from './structured-agent-session-client' -export type StructuredAgentSessionStatusSnapshot = ReadonlyMap +export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void @@ -58,22 +60,15 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { const applyEvent = (event: AgentSessionStatusEvent): void => { if (event.type === 'snapshot') { reconnectAttempt = 0 - // Merged, not replaced: a restarted host restores its readable sessions asynchronously, so - // the first snapshot can be empty and dropping those rows flickers every one to no-status. - const next = new Map(snapshot) for (const session of event.sessions) { confirmedSessions.add(session.sessionId) - next.set(session.sessionId, session) } - setSnapshot(next) + } else if (event.type === 'status') { + confirmedSessions.add(event.session.sessionId) + } else { return } - if (event.type === 'status') { - confirmedSessions.add(event.session.sessionId) - const next = new Map(snapshot) - next.set(event.session.sessionId, event.session) - setSnapshot(next) - } + setSnapshot(foldAgentSessionStatusEvent(snapshot, event)) } const active = (candidate: number): boolean => activations.size > 0 && candidate === generation const clearReconnect = (): void => { @@ -87,20 +82,9 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { handle = null } const revokeSnapshotOwnership = (): void => { - let next: Map | null = null - for (const [sessionId, summary] of snapshot) { - if (!summary.hostExecutionOwned) { - continue - } - if (!next) { - next = new Map(snapshot) - } - const { hostExecutionOwned: _owned, hostExecutionPhase: _phase, ...retained } = summary - next.set(sessionId, retained) - } - if (next) { - snapshot = next - emit() + const next = revokeAgentSessionStatusLive(snapshot) + if (next !== snapshot) { + setSnapshot(next) } } let open = (): void => {} diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 854fea121dc..38920f5e1f9 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -84,6 +84,7 @@ export function continueMainAgentStatus( next: { state: AgentStatusState outcome?: AgentMainAgentStatus['outcome'] + stopping?: true stateStartedAt?: number }, now: number @@ -94,6 +95,7 @@ export function continueMainAgentStatus( return { state: next.state, ...(next.state === 'done' && next.outcome ? { outcome: next.outcome } : {}), + ...(next.state === 'working' && next.stopping ? { stopping: true as const } : {}), stateStartedAt } } diff --git a/src/shared/agent-session-status-snapshot-fold.ts b/src/shared/agent-session-status-snapshot-fold.ts new file mode 100644 index 00000000000..ce88e1d2d82 --- /dev/null +++ b/src/shared/agent-session-status-snapshot-fold.ts @@ -0,0 +1,51 @@ +// A client's mirror of the host's status feed: the latest summary per session. The desktop and +// the phone fold the stream with these, so they agree on what a frame changes and on what losing +// contact takes away. + +import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from './agent-session-wire' + +export type AgentSessionStatusSnapshot = ReadonlyMap + +/** The mirror after one frame; `end` leaves it as it is. A snapshot merges over what is held: a + * restarted host restores its readable sessions asynchronously, so its first snapshot can be + * empty, and dropping those rows would flicker every one to no status. */ +export function foldAgentSessionStatusEvent( + snapshot: AgentSessionStatusSnapshot, + event: AgentSessionStatusEvent +): AgentSessionStatusSnapshot { + if (event.type === 'snapshot') { + const next = new Map(snapshot) + for (const session of event.sessions) { + next.set(session.sessionId, session) + } + return next + } + if (event.type === 'status') { + const next = new Map(snapshot) + next.set(event.session.sessionId, event.session) + return next + } + return snapshot +} + +/** With contact lost, what only a live host vouches for goes: its execution, and a Stop it was + * ending. The rest is kept, since losing contact is never exit. `snapshot` itself if unchanged. */ +export function revokeAgentSessionStatusLive( + snapshot: AgentSessionStatusSnapshot +): AgentSessionStatusSnapshot { + let next: Map | null = null + for (const [sessionId, summary] of snapshot) { + if (!summary.hostExecutionOwned && !summary.stopping) { + continue + } + next ??= new Map(snapshot) + const { + hostExecutionOwned: _owned, + hostExecutionPhase: _phase, + stopping: _stopping, + ...retained + } = summary + next.set(sessionId, retained) + } + return next ?? snapshot +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index f80b1f878f2..8ce2caa1781 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -255,6 +255,11 @@ export type AgentSessionStatusSummary = { * UNKNOWN, never success. Optional for mixed-version hosts; an older client reads an arm it * does not know as no verdict. The agent-status row publishes it as `mainAgent.outcome`. */ turnOutcome?: AgentTurnOutcome + /** Present only while `status` is 'working' and a person's Stop is still ending that work: while + * the Stop settles, then until the turn it stopped or failed to stop ends. A Stop that settles + * having stopped nothing clears it. Derived by the host, never stored. Absent from older hosts; + * an older client ignores it. */ + stopping?: true /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for * mixed-version hosts. Derived from `children` on hosts that publish it. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 5edf46fe788..1fff0ea17e3 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -297,6 +297,7 @@ export function normalizeMainAgentStatusField(value: unknown): AgentMainAgentSta state, // Why: a verdict belongs to a finished turn; anything riding on a live state is stale. ...(state === 'done' && isAgentTurnOutcome(obj.outcome) ? { outcome: obj.outcome } : {}), + ...(state === 'working' && obj.stopping === true ? { stopping: true as const } : {}), stateStartedAt: obj.stateStartedAt } } diff --git a/src/shared/agent-stop-display-status.test.ts b/src/shared/agent-stop-display-status.test.ts new file mode 100644 index 00000000000..b001bb60b31 --- /dev/null +++ b/src/shared/agent-stop-display-status.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { agentStopDisplayStatus } from './agent-stop-display-status' + +describe('agentStopDisplayStatus', () => { + it("reads stopping from the host's flag or this client's own press while the agent works", () => { + expect(agentStopDisplayStatus({ working: true, hostStopping: true })).toBe('stopping') + expect(agentStopDisplayStatus({ working: true, stopPressed: true })).toBe('stopping') + expect(agentStopDisplayStatus({ working: true })).toBe('working') + }) + + it('never reads stopping on an agent that is not working', () => { + expect(agentStopDisplayStatus({ working: false, hostStopping: true, stopPressed: true })).toBe( + 'not-working' + ) + }) +}) diff --git a/src/shared/agent-stop-display-status.ts b/src/shared/agent-stop-display-status.ts new file mode 100644 index 00000000000..577612774c8 --- /dev/null +++ b/src/shared/agent-stop-display-status.ts @@ -0,0 +1,18 @@ +// What an agent's row, chat or composer shows about a person's Stop: one rule for every surface, +// so none of them combines the host's flag and this client's own press on its own. + +export type AgentStopDisplayStatus = 'stopping' | 'working' | 'not-working' + +/** `stopping` while the agent works and either the host says a person's Stop is ending its turn + * or this client's own Stop request is still in flight; the host's flag is never shown on a + * row that is not working. */ +export function agentStopDisplayStatus(input: { + working: boolean + hostStopping?: boolean + stopPressed?: boolean +}): AgentStopDisplayStatus { + if (!input.working) { + return 'not-working' + } + return input.hostStopping === true || input.stopPressed === true ? 'stopping' : 'working' +} diff --git a/src/shared/main-agent-status.ts b/src/shared/main-agent-status.ts index 88518130cc8..4d9467e6dbe 100644 --- a/src/shared/main-agent-status.ts +++ b/src/shared/main-agent-status.ts @@ -13,6 +13,9 @@ export type AgentMainAgentStatus = { * new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never infers `success`, because an * older provider that omits its interrupt flag would turn a cancel into a false success. */ outcome?: AgentTurnOutcome + /** Present only while `state` is `working`: a person's Stop is ending this turn. Published by a + * structured session's host; readers present it ("Stopping…") and never store it. */ + stopping?: true /** When the main agent's own `state` first appeared (ms). The row's `stateStartedAt` dates the * combined state instead, so the two differ while child work holds the row open. */ stateStartedAt: number @@ -28,5 +31,10 @@ export function mainAgentStatusEqual( if (!a || !b) { return false } - return a.state === b.state && a.outcome === b.outcome && a.stateStartedAt === b.stateStartedAt + return ( + a.state === b.state && + a.outcome === b.outcome && + a.stopping === b.stopping && + a.stateStartedAt === b.stateStartedAt + ) } diff --git a/src/shared/native-chat-live-line.test.ts b/src/shared/native-chat-live-line.test.ts index 981b9db2f23..cf62ba523f7 100644 --- a/src/shared/native-chat-live-line.test.ts +++ b/src/shared/native-chat-live-line.test.ts @@ -17,7 +17,12 @@ const open: NativeChatMessage = { source: 'transcript', state: 'running' } -const line = (fields: { draws?: boolean; thinking?: boolean; activityText?: string | null }) => +const line = (fields: { + draws?: boolean + thinking?: boolean + stopping?: boolean + activityText?: string | null +}) => nativeChatLiveLine({ draws: true, thinking: true, @@ -30,6 +35,7 @@ describe('the live line both clients draw', () => { it('discloses the open block while it reads "Thinking"', () => { expect(line({})).toEqual({ thinking: true, + stopping: false, activityText: null, reasoning: { message: open, markdown: 'Weighing two approaches' } }) @@ -40,6 +46,17 @@ describe('the live line both clients draw', () => { expect(line({ draws: false })).toBeNull() expect(line({ thinking: false })).toEqual({ thinking: false, + stopping: false, + activityText: null, + reasoning: null + }) + }) + + // While a Stop ends the turn the line reads "Stopping…", so the open block's own row draws it. + it('discloses no block while a Stop is ending the turn', () => { + expect(line({ stopping: true })).toEqual({ + thinking: true, + stopping: true, activityText: null, reasoning: null }) diff --git a/src/shared/native-chat-live-line.ts b/src/shared/native-chat-live-line.ts index d7c10d19f20..04ebaea6341 100644 --- a/src/shared/native-chat-live-line.ts +++ b/src/shared/native-chat-live-line.ts @@ -11,6 +11,8 @@ import type { NativeChatMessage } from './native-chat-types' export type NativeChatLiveLine = { /** The turn is reasoning now; the label reads "Thinking" unless activity text outranks it. */ thinking: boolean + /** The person's Stop is ending the turn: the label reads "Stopping…" and discloses no block. */ + stopping: boolean activityText: string | null /** The open block the line discloses; its row draws nothing meanwhile. */ reasoning: NativeChatLiveReasoning | null @@ -20,6 +22,7 @@ export function nativeChatLiveLine(input: { /** The running turn's tail is the activity line: nothing (a prompt the reader owes) replaces it. */ draws: boolean thinking: boolean + stopping?: boolean activityText?: string | null messages: readonly NativeChatMessage[] inLiveWorkingTurn: (index: number) => boolean @@ -27,12 +30,15 @@ export function nativeChatLiveLine(input: { if (!input.draws) { return null } + const stopping = input.stopping === true return { thinking: input.thinking, + stopping, activityText: input.activityText ?? null, // Rows never say "Thinking", so the line discloses only while it is the one that does. - reasoning: input.thinking - ? selectNativeChatLiveReasoning(input.messages, input.inLiveWorkingTurn) - : null + reasoning: + input.thinking && !stopping + ? selectNativeChatLiveReasoning(input.messages, input.inLiveWorkingTurn) + : null } } diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 20664521de2..98c2d2c24f9 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -245,16 +245,22 @@ function anchoringUserItems(recordKeys: ReadonlyMap): Rea /** * The rows accepted but not yet handed over while a conversation command's turn runs, as a message * sent during `/compact` is: the host hands nothing over until the command ends, so they draw after - * that turn's live activity. Any other running turn takes a send within moments, so it stays put. + * that turn's live activity. Any other running turn takes a send within moments, so it stays put, + * unless a person's Stop is ending it (`stopping`): the host holds a send made then until it ends, + * so those, and this client's own sends made then that it has not recorded yet, draw after the + * live activity too. A send made before the Stop stays where it is. */ export function nativeChatMessagesWaitingBehindLiveTurn( - messages: readonly { id: string; queued?: true }[], - items: readonly AgentJournalRenderItem[] | null | undefined + messages: readonly { id: string; queued?: true; sentWhileStopping?: true }[], + items: readonly AgentJournalRenderItem[] | null | undefined, + stopping = false ): ReadonlySet { - const queued = messages.filter((message) => message.queued === true) + const waits = (message: (typeof messages)[number]): boolean => + message.queued === true || (stopping && message.sentWhileStopping === true) + const waiting = messages.filter(waits) return new Set( - queued.length > 0 && items && commandTurnRunning(items) - ? queued.map((message) => message.id) + waiting.length > 0 && items && (stopping || commandTurnRunning(items)) + ? waiting.map((message) => message.id) : [] ) } diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 08557aef9a0..c9fc746273f 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -87,6 +87,16 @@ describe('describeNativeChatActiveTurnLabel', () => { key: 'working' }) }) + + it("says Stopping over the provider's activity once a person's Stop is ending the turn", () => { + expect( + describeNativeChatActiveTurnLabel({ + activityText: 'Running pnpm test', + thinking: true, + stopping: true + }) + ).toEqual({ source: 'status', key: 'stopping' }) + }) }) describe('formatNativeChatActiveTurnLabel', () => { @@ -96,6 +106,7 @@ describe('formatNativeChatActiveTurnLabel', () => { ).toBe('Running pnpm test') expect(formatNativeChatActiveTurnLabel({ thinking: true })).toBe('Thinking') expect(formatNativeChatActiveTurnLabel({ thinking: false })).toBe('Working…') + expect(formatNativeChatActiveTurnLabel({ thinking: false, stopping: true })).toBe('Stopping…') }) }) diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 446c489c350..83da06a8270 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -9,6 +9,11 @@ import type { AgentTurnOutcome } from './agent-turn-outcome' export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', + stopping: 'Stopping…', + /** The composer's placeholder while the chat reads Stopping, where the host queues sends. */ + queueAfterStop: 'Queue a message to run after the stop', + /** The same where it does not: the host holds the send until the stop lands. */ + sendAfterStop: 'Send a message to run after the stop', workingFor: 'Working for {{value0}}', workedFor: 'Worked for {{value0}}', interruptedAfter: 'Interrupted after {{value0}}', @@ -76,22 +81,35 @@ function settledTurnStatusKey( export type NativeChatLiveTurnIndicator = { thinking: boolean activityText: string | null + /** A person's Stop is ending the turn. */ + stopping?: boolean + /** This client's own Stop request is in flight: only then does its Stop control hold. */ + stopRequestInFlight?: boolean + /** While stopping: a message sent now is queued as a card where the host queues sends, else sent + * for the host to hold until the stop lands. */ + afterStop?: 'queue' | 'send' } export type NativeChatActiveTurnLabel = | { source: 'activity'; text: string } - | { source: 'status'; key: 'thinking' | 'working' } + | { source: 'status'; key: 'thinking' | 'working' | 'stopping' } -/** The live tail line's label. Provider activity wins because it is the only text - * that says what the turn is actually doing; reasoning is next. It never carries - * the clock — the turn bar owns that. Shared so desktop and mobile cannot disagree. */ +/** The live tail line's label. The person's own Stop wins: whatever the turn was doing, it is + * now ending. Provider activity is next because it is the only text that says what the turn is + * actually doing; reasoning is next. It never carries the clock — the turn bar owns that. + * Shared so desktop and mobile cannot disagree. */ export function describeNativeChatActiveTurnLabel({ activityText, - thinking + thinking, + stopping = false }: { activityText?: string | null thinking: boolean + stopping?: boolean }): NativeChatActiveTurnLabel { + if (stopping) { + return { source: 'status', key: 'stopping' } + } const text = activityText?.trim() if (text) { return { source: 'activity', text } @@ -103,6 +121,7 @@ export function describeNativeChatActiveTurnLabel({ export function formatNativeChatActiveTurnLabel(input: { activityText?: string | null thinking: boolean + stopping?: boolean }): string { const label = describeNativeChatActiveTurnLabel(input) return label.source === 'activity' ? label.text : NATIVE_CHAT_TURN_STATUS_COPY[label.key] diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 46c82b7fabf..9fe39148520 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -238,6 +238,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { /** Shown as not sent: in no turn, so a newer turn's bar and clock never land on it. Drawn where * the journal recorded it, or after the conversation when it holds no place there. */ unsent?: true + /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ + sentWhileStopping?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ journalPosition?: AgentJournalPosition diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 43bae879867..70529f10f79 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -109,4 +109,15 @@ describe('structuredAgentSessionAgentStatus', () => { structuredAgentSessionAgentStatus({ status: 'working', turnOutcome: 'failure' }) ).toEqual({ state: 'working', mainAgent: { state: 'working' } }) }) + + it("carries the host's Stopping on a working main agent only", () => { + expect(structuredAgentSessionAgentStatus({ status: 'working', stopping: true })).toEqual({ + state: 'working', + mainAgent: { state: 'working', stopping: true } + }) + expect(structuredAgentSessionAgentStatus({ status: 'idle', stopping: true })).toEqual({ + state: 'done', + mainAgent: { state: 'done' } + }) + }) }) diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index 9e424c62165..f00f2f7f2ad 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -28,7 +28,7 @@ function structuredAgentSessionLeadState( * `childWork` is the host's child records (or their views); only an older host's summary, which * publishes none, is read by its live background tasks. */ export function structuredAgentSessionAgentStatus( - summary: Pick & { + summary: Pick & { status: StructuredAgentSessionProjectedStatus childWork?: readonly AgentChildWorkLivenessCandidate[] } @@ -43,7 +43,8 @@ export function structuredAgentSessionAgentStatus( ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), mainAgent: { state: leadState, - ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}) + ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}), + ...(leadState === 'working' && summary.stopping ? { stopping: true as const } : {}) } } } diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 6d656ea560d..733ab6e397d 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -137,7 +137,9 @@ export function projectStructuredAgentSessionMessages( blocks: entry.body.blocks, ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) ? { unsent: true as const } - : {}) + : entry.sentWhileStopping + ? { sentWhileStopping: true as const } + : {}) })) ] } diff --git a/src/shared/structured-agent-session-outbox-delivery.ts b/src/shared/structured-agent-session-outbox-delivery.ts index 28ced3d2fee..067cf2e729e 100644 --- a/src/shared/structured-agent-session-outbox-delivery.ts +++ b/src/shared/structured-agent-session-outbox-delivery.ts @@ -17,6 +17,13 @@ export type StructuredAgentSessionQueueDelivery = { enabled: boolean } +/** Whether a send made now asks to be queued: a host known to queue, with queueing enabled. */ +export function structuredAgentSessionNewSendsQueue( + host: StructuredAgentSessionQueueDelivery +): boolean { + return host.capability === 'supported' && host.enabled +} + /** Whether this entry's next request asks to be queued. An attempted id asks exactly what it sent, * except of a host known not to queue, which rejects the field before its operation ledger, so * nothing there was recorded with it. A first attempt asks only of a host known to queue, with @@ -29,8 +36,7 @@ export function structuredAgentSessionEntryAsksToQueue( return entry.sentDelivery === 'queue-if-active' && host.capability !== 'unsupported' } return ( - host.capability === 'supported' && - host.enabled && + structuredAgentSessionNewSendsQueue(host) && entry.source !== 'launch' && entry.body.blocks.every((block) => block.type === 'text') ) @@ -55,11 +61,13 @@ export function structuredAgentSessionEntryAttempt( export function parseStructuredAgentSessionOutboxQueueFields(entry: { sentDelivery?: unknown outlivedStop?: unknown -}): Pick { + sentWhileStopping?: unknown +}): Pick { return { ...(entry.sentDelivery === 'queue-if-active' || entry.sentDelivery === null ? { sentDelivery: entry.sentDelivery } : {}), - ...(entry.outlivedStop === true ? { outlivedStop: true as const } : {}) + ...(entry.outlivedStop === true ? { outlivedStop: true as const } : {}), + ...(entry.sentWhileStopping === true ? { sentWhileStopping: true as const } : {}) } } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 32dcab0e66b..7db60719704 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -42,6 +42,8 @@ export type StructuredAgentSessionOutboxEntry = { * of this id asks the same (structured-agent-session-outbox-delivery). On a request's own copy, * what that request carries. */ sentDelivery?: 'queue-if-active' | null + /** Sent while the chat read Stopping: until the host records it, it is drawn after that turn. */ + sentWhileStopping?: true /** Why the last attempt did not go through. Lives on the message so it goes when the message * is sent again or delivered, instead of outliving it as a separate error. On a `queued` entry * it is also the hold (structured-agent-session-outbox-admission). */ diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index d7444e2c77e..ac18fe15986 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -22,6 +22,14 @@ import { } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../../../src/main/native-chat/agent-session-journal/journal-reducer' // A release that knows neither the Stop event nor the Resume marker: an unknown row kind would // make it delete the journal from that row on, so both ride a tombstone it already reads. @@ -49,6 +57,69 @@ const OLDER_IDENTITY: OlderJournalIdentity = { providerHandle: { kind: 'codex', threadId: 'thread-1' } } +test.each(['v1.4.219', 'v1.4.220', WRITABLE_BASELINE_REF])( + '%s replays current raw rows with the unconfirmed failure intact', + async (ref) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-note-raw-skew-')) + const journals = createTrackedJournalOpener() + try { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const turn = { provider: 'orca', clientMessageId: 'turn-raw-skew' } as const + const note = { provider: 'orca', clientMessageId: 'stop:turn-raw-skew' } as const + const unconfirmed = { + kind: 'status' as const, + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + await journal.appendItem( + turn, + { + kind: 'status', + text: 'Interrupted', + turnLifecycle: { turnId: 'turn-raw-skew', state: 'interrupted' } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem(note, unconfirmed, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } + }) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(since.reset) + } + const rawJson = JSON.stringify(since.rows) + const checkout = await materializeReleaseCheckout(ref) + const reducer = await importReleaseCheckoutModule(checkout, `${JOURNAL}/journal-reducer.ts`) + const create = releaseExport<(sessionId: string, epoch: string) => OldReplay['state']>( + reducer, + 'createJournalReducerState' + ) + const fold = releaseExport<(state: OldReplay['state'], row: JournalRow) => void>( + reducer, + 'applyJournalRow' + ) + const old = create(IDENTITY.sessionId, journal.epoch) + since.rows.forEach((row) => fold(old, row)) + expect(old.items.get(agentJournalItemKey(note))).toMatchObject({ body: unconfirmed }) + const current = createJournalReducerState(IDENTITY.sessionId, journal.epoch) + since.rows.forEach((row) => applyJournalRow(current, row)) + expect(current.items.get(agentJournalItemKey(note))?.body).toEqual(unconfirmed) + expect( + renderJournalState(current).items.find((item) => item.itemId === agentJournalItemKey(note)) + ?.body + ).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(JSON.stringify(since.rows)).toBe(rawJson) + expect(journal.itemBody(agentJournalItemKey(note))).toEqual(unconfirmed) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } + }, + 120_000 +) + function item(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } 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 f1076ac1df4..dfb9d6bfc09 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 @@ -66,10 +66,13 @@ import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { describeReleasedStopNoteProjection } from './cross-version-stop-note-scenarios' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 +describeReleasedStopNoteProjection({ build: () => current, callBuild, runtimeStub }) + const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update' let baselineRef: string diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts new file mode 100644 index 00000000000..8d3dc4f4d43 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../src/shared/agent-session-journal-types' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page-bounds' +import { + createReleasedStopNoteRig, + noteId, + noteIdentity, + turnItemId, + scope, + ordinary, + unconfirmed, + type ScenarioPort +} from './cross-version-stop-note-test-rig' + +export function describeReleasedStopNoteProjection(port: ScenarioPort): void { + describe.each(['v1.4.219', 'v1.4.220'])( + '%s client against the current host Stop projection', + (ref) => { + const rig = createReleasedStopNoteRig(port, ref) + const { page, subscribe, events, reduce, render, turn, note, seed } = rig + it('renders the derived page with the interrupted turn outside the page', async () => { + await turn('interrupted') + await note() + const snapshot = rig.journal.snapshot() + const stored = rig.journal.itemBody(noteId) + const notePage = await page({ direction: 'tail', limit: 1 }) + expect(notePage.items.map((item) => item.itemId)).toEqual([noteId]) + const state = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: notePage + } + ) + expect(render(state)).toMatchObject({ type: 'text', text: ordinary.text }) + expect(state.items[0]).toEqual(snapshot.items.find((item) => item.itemId === noteId)) + expect(rig.journal.itemBody(noteId)).toBe(stored) + }) + it.each([false, true])( + 'renders the same-revision note with a live turn end (lifecycle batch=%s)', + async (batch) => { + const before = await seed() + expect(render(before)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + const prior = before.items.find((item) => item.itemId === noteId)! + const replies = await subscribe() + replies.length = 0 + await turn('interrupted', batch) + const event = events(replies).find( + (event) => + event.type === 'batch' && event.batch.items.some((item) => item.itemId === noteId) + ) + expect(event).toMatchObject({ + type: 'batch', + batch: { + items: expect.arrayContaining([ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ ...prior, body: ordinary }) + ]), + removedItemIds: [] + } + }) + const after = reduce(before, replies) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + expect(rig.journal.itemBody(noteId)).toEqual(unconfirmed) + const since = rig.journal.readSince(before.cursor!) + expect(since).toMatchObject({ + ok: true, + rows: [expect.objectContaining({ kind: batch ? 'lifecycle-batch' : 'item' })] + }) + } + ) + it('refreshes a non-upgrade stale page after an out-of-window live correction at the terminal cursor', async () => { + await turn('running') + await note() + await rig.journal.appendItem( + { provider: 'orca', clientMessageId: 'newer' }, + { kind: 'status', text: 'Newer' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const tail = await page({ direction: 'tail', limit: 1 }) + let state = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: tail + } + ) + const requestedCursor = tail.window.nextCursor + const stale = await page({ direction: 'before', cursor: requestedCursor, limit: 1 }) + const replies = await subscribe() + replies.length = 0 + await turn('interrupted') + state = reduce(state, replies) + expect(state.items.some((item) => item.itemId === noteId)).toBe(false) + state = rig.client.reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor, + page: stale + }) + expect(render(state)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(state.cursor).toEqual(rig.journal.cursor()) + const held = rig.journal.cursor() + const refreshed = await subscribe(held) + state = reduce(state, refreshed) + expect(render(state)).toEqual({ type: 'text', text: ordinary.text }) + expect(rig.journal.cursor()).toEqual(held) + }) + it('catches up through byte-bounded pages without losing the dependent note or cursor', async () => { + const before = await seed() + const held = rig.journal.cursor() + for (const clientMessageId of ['large-a', 'large-b']) { + await rig.journal.appendItem( + { provider: 'orca', clientMessageId }, + { + kind: 'message', + role: 'assistant', + blocks: [ + { + type: 'text', + text: 'x'.repeat(Math.floor(HISTORY_PAGE_CONTENT_BUDGET_BYTES * 0.6)) + } + ] + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('interrupted') + const first = await page({ direction: 'after', cursor: held, limit: 3 }) + expect(first.hasNewer).toBe(true) + const caughtUp = await subscribe(held) + const after = reduce(before, caughtUp) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + expect(after.cursor).toEqual(rig.journal.cursor()) + expect(events(caughtUp).filter((event) => event.type === 'batch').length).toBeGreaterThan(1) + }) + it.each(['running', 'completed', 'unverifiable', 'missing', 'other-failure'] as const)( + 'keeps %s wording through the released page reader', + async (state) => { + if (state !== 'missing') { + await turn(state === 'other-failure' ? 'interrupted' : state) + } + const body: AgentJournalItemBody = + state === 'other-failure' + ? { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + surface: 'row' + }) + } + : unconfirmed + await note(body) + const initial = await page({ direction: 'tail' }) + expect(initial.items.find((item) => item.itemId === noteId)?.body).toEqual(body) + } + ) + it('preserves removal when the operation note is re-keyed before the interrupted end', async () => { + const op = { provider: 'orca', clientMessageId: 'stop:operation-1' } as const + await rig.journal.appendItem(op, unconfirmed, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await turn('running') + const initial = await page({ direction: 'tail' }) + const before = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: initial + } + ) + const held = rig.journal.cursor() + await rig.journal.appendLifecycleBatch({ + settlementId: 're-key', + fence: 1, + mutations: [ + { kind: 'tombstone', identity: op }, + { kind: 'item', identity: noteIdentity, body: unconfirmed, turnScope: scope } + ] + }) + await turn('interrupted') + const replies = await subscribe(held) + const after = reduce(before, replies) + expect(after.items.some((item) => item.itemId === agentJournalItemKey(op))).toBe(false) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + }) + } + ) +} diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts new file mode 100644 index 00000000000..d14aee3b599 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, expect } from 'vitest' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../../../src/main/native-chat/agent-session-journal/journal-store' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnLifecycle +} from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { + attachParams, + NOW, + resetOperationIds, + SESSION, + THREAD +} from './structured-agent-session-surface-manifest' +import { + loadAgentSessionWireBuild, + type AgentSessionClientProjection, + type AgentSessionWireBuild, + type RpcClientIdentity, + type RpcReply +} from './versioned-agent-session-wire' + +export type ScenarioPort = { + build: () => AgentSessionWireBuild + callBuild: ( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime?: unknown + ) => Promise + runtimeStub: () => unknown +} +export const turnIdentity = { provider: 'orca', clientMessageId: 'turn-stop-test' } as const +export const turnItemId = agentJournalItemKey(turnIdentity) +export const noteIdentity = { provider: 'orca', clientMessageId: 'stop:turn-stop-test' } as const +export const noteId = agentJournalItemKey(noteIdentity) +export const scope = { kind: 'turn', turnItemId } as const +export const ordinary = { kind: 'status', text: 'Cancellation requested.' } +export const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) +} + +export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { + let released: AgentSessionWireBuild + let client: AgentSessionClientProjection + let root: string + let host: StructuredAgentSessionHost + let journal: AgentSessionJournal + beforeAll(async () => { + released = await loadAgentSessionWireBuild(ref) + client = await released.clientProjection() + }, 180_000) + beforeEach(async () => { + resetOperationIds() + root = await mkdtemp(join(tmpdir(), 'orca-released-stop-note-')) + const store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + store, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + logger: createStructuredAgentSessionLogger(), + now: () => NOW, + adapter: { + supportsCreate: () => true, + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ state: 'unknown', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => {}, + setOption: async () => {} + } + }) + setStructuredAgentSessionHost(host) + expect(await host.attach({ callerKey: 'released-client' }, attachParams(null))).toMatchObject({ + ok: true + }) + journal = host['sessions'].get(SESSION)!.journal + }) + afterEach(async () => { + setStructuredAgentSessionHost(null) + await host.flushAllStreamedEvents() + await journal.close() + await rm(root, { recursive: true, force: true }) + }) + function call(method: string, params: unknown) { + return port.callBuild( + port.build(), + method, + params, + { + clientKind: 'runtime', + clientCapabilities: released.capabilities, + clientId: 'released-client' + }, + port.runtimeStub() + ) + } + async function page(params: Record): Promise { + const reply = (await call('agentSession.history', { sessionId: SESSION, ...params }))[0] + expect(reply?.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: real host history RPC answer; every item is admitted with the released client's schema below. + const result = reply?.result as AgentSessionHistoryResult + if (!result.ok) { + throw new Error(result.reset) + } + return { + ...result.page, + items: result.page.items.map((item) => client.AgentJournalRenderItemSchema.parse(item)) + } + } + async function subscribe(cursor = journal.cursor()): Promise { + return call('agentSession.subscribe', { sessionId: SESSION, cursor }) + } + function events(replies: RpcReply[]): AgentSessionSubscribeEvent[] { + return replies.map((reply) => { + expect(reply.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: real streaming RPC reply; release admission validates each item before reduction. + const event = reply.result as AgentSessionSubscribeEvent + return event.type === 'batch' + ? { + ...event, + batch: { + ...event.batch, + items: event.batch.items.map((item) => + client.AgentJournalRenderItemSchema.parse(item) + ) + } + } + : event + }) + } + function reduce(state: StructuredAgentSessionState, replies: RpcReply[]) { + return events(replies).reduce( + (state, event) => client.reduceStructuredAgentSession(state, { type: 'event', event }), + state + ) + } + function render(state: StructuredAgentSessionState) { + const item = state.items.find((item) => item.itemId === noteId)! + return client.projectStructuredItemsToNativeChat([item])[0]?.blocks[0] + } + async function turn(state: AgentJournalTurnLifecycle['state'], batch = false) { + const body = { kind: 'turn' as const, turnId: 'turn-stop-test', state } + await (batch + ? journal.appendLifecycleBatch({ + settlementId: `end-${state}`, + fence: 1, + mutations: [ + { kind: 'item', identity: turnIdentity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ] + }) + : journal.appendItem(turnIdentity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + } + async function note(body: AgentJournalItemBody = unconfirmed) { + await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) + } + async function seed() { + await turn('running') + await note() + const initial = await page({ direction: 'tail' }) + return client.reduceStructuredAgentSession(client.EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: initial + }) + } + + return { + page, + subscribe, + events, + reduce, + render, + turn, + note, + seed, + get journal() { + return journal + }, + get client() { + return client + } + } +} diff --git a/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts new file mode 100644 index 00000000000..8fdfd9033de --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts @@ -0,0 +1,207 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { + importReleaseCheckoutModule, + importWorkingTreeModuleCopy, + materializeReleaseCheckout +} from './release-checkout' + +/** + * "Stopping…", paired across two builds. + * + * The host now publishes `stopping: true` on a working session's status summary, and the main + * agent's row carries it as `mainAgent.stopping`. Both are optional fields an older reader must + * ignore (Rule 1): an old desktop folds a new host's summary into a plain working row, and an old + * phone reads a new host's `worktree ps` row as working with its usual line. A new reader of an old + * host, which never publishes either field, must never read Stopping from it. + * + * The pre-change ref is pinned rather than derived: a newer baseline would carry the new reader. + */ +const PRE_CHANGE_REF = 'v1.4.214' +const SUITE_TIMEOUT_MS = 180_000 +const STATUS_FOLD = 'src/shared/structured-agent-session-agent-status.ts' +// Both builds load a copy in the checkout cache, out of reach of `mobile/tsconfig.json`. +const PHONE_ROW_READER = 'mobile/src/worktree/agent-row-display.ts' +const PROTOCOL_VERSION = 'src/shared/protocol-version.ts' +const MOBILE_ALLOWLIST = 'src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts' +const STATUS_STREAM = 'agentSession.subscribeStatus' + +const WORKTREE_ID = 'repo::/worktree' +const NOW = 1_000_000 + +type Summary = Record +type Row = Record +type Fold = (summary: Summary) => { state: string; mainAgent: Record } + +type HostRowModules = { + collectRuntimeWorktreeAgentSources: (args: Record) => unknown + attachRuntimeWorktreeAgentRows: (args: Record) => void +} + +type Build = { + fold: Fold + agentDotState: (row: Row, now: number) => string + agentDisplayLabel: (row: Row, now: number) => string + host: HostRowModules +} + +/** A working session's summary as the new host publishes it, JSON round-tripped. */ +const STOPPING_SUMMARY: Summary = JSON.parse( + JSON.stringify({ + sessionId: 'session-1', + workspaceId: WORKTREE_ID, + agent: 'claude', + status: 'working', + latestPrompt: 'ship it', + updatedAt: NOW, + stopping: true + }) +) + +/** A host's `worktree ps` rows for one structured session, JSON round-tripped. */ +function publishRow(host: HostRowModules, mainAgent: Record): Row { + const summary: Record = { + worktreeId: WORKTREE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } + const snapshot = { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + connectionId: null, + structuredHost: 'owned', + state: 'working', + prompt: 'ship it', + lastAssistantMessage: 'running the tests', + agentType: 'claude', + receivedAt: NOW - 1_000, + stateStartedAt: NOW - 60_000, + mainAgent + } + host.attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKTREE_ID, summary]]), + pathIndex: { byPath: new Map(), byRealPath: new Map() }, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: host.collectRuntimeWorktreeAgentSources({ + hookSnapshots: [snapshot], + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + } + }), + orchestrationByPaneKey: null, + getSummary: (map: Map, _paths: unknown, _missing: unknown, id: string) => + map.get(id) ?? null + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse of the rows the host just attached, which are plain objects. + const [row] = JSON.parse(JSON.stringify(summary.agents)) as Row[] + if (!row) { + throw new Error('expected one row') + } + return row +} + +async function loadBuild(ref: string | null): Promise { + if (ref === null) { + const [fold, display, sources, rows] = await Promise.all([ + import('../../../src/shared/structured-agent-session-agent-status'), + importWorkingTreeModuleCopy(PHONE_ROW_READER), + import('../../../src/main/runtime/runtime-worktree-agent-sources'), + import('../../../src/main/runtime/runtime-worktree-agent-rows') + ]) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this spec drives both builds' fold through one untyped surface. + fold: fold.structuredAgentSessionAgentStatus as unknown as Fold, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import of a copy is typed unknown; this is the current phone's row reader by path. + agentDotState: display.agentDotState as unknown as Build['agentDotState'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the current phone's row label. + agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed by its module; this spec drives both builds through one untyped surface. + host: { ...sources, ...rows } as unknown as HostRowModules + } + } + const checkout = await materializeReleaseCheckout(ref) + const [fold, display, sources, rows] = await Promise.all([ + importReleaseCheckoutModule(checkout, STATUS_FOLD), + importReleaseCheckoutModule(checkout, PHONE_ROW_READER), + importReleaseCheckoutModule(checkout, 'src/main/runtime/runtime-worktree-agent-sources.ts'), + importReleaseCheckoutModule(checkout, 'src/main/runtime/runtime-worktree-agent-rows.ts') + ]) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old build's summary fold by path. + fold: fold.structuredAgentSessionAgentStatus as unknown as Fold, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old phone's row reader by path. + agentDotState: display.agentDotState as unknown as Build['agentDotState'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the old phone's row label. + agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; these two modules are the old host's row surface by path. + host: { ...sources, ...rows } as unknown as HostRowModules + } +} + +let oldBuild: Build +let newBuild: Build + +beforeAll(async () => { + ;[oldBuild, newBuild] = await Promise.all([loadBuild(PRE_CHANGE_REF), loadBuild(null)]) +}, SUITE_TIMEOUT_MS) + +describe('cross-version Stopping', () => { + it('pairs two real builds that disagree on the field', () => { + // Anti-vacuous-pass oracle: one module resolved twice would make every cell same-version. + expect(oldBuild.fold).not.toBe(newBuild.fold) + expect(newBuild.fold(STOPPING_SUMMARY).mainAgent).toMatchObject({ stopping: true }) + expect(oldBuild.fold(STOPPING_SUMMARY).mainAgent).not.toHaveProperty('stopping') + }) + + it('an OLD desktop folds a new host summary into the plain working row it always drew', () => { + expect(oldBuild.fold(STOPPING_SUMMARY)).toEqual({ + state: 'working', + mainAgent: { state: 'working' } + }) + }) + + it("an OLD phone reads a new host's row as working, with its usual line", () => { + const row = publishRow(newBuild.host, { + state: 'working', + stopping: true, + stateStartedAt: NOW - 60_000 + }) + expect(row.mainAgent).toMatchObject({ stopping: true }) + expect(oldBuild.agentDotState(row, NOW)).toBe('working') + expect(oldBuild.agentDisplayLabel(row, NOW)).toBe('running the tests') + expect(newBuild.agentDisplayLabel(row, NOW)).toBe('Stopping…') + }) + + it('a NEW reader of an old host never reads Stopping, which that host never publishes', () => { + const { stopping: _stopping, ...oldSummary } = STOPPING_SUMMARY + expect(newBuild.fold(oldSummary)).toEqual({ state: 'working', mainAgent: { state: 'working' } }) + const row = publishRow(oldBuild.host, { state: 'working', stateStartedAt: NOW - 60_000 }) + expect(newBuild.agentDotState(row, NOW)).toBe('working') + expect(newBuild.agentDisplayLabel(row, NOW)).toBe('running the tests') + }) + + // The phone's chat reads Stopping from the status stream only on a host that lets phones call it. + it('an OLD host advertising the status feed refuses it to a NEW phone, which reads that as no feed', async () => { + const checkout = await materializeReleaseCheckout(PRE_CHANGE_REF) + const [oldProtocol, oldAllowlist, newProtocol, newAllowlist] = await Promise.all([ + importReleaseCheckoutModule(checkout, PROTOCOL_VERSION), + importReleaseCheckoutModule(checkout, MOBILE_ALLOWLIST), + import('../../../src/shared/protocol-version'), + import('../../../src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist') + ]) + const capability = newProtocol.AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY + expect(oldProtocol.RUNTIME_CAPABILITIES).toContain(capability) + // Anti-vacuous oracle: the old Set answers membership for a method it does grant phones. + expect(oldAllowlist.MOBILE_RPC_METHOD_ALLOWLIST).toContain('agentSession.subscribe') + // The capability alone is no grant: this host's mobile gate answers `forbidden`, which the + // phone's feed reads as absence (mobile-structured-session-status-feed.test.ts). + expect(oldAllowlist.MOBILE_RPC_METHOD_ALLOWLIST).not.toContain(STATUS_STREAM) + expect(newProtocol.RUNTIME_CAPABILITIES).toContain(capability) + expect(newAllowlist.MOBILE_RPC_METHOD_ALLOWLIST.has(STATUS_STREAM)).toBe(true) + }) +}) diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index da2f457ec8a..2a8f9a2fc85 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -1,3 +1,6 @@ +import type * as ClientReducer from '../../../src/shared/structured-agent-session-reducer' +import type * as ClientProjection from '../../../src/shared/structured-agent-session-projection' +import type * as ClientSchemas from '../../../src/shared/agent-session-journal-schemas' import type { sendPlan } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans' import { importReleaseCheckoutModule, @@ -14,6 +17,13 @@ import { export const WORKING_TREE = 'working-tree' as const +export type AgentSessionClientProjection = Pick< + typeof ClientReducer, + 'EMPTY_STRUCTURED_AGENT_SESSION' | 'reduceStructuredAgentSession' +> & + Pick & + Pick + /** Each build owns its own copy of the module-level host slot, so a host installed * in current source is invisible to a release checkout's dispatcher. */ const STRUCTURED_HOST_REGISTRY = @@ -69,6 +79,28 @@ export type AgentSessionWireBuild = { * journal it stops after the fingerprint check: a fingerprint it derives differently refuses * as `fingerprintMismatch`, one it agrees with as `sessionNotAttached`. */ admitSend: (sent: SentMessage) => Promise + clientProjection: () => Promise +} + +async function loadClientProjection( + load: (path: string) => Promise> +): Promise { + const modules = await Promise.all([ + load('/src/shared/structured-agent-session-reducer.ts'), + load('/src/shared/structured-agent-session-projection.ts'), + load('/src/shared/agent-session-journal-schemas.ts') + ]) + const client = Object.assign({}, ...modules) + if ( + typeof client.reduceStructuredAgentSession !== 'function' || + typeof client.projectStructuredItemsToNativeChat !== 'function' || + !client.EMPTY_STRUCTURED_AGENT_SESSION || + typeof client.AgentJournalRenderItemSchema?.parse !== 'function' + ) { + throw new Error('Release does not export the structured transcript reader') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: checked release exports; skew tests exercise admission, reducer and transcript signatures against real host frames. + return client as AgentSessionClientProjection } type DispatcherModule = { @@ -143,6 +175,14 @@ async function loadWorkingTreeBuild(): Promise { capabilities: capabilityStrings(protocol as unknown as Record), protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, methodNames: registeredMethodNames(methods), + clientProjection: async () => { + const [reducer, projection, schemas] = await Promise.all([ + import('../../../src/shared/structured-agent-session-reducer'), + import('../../../src/shared/structured-agent-session-projection'), + import('../../../src/shared/agent-session-journal-schemas') + ]) + return { ...reducer, ...projection, ...schemas } + }, createDispatcher: (runtime) => new module.RpcDispatcher({ runtime, @@ -177,6 +217,8 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise + loadClientProjection((path) => importReleaseCheckoutModule(checkout, path)), createDispatcher: (runtime) => new module.RpcDispatcher({ runtime,