From d60f999f94bde659ffc835e61b92f2e64dd12b2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:17:03 -0700 Subject: [PATCH] fix(native-chat): turn facts come from the turn record, and /compact is a message the chat sends (#23059) * refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff ()." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * fix(native-chat): the conversation outlives its agent Opening a chat no longer starts its agent. A conversation is reached through one host accessor that opens its journal at rest, and a send is what starts the agent, through the delivery loop. One idle sweep, every five minutes, stops an agent that has been quiet for thirty minutes and owes no work, then drops an open journal handle that is only a cache. Its record, tab, status row and readers stay. - hold and release are no-ops; hold still builds the host for shipped mobile builds. - The holders, the holds, the release clock and the exit respawn are deleted. - Options, the model list, the goal and the context meter answer at rest; a model pick at rest is recorded as intent for the next start. - Compact, rewind, clear and goal changes start the agent first. A send does too when a rewind is still in doubt after the conversation opens. - Orchestration routes mail and group addresses on ownership (the record plus the chat tab), not on whether the process runs. An open dispatch keeps its worker running. - The restart continuation is a send; Resume all holds each slot until the message is handed over or rejected. - A read error never replaces a loaded transcript, and shows the host's own words. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * fix(native-chat): a request that failed reads as failed A structured chat whose only message the agent's start refused read as a green finish, and a cancelled structured turn did too: the host published a verdict only for turn records, and structured rows carried no `interrupted`. The host projection now reads the session's latest request: its turn's outcome, or `failure` for a send the agent or its start refused. A send that was withdrawn, or left undelivered by a restart or a close, fails nobody and makes nothing listable. The ingest publishes `interrupted` as the hook lanes do, and every reader decodes the verdict through one accessor, so a failure reads Failed on the dot, the rollups, history and `worktree ps`, behaves like a cancellation in every clean-finish policy, and notifies as "failed". * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): a verdict change republishes the mobile status projection * refactor(native-chat): the store's retention trigger keeps its flag compare A verdict change always moves the completion clock the same check already reads, so a second verdict compare there caught nothing new. * test(native-chat): a user message the provider journaled keeps its session listed * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * fix(native-chat): a restart offer ends when the chat's agent starts again The offer used to end only when the chat's newest user message changed, because opening a chat started its agent and that start could not be told apart from real activity. Opening a chat starts nothing now, so the host reads the fact it already publishes: a chat's status row goes from not host-owned to host-owned exactly when its agent is started. At that edge the offer and any failure record for the chat are withdrawn, unless the start is a resume action's own (its continuation is the oldest undelivered message). A continuation and a message racing to be first are decided at acceptance: the continuation is refused, quietly and with nothing filed, when any other message was accepted since the restart. A failed continuation start leaves the offer retryable, and each resume action sends its own message id. Deleted: the newest-user-message comparison, its journal reader, the continuation filter, and the failure ledger's own "answered by the chat" check. The marker still carries its message id for one release, so the previous build can read it. * fix(runtime): end a transcript stream when its client unsubscribes Desktop: the IPC subscription controller was dropped as soon as the streaming handler returned, which for most streams is right after it binds. A later runtime:unsubscribe then found nothing to abort, so the host kept the subscriber and derived and sent every publish to a channel no one listened to. The controller now lives until the renderer unsubscribes, resubscribes the same id, or goes away. Mobile: disposing an agentSession.subscribe stream now sends agentSession.unsubscribe with the stream's frame id, so the host ends that subscriber and leaves a sibling stream on the same socket running. The direct path now passes the frame id the relay path already passed. * fix(native-chat): a late provider-session update keeps a failed recovery record failed A provider-session heartbeat that rewrites a completed recovery record kept its interrupted flag but dropped the outcome it was copied with, so a live failed checkpoint read as a clean finish until the next status write. * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * test(native-chat): the terminal-bell check asserts the renamed verdict field The bell notification test still checked for agentInterrupted, which no longer exists, so it could not catch a verdict leaking into a bell dispatch. * fix(native-chat): a failed turn ranks like a completion for attention Attention readers (completion time, Smart Sort, sticky retention, Cmd+J Recent) now demote only a turn the user stopped. A failure is news the user has not seen, so it keeps its completion time, ranks in the Done class, stays retained after its pane goes away, and a retained failure reads failed in the worktree rollup instead of done. Clean-finish policy (hibernation, pane ownership, the value moment) still treats a failure like a stop. The retention trigger compares verdicts again: success -> failure no longer moves the completion clock. * fix(native-chat): one fact ends a restart offer: the chat moved on since the restart The offer is live while no other message has been accepted in the chat since the restart and its agent has not proved a start since. The offer list, the resume's reservation check and the continuation's acceptance check all read that one fact, so a message whose start then failed withdraws the offer too, and a stale click finds nothing to act on. The fact is read off the conversation's open handle, which the restart closed, so it is retired durably whenever it may have changed: a message accepted, a start proven. A close and reopen within the same run therefore cannot bring the offer back. A continuation rejected before it reached the agent does not count, so a retry after a failed start still runs. Deleted: the quit-time gate on withdrawal, which changed nothing because the withdrawal and the quit's own offer write share one queue; the per-action "withdrawn" flag and the separate acceptance check it paired with. * test(native-chat): an older build reads the restart offer this build records The offer lives in a file the previous release reads after a downgrade. Pin that against the pinned release's own capsule, and run the lane when the marker or the capsule changes. * fix(native-chat): read a restart offer against where the journal stood when it was taken "Since the restart" was read off the conversation's open handle, which the idle sweep closes: after a reopen, a message the user had already sent looked older than the handle and the withdrawn offer came back. The offer now records the journal position (epoch and sequence) at the moment it is taken, and a message accepted after that position, or a journal on another epoch, means the chat moved on. That is derived from the journal, so it holds across any number of closes and reopens. An older build's offer has no position; only a start withdraws it. Because the message half is now durable, the offer is no longer rewritten in the recovery file on every accepted message; a proven start still writes it, since only the host that saw the start knows of it. * test(native-chat): wait for the listing's retire write before reading the recovery file * refactor(native-chat): every journal row states which turn it belongs to Rows gain a turn scope stated by the write that creates them: the open root turn, or the conversation. A queued message takes its scope from its handover. Rows stored before scopes existed are placed on replay by the root turn open when they were created, so no persisted state is needed for them. Rewind keeps each retained row's scope and producer, so a subagent's row stays its own. * fix(native-chat): keep the terminal-backed chat's read error over its local echoes Messages winning over a read error is right for the structured chat, whose read retries and whose messages came from the transcript. The terminal-backed view assembles its list from local echoes too (a launch prompt, a pending send), so a failed read there showed only those bubbles and no error. Only the structured pane now keeps messages over an error. * fix(native-chat): a start retries the exit settlement a failed journal write left owed An agent exit whose journal settlement write failed releases the lease latched until a retry lands. Reopening the chat used to be that retry; with reveal now only opening the journal, nothing retried it before the next app launch, and every send was refused. The start the send needs now runs the retry first, where the attach would. * fix(native-chat): a failed main agent reads failed while its subagents still work The verdict is now read from the main agent's own state, not the folded row: a main agent that is done and failed has a verdict even while its subagents keep the row working. Without mainAgent (history, worktree ps, older hosts) the old combined-done rule stands. Display marks the verdict through agentVerdictDisplayMark: a failure outranks every combined state on the agent's dot, label, tab badge, dashboard and activity rows; a stop marks only a done row, so a successful or stopped main agent with live subagents still reads working. Subagent rows keep their own state. The worktree card, terminal tab and Cmd+J rollups share one pane fold and rank a pending question, then failed, then working, monitoring, interrupted and done. worktree ps publishes the main agent's outcome on a working row, and the mobile mirror reads it. The store's change check, the paired-client mirror's equality and its epoch now see a verdict change on a working row, which otherwise moves no state or clock and left the worktree card reading working. Clean-finish policy is unchanged: a working row is never hibernated and has no completion time. * perf(native-chat): answer the owner check without opening the chat Worktree activation calls agentSession.handoffStatus for every chat tab in the worktree, and the answer comes from the session record alone. Reaching it through the accessor opened each resting chat's journal (a full read, the crash-boundary write and a restored status publish), then kept it open for the idle window. It now checks the record and the adapter's support, as before this series, and opens nothing. * fix(native-chat): a read waiting on the session lock opens nothing once quit began The accessor checked for quit before queueing the open, so a read queued behind a session task ran its open after teardown had begun and indexed a journal no teardown step would close. The check now runs at the open itself. * test(native-chat): pin stated turn scopes, the upcast of unscoped rows, and rewind attribution * fix(native-chat): /compact is a message the chat sends, run as a turn of its own The conversation command RPC now accepts /compact into the queue like any send and answers once it is handed over. The delivery loop opens the command's own turn, starts the provider on it, and waits for the provider's end off the session's queue, so messages typed meanwhile are held and delivered after it, even when it fails. It settles by re-reading the journal: a child that died meanwhile already wrote the verdict. Stop ends the command at once. The 180 s completion window, the unconfirmed row and the recovery of an older build's compaction record are gone; that record no longer gates anything. On Codex the provider turn the command opens is claimed into the command's turn. * fix(native-chat): read a failed resume's chat before calling it retryable Whether a failed resume is retryable is the offer's own rule: the chat has not moved on since the restart, read from its journal. The failure list read it only for a chat already open, so once the idle sweep closed a chat the user had moved on in, its failure showed Retry again, and the click did nothing. The list now opens the failed chats first, as the offer list does. * test(native-chat): type the provider event sink the settlement test reaches for * docs(native-chat): the worktree ps outcome comment no longer claims old hosts send it The field is new: an old host sends no outcome at all, so a reader falls back to interrupted. The removed clause said old hosts send it on done rows, which never shipped. * fix(native-chat): say the structured read keeps trying only where it does The structured pane's "Orca keeps trying to load it" line never showed: the view state filled in an untranslated fallback whenever the read error had no text, and the empty state prefers any message. The view state now leaves the message out, so the structured pane shows that line and the terminal-backed pane its own translated one. Mobile's structured lane does not resubscribe after an error frame, so it no longer makes the claim. * fix(native-chat): rows group under the turn their record names, not the one above them Each row's turn is the turn its stated scope names, anchored on the entry that opened it, or on the turn itself when the provider opened it unasked. So /compact groups its own rows and the previous turn is untouched, a message typed into a running turn joins it, and a provider-resumed turn folds under its own Worked-for. A row reporting how a turn ended, an error or the compaction separator, never folds. Desktop and mobile read the same keys; a host that states no scope keeps today's positional grouping. * test(native-chat): await the send's settlement instead of polling for the start The at-rest send tests polled for the provider start with vi.waitFor's one-second default, which a loaded machine outran. They now await the host's own settlement of the message. * docs(native-chat): the status-store listing rule names provider-journaled user messages * fix(native-chat): a restart offer resumes any time after the quit, and knows its own continuations The continuation's message id was dated by the quit, and the ledger refuses a new id dated more than a day back, so Resume or Retry a day after quitting was always refused (on main too). It is now dated by the resume action. Telling a rejected continuation from the user's own message read the operation ledger, whose rows expire after about a day; after that a failed resume stopped being retryable. The offer now records the continuation each action sends on its own capsule entry, bounded to the newest 16, so the ids end with the offer. The ledger read is deleted. * fix(native-chat): a /compact is not a request the sidebar, notifications or restart resume report The sidebar's prompt, preview, verdict and instant, the turn-completion feed, and the restart-resume marker read past a conversation command and its turn to the last real request, so a /compact neither notifies nor re-dates the row, and a command in flight is never offered as work to resume. An older client shown a command's turn in the legacy form names the session's own agent. * fix(orchestration): route no mail to a structured worker its orchestration released A structured worker is routed on ownership, and a resting worker's lease is released, so ownership held while its chat tab stayed listed. A worker the coordinator abandoned and then released, found at rest by the release, therefore still took peer mail and @worktree: broadcasts, and each one restarted its agent. Routing now also reads the orchestration's own resource row: once it is released, direct mail, group addressing and worker-show's addressable answer drop the worker, as they would a terminal worker whose terminal closed. The chat tab stays, and nothing new is stored. * fix(native-chat): a failed retry names the user's prompt, not Orca's continuation A resume's continuation is written to the chat before its start, so after a failed attempt the chat's newest user message is that rejected continuation. A second failure then showed Orca's own restart text as the chat's prompt. A retry now keeps the prompt its first failure named. * test(native-chat): pin what a conversation command's admission refuses at rest and at handover * test(native-chat): tests merged from the base state which turn their rows belong to * fix(native-chat): a refused send notifies failed through the completion feed The host's completion feed followed only the newest turn, so a send the agent or its start refused, which creates no turn, read Failed on its row but sent no notification. The feed now follows the session's latest request, read from the projection the status feed already makes for the commit: a turn keeps its id, a refused send is named by its journal item key. It announces only while the session is idle, as the row reports a verdict, so queued sends refused one commit at a time notify once, and a withdrawn send falls back to a request already announced. * fix(orchestration): read the released row optionally, as the authority does worker-show's observation called the row lookup directly, which a runtime double without it threw on and failed the structured tab-retirement release. * chore(native-chat): one import per module and no unexplained casts in the turn-scope changes * test(claude): pin which turn a Claude row joins, including a subagent's after the turn ends * fix(native-chat): the status bar drops a restart offer the chat moved on from The renderer re-read the host's restart offer only when a failed chat showed activity, so after a message withdrew a pending offer the host answered no chats while the status bar kept counting one, and clicking it opened nothing. The same watch now covers pending offers: a status change in an offered chat asks the host again, once. * fix(native-chat): a refused steer is read from the turn its handover named The latest-request reader decided whether a refused send had joined a running turn by comparing host clocks: its handover time against the previous turn's end. The handover row now states the turn it delivered into, so the reader reads that instead and the clock comparison goes. A journal written before handover rows stated a turn is scoped on replay from the turn open when each row was written, which can differ from the clock reading only when a send and a turn's end share a millisecond. * fix(mobile): the native-chat controller contract carries the turn journal The controller and overlay already pass nativeChatTurnJournal, but the contract type never declared it, so mobile failed to typecheck. * fix(native-chat): the live turn is the running turn, not the newest user row A turn the provider opened on its own (a background wake, a resumed turn) anchors on its own record, but the list still treated the newest user row as the live turn. While such a turn ran, the settled user turn before it lost its duration and the running turn's own rows were drawn as settled, so its tool calls lost their live state. nativeChatTurnMembership now answers both questions from the turn record: each row's turn, and the live turn (the running root turn's anchor, else the newest user row, which is also all an unscoped host has). Desktop and mobile key liveness, the timing clock and the live status's row on it. * test(native-chat): a turn the provider opened keeps its own clock Pins that the local turn clock follows the live turn, so a wake after a settled turn does not restart that turn's clock when no host durations are recorded. * fix(native-chat): a running turn no message opened draws its status on no row Its live status belongs to the transcript-tail indicator alone. Once it settles, its duration draws at its first row as before; a running turn a message opened still draws on that message. * fix(native-chat): every copy of a row carries the main agent's own status History entries, sleep records and `worktree ps` rows carried a flattened top-level `outcome`, copied under different gates and without the main agent's clock. They now carry `mainAgent` (state, outcome, stateStartedAt), the type the live row already persists and sends, and every copy site takes it with `interrupted` through one function, `agentVerdictFields`. - The accessor reads `mainAgent` then the legacy flag; the mobile mirror matches it line for line. - Sleep records admit `mainAgent` with `normalizeMainAgentStatusField`, so a malformed value drops the field, never the record. - Mobile dates a main agent that failed under live subagents by its own clock, as desktop does, and its row equality compares `mainAgent`. - The activity feed reads a history entry's own `mainAgent` instead of rebuilding one; the sync key and history equality compare it. * test(native-chat): pin the worktree ps verdict across host and phone versions Pairs the real v1.4.212 host and phone row reader with this build: an old phone reads a new host's rows by `interrupted`, a new phone reads an old host's rows (no `mainAgent`) the same way, and a new phone reads a failure under live subagents as Failed, dated by `mainAgent.stateStartedAt`. The release checkout now carries the phone's self-contained row reader, and the lane runs when the `worktree ps` row producers change. * test(mobile): name the parity table's row for its role * test(native-chat): a roster of idle or finished children does not keep an agent awake The sweep reads owed background work through the shared child-work liveness that upstream's release clock adopted; a child that went idle or finished is not work the agent still owes. * fix(native-chat): a request that settles while the user is asked something notifies once The completion edge waited for an idle session, and a pending prompt (including a subagent's approval) is not idle. Structured chat has no other attention producer, so a main turn that finished while a subagent waited on the user sent nothing until the prompt was answered. The edge now waits only on owed work (a running turn or an unanswered send), which the projection reports even beneath a pending prompt. A request that settles with a prompt pending announces once; the renderer words it "needs input" from the host status mirror's `attention`, and answering the prompt keeps the same request identity, so it does not announce again. The wire shape is unchanged. * fix(orchestration): a task dispatched into a resting structured worker keeps it running The sweep's open-dispatch check read only the worker-start dispatch that owns the worker's terminal resource, so a task later dispatched to the same worker (orchestration dispatch --to, which writes a dispatch with no worker row) did not count: after thirty quiet minutes the worker was stopped while that task was open, and its coordinator read exited. Any unsettled dispatch addressed to the worker's process incarnation now counts, derived from the existing rows. * fix(native-chat): a command's wait ends when its child does The delivery loop waited for a /compact only on the adapter's compaction tracker, which learns of the child's end only on some exit paths: a Codex exit or close, and a Claude close, never reach it. The wait then never ended, so nothing queued behind the command was delivered again, Stop had no child to answer through, and the tracker's leftover entry refused the next /compact. Every way a child ends passes endProviderChild, so the host now offers a per-child end signal there. The loop races the tracker against it (the dead-generation settlement has already written the command's verdict), and on that end asks every adapter to release the command, so a later command runs and no later provider turn is claimed into the dead one. The adapters' own exit-time releases were unreachable (Codex) or covered one path of several (Claude), and are removed. The Codex RPC test harness moves to its own module so the exit can be driven through the real adapter's connection callback. * fix(native-chat): keep refusing sends during a command on an older host An older host's controller still refuses a send while a conversation command runs, so dropping the client's block turned every message typed during /compact into a 'not sent' row with Retry there. The block stays for hosts that do not run the command as a send-path turn, and goes only for those that do. The signal is one the client already holds: a host that runs /compact on the send path states a turn scope on every journal row it writes, the same fact turn membership uses to tell it from an older host. Both now read it from one predicate. On an empty conversation, or one whose rows all predate the upgrade, the signal is absent until the command's own entry streams in, so that brief window keeps the old local refusal; no capability or wire field is added. * docs(native-chat): comments stop describing the hold this PR removed Eight comments still justified orderings and teardown choices by a viewer or dispatch hold that pinned the provider child. Nothing holds any more; the orderings stand for the binding's redrive subscription and parked mail, and a chat's agent runs from a send until the idle sweep rests it. Comment-only. * fix(native-chat): the completion says when the user is being asked A request that settles while a prompt waits on the user was worded "needs input" from the renderer's status-feed mirror. Remote clients receive the status and completion streams over separate sockets, so they can arrive in either order and the wording could be wrong both ways. The host already knows at emit time, so the completion now carries an optional `awaitingUser: true` in that case and omits it otherwise. The renderer words the notification from that field alone and no longer reads the status mirror. Old clients ignore the field and word by outcome; old hosts never send it. * fix(native-chat): a restart offer keeps the start its own continuation made Whose start ended an offer was decided at read time, from whether the offer's continuation was still the queued message. Once the provider refused that continuation, the child it had started read as someone else's start, so the offer ended and its failure showed no Retry. The delivery loop now records which queued message a start is for on the in-memory child, and the child's end carries it; the offer counts a start as its own when that message is one of its continuations. * fix(native-chat): a rewound turn still names the message that opened it A Codex rewind rebuilds the epoch without submissions, so each sent message survives only under its provider key. The kept turn records still named the submission key, so each turn anchored on itself and its rows grouped apart from the message that opened it. The rewind now renames the turn's opener along with the message. * fix(native-chat): Stop ends only the command it names Stop on a command turn abandoned whatever compaction the session had pending, so a late Stop for an earlier /compact cancelled the one running now. The tracker now ends a command only when the Stop names its turn, and the cancel reply reports whether it did. * fix(native-chat): an agent gets a full idle window after its owed work ends The sweep measured quiet only from the last journal row, so once a subagent, command, monitor or dispatch that had outlived the window ended, the agent was stopped at the next tick. A child can read done before the lead's wake-up turn writes anything, and stopping in that gap loses the wake-up. The sweep now counts owed work it observes as activity, which gives the agent the full window afterwards, as the release clock it replaced did. * test(claude): the options-read fixture runs a live child The fixture marked its conversation running with a hasProviderChild field the session type does not have, so the read took the at-rest path and refused a session with no record. It now carries a child, which is what the read checks. * test(native-chat): host tests reach its collaborators through a typed seam The rest-test rig and three test files read the host's private members with Reflect.get and cast the result. The host now exposes one test-only accessor, collaboratorsForTests(), and the subscribers class a subscriberCountForTests() beside its existing retainedActivityCountForTests(), so the tests are checked against the real types and the casts are gone. * fix(worktree-status): a departed agent's failure yields to live work on the worktree card A retained failed agent has no expiry, so ranking it with a live failure pinned the card to Failed over other panes' live work. It now ranks below working, monitoring and permission, and above every finished outcome. * refactor(orchestration): one owner answers a structured worker's custody Routing, group addressing, worker-show and the idle sweep each composed their own reading of whether orchestration still holds a structured worker, so each new obligation or retirement state had to be added to every reader. structured-worker-custody now derives both answers from the worker-terminal list state coordinators see in worker-list: addressable is owned and not released, and owed work is an active custody or an unsettled task dispatched to the same incarnation. The owner's state is read through the remote dispatch attachment too, as the terminal transfer lookup already does. Behaviour is unchanged; a settled worker awaiting its coordinator still rests. * refactor(orchestration): owed work is an open dispatch on the worker's incarnation A supervised worker's own dispatch context stays open exactly while the worker is active, so the separate active-custody branch only repeated it. Owed work is now one fact, which also states the policy that a worker awaiting its coordinator's decision may rest, and both custody decisions are written once at the top of the module. * docs(agent-status): a departed agent's failure ranks below live work on the worktree card * fix(native-chat): a restart offer knows its continuations by a tag in their id The offer recorded each continuation id in a list on its capsule entry, capped at 16, and a running action's id in memory. Both could disagree with the journal: past the cap an old rejected continuation read as the chat moving on, and a crash during a retry restored the failure's older entry, which lacked the retry's id. Each continuation id now carries a tag derived from the offer (its teardown and chat), then the action's own part, so any continuation of this offer, queued or rejected, is recognised from the journal row and the marker alone. The persisted list, its cap and the in-memory action map are deleted; the agent-start withdrawal keeps an offer whose own continuation the start was for, read against the stored marker. * test(runtime): the legacy-worker reveal test judges its stale snapshot inside the wait The tui-idle probe reads through readTerminal, which now awaits the structured worker check before the PTY read, so the probe's snapshot request starts a microtask later. vi.waitFor missed it on its first check and polled again at 50 ms, the same moment the wait's own 50 ms timeout fired. The stale snapshot then resolved after the wait had already timed out, so the test passed without judging it, and the rejection landed before any handler was attached. Vitest reported that as an unhandled error and failed the shard. Polling every 1 ms sees the request within a few ms, so the snapshot is judged while the wait is still pending. * fix(native-chat): a message held behind /compact is drawn where it was handed over A message typed while /compact runs was drawn above the compaction's result, between itself and its own answer. The reducer kept every item at the sequence and timestamp of the row that created it, and a queued message is created at acceptance, long before the command it waits behind writes its result. The phone orders by that sequence and the desktop by that timestamp, so both put the message first. A queued message now takes its position from its handover row, the same row that already states its turn scope. Everything the agent did before the handover, a command it waited behind included, draws above it. This holds for every held message, not only /compact's, and needs no client change: every client, older builds included, reads the position the host publishes. A live batch already carries the item when its dispatch row lands, and history pages cut the reduced timeline by sequence, so paging stays contiguous. * fix(native-chat): a phone's send during /compact answers without waiting out the compaction A client that predates accepted-send replies, which is every phone build, has its send reply held until the host hands the message over. A message sent during /compact is not handed over until the compaction ends, so the phone's 15 s request timeout fired first and showed the message as unconfirmed. That wait now also ends once the message is queued behind a running command. This is read from the journal's running turn and needs no new state. Every other wait still ends at the handover: behind a starting child or an ordinary turn, and for restart resume, the command front door and orchestration, which keep the plain handover point. * perf(native-chat): a rewind places provider items with one pass over the merged rows A Codex rewind gives each provider item the old epoch never held the turn record for its provider turn. It found that record by scanning every merged row, restoring each row's body, once per provider item. That is quadratic, and it runs on the host's main thread up to the journal's 10,000-row cap, twice per rewind. A rewind record written before rows carried their scope holds no scope for any provider item, so it paid the full cost. The merge now indexes turn records by provider turn id once, keeping the first match as the scan did, and each provider item looks its record up. * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * fix(native-chat): a message waiting behind /compact is drawn after it until it is sent A message sent while /compact runs is placed where it was handed over. It was still drawn where it was accepted until then. /compact writes its result one step before the handover, so for that step the waiting message sat above the compaction's separator. A message the host accepted but has not handed over is not part of the conversation yet, so both clients now draw it after everything the agent has done. The shared projection moves it to the end, which is the order the phone draws. The desktop ranks it with the other not-yet-sent rows, after the streaming preview. At handover it takes its place from its handover row, which is also after the separator, so it never appears above the compaction it waited for. * fix(native-chat): the idle sweep reads owed work every tick Owed work counted as activity, but the sweep read it only once the idle window had elapsed, so it refreshed the clock at most once a window. Work that ended just before the next read left the agent to be stopped at that read, moments after the work ended, which is the gap the refresh was meant to cover. The sweep now reads owed work on every tick for a started agent, so the window always runs from the last tick that saw work owed. * fix(native-chat): a continuation handed to the agent stays sent The offer read its own continuation as not reaching the agent while its dispatch was pending, which also covered one already handed over and still unanswered. When the wait for that answer ended first, the failure it filed read as retryable, and a retry sent a second continuation to an agent that may have acted on the first. Only a continuation still queued, or rejected, is now read as unsent. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(cross-version): load the phone row readers without mobile's toolchain Vite transforms a file against its nearest tsconfig, and mobile/tsconfig.json extends expo/tsconfig.base.json, which the root-only cross-version lane never installs. The worktree ps verdict suite imported the current phone row reader from mobile/ directly, so CI failed with TSConfckParseError before any test ran. The harness now imports a copy of the working-tree reader placed under the checkout cache, where the root tsconfig applies, as it already does for the release checkout's copy. Both readers are still the real files. * test(cross-version): keep the checkout path-guard message and justify the copy import's cast * fix(native-chat): a command ends only by its own provider answer or its child's end Stop no longer settles a conversation command. It interrupts it like any turn, and when the provider cannot take that (Codex has not opened the command's turn yet, or Claude refuses the interrupt) it stops the child, whose dead-generation settlement writes the verdict. The pending command now lives on the provider child's own session instead of an adapter-wide map keyed by session, so it dies with the child and nothing has to release it. Claude's /compact is sent under a uuid the slot records, and only a root result naming that input (or naming none) ends it; its outcome is read with the ordinary result reading, so a stopped /compact is a cancellation. * fix(native-chat): a command's settle answers its message before ending its turn The two writes are not one batch. Writing the message's answer first means a crash between them leaves a running command turn, which the stale-turn sweep already settles, instead of an ended turn whose message reads as in flight forever. The settle now writes only while the command turn is still running. * fix(native-chat): "Worked for" counts from the handover, not the send A message held behind /compact, or behind a cold start, used to count the wait as the agent's work, although its row is drawn at the handover. Every handed-over submission's turn, the command's own included, now starts at the handover row's instant, falling back to the send time for a host that recorded none. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): the interrupted create's own retry continues again The merge of main's lease-latch fix replaced that test's retry of the interrupted create, under its own operation id, with a fresh start whose result nothing read. That fresh start passes with the released-reservation continuation deleted, so the case the fix exists for went untested. The retry and its assertion are main's again. * docs(native-chat): three comments that still had views starting agents A start with nothing queued now comes from a command, goal change or rewind; an interrupted compaction left alone would refuse every send, so no agent would ever start to finish it; and a current host raises the unattached read refusal only once quit began, with the attach window belonging to an older host. * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(native-chat): a second Stop on a command ends its child; one compaction verdict for every provider A Stop's note now names itself in its key, so a later Stop on a command still running reads, from the journal, that the provider was already asked and never answered, and stops the child instead of interrupting again. Nothing is held in memory for it. Adds the rule both translators will read a compaction's end by: only a compaction the provider reported is a success; none after Orca's interrupt is a cancellation; anything else is a failure. A real Claude capture, pinned as a fixture, is why: a stopped /compact ends in the same success result as a finished one. * test(native-chat): a reader's open settles the turn a failed exit settlement left running An exit whose settlement write failed leaves its turn running in the open journal. PR 1's open now settles it, and this pins the two reads that reach it here: a reader reopening a chat the idle sweep closed, and a read that opens the chat before the restart restore reaches it. * test(native-chat): the view-start test's starting window outlasts two subscriptions on a loaded runner A subscription reads the conversation before it returns, so under load the two views took longer than the create child's 300 ms start, which then exited before the test checked that it had not. The child now takes a second to fail. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * refactor(native-chat): the provider's translator ends a command's turn; the loop holds no command state A conversation command is now a turn of the provider child's own journal pipeline. The adapter-wide tracker, its promise and the loop's settle step are gone. - Codex: the translator claims the provider turn that carries the command, scopes its rows to the command's turn, and writes the command's end in the same batch that settles that turn. Codex's own compaction marker is the success row. - Claude: the command's turn is the translator's open turn until the result that answers the /compact input ends it. The command's own frames, such as the continuation summary, its echo and "Compaction canceled.", draw nothing. - Both read the end with the one compaction rule: success needs the provider's report of the compaction; none after Orca's interrupt is a cancellation. - The message resolves at the provider's receipt, as any send does: the Codex ack, or the Claude slash-command waiter on its result. The host writes a command's end only when the provider never took it. - The delivery loop stops while a command's turn runs, and every journal commit re-wakes it through the session's serialize, so an end that lands while a step decides to stop is never lost. A child that ends first is settled with it. * test(native-chat): pin a command's end to real /compact frames and to each path it threads The captured /compact frames drive the Claude translator's command turn: a finished compaction ends as a success with only the separator drawn; a stopped one ends as a cancellation with no failure row, and the next send answers in its own turn; a result naming another input ends nothing. The command's end is checked at each point the ordinary result path threads through: the reopen latch after a failure, the settling of a child still working, the context facts the result reports, and the provider's own error row. On the host: a message held behind a command is handed over when the command ends just as the loop stops for it, a refused command settles as a failure and the loop moves on, and a Claude child that exits mid-command settles the command and hands what waited to a fresh child. * test(native-chat): tests merged from the base state which turn their rows belong to * refactor(native-chat): drop the child-end waiter nothing waits on A command no longer waits for its child here: its turn ends from the provider's frames or from that child's settlement, and the delivery loop is woken by the commit. The waiter and its test were left from the earlier shape. * fix(native-chat): a command holds the queue only while its child runs it The delivery loop stopped whenever the journal showed a command's turn running. When the command's child ended and its settlement could not be written, that turn stayed running with no child to end it, and the loop's gate kept it from ever starting the next child, which is what settles a gone generation's leftovers. Every later send was held for good, and Stop had no child to end. The gate now holds only while the conversation has a child: with none, the command belongs to a gone generation, and the loop's start settles it like any turn a dead child left running. * fix(native-chat): a Claude /compact succeeds only on its compaction boundary The command's evidence counted Claude's `compact_result: 'success'` status as the compaction done. That status comes before the boundary that replaces the history, so a Stop landing between the two read as a finished compaction even though no boundary was ever written. Only the boundary now counts, as the rule for both providers states; the capture's finished compaction carries one, so it still reads as a success. * fix(native-chat): a Claude child's exit says why the turn it ended stopped When a Claude child exited mid-/compact, the command showed "Worked for 0s" and no reason. The child's translator ends its open turn the moment the exit is reported, stamped with the exit's instant, so by the time the exit settlement ran nothing was running. The settlement recognises a turn the exit already ended by that same instant, but the Claude lifecycle event dropped it on the way to the host, which then used its own clock, matched nothing, and wrote no row. When the clocks did agree, the row was scoped to the running turn, of which there was none, so it landed outside the turn it explained. The exit's instant now reaches the host, and the exit row belongs to the turn the exit ended: still running, or ended by the translator at that instant. * fix(native-chat): a message waiting behind /compact draws below its live activity A message sent while /compact runs waits on the host until the command ends. Both clients moved it to the end of the transcript rows, but the running turn's live activity line ("Compacting the conversation") draws after every row, so the waiting message sat between the command and its own live status. A row that is queued, and not what the live turn is for, now draws after that live activity: on desktop outside the transcript window, below the activity line; on the phone in the list footer, below the live status. A message whose own start is pending still draws above the activity that start reports. * fix(native-chat): only a running command holds a message below its live activity A message is accepted, then handed over a moment later, and in between it reads as waiting. Every message waiting behind a live turn drew below that turn's activity line, so an ordinary message sent while the agent was working crossed below "Thinking" and jumped back up once it was handed over, on desktop and phone. Only a conversation command's turn holds the queue on the host. A message now waits below the live activity only while the running turn is one a command opened, read from the entry that opened it. The phone test also typechecks, which the mobile test ratchet requires. * test(codex): the claim test names its notification params as a record * test(native-chat): a read that reaches a crashed chat before the startup reconcile settles its turn On desktop the chat on screen at relaunch reads before startup reconciles the leases, while the dead process's lease still reads live. The open settles the turn it left running anyway, and the restore that follows finds it settled. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * docs(native-chat): drop the removed dispatch hold from six comments A worker's session no longer takes a dispatch hold, and no release clock rests a chat by visibility; the agent-launch comments, the abandon test, the teardown test and the refusal census still said so. * test(native-chat): rest the owner-status chat through the idle sweep, not a hold The activation-gate test from #22808 put its chat at rest by holding and releasing it, and passed the release-clock grace. This branch deleted both, so the case threw before it reached its assertions. It now moves the host's clock past the idle window and lets the sweep stop the agent and close the conversation, then asserts the same owner answer and activation gate. * fix(native-chat): show the structured pane's retrying line when a read fails The read transport always hands the pane the host's words, so the error state's "Orca keeps trying to load it" line, which showed only when there were none, was never seen: the pane showed the host's text twice, as its subtitle and on the status line under it. The structured pane now always says its read keeps retrying, and the host's text stays on the status line. The terminal-backed chat is unchanged. * fix(native-chat): a send the provider never received after a restart has no verdict Restart reconciliation rejects a crash-stranded send that is absent from a trustworthy provider history with reason 'not_delivered'. Nobody failed that send, but the verdict allowlist did not name it, so after a crash the chat read Failed, was listed, and could notify "failed". Give the reason a shared constant (persisted value unchanged), add it to the no-verdict set, and treat it as an internal marker so the Retry row no longer shows the raw string. * fix(native-chat): a failed Codex compaction's late completion writes no turn of its own Codex ends a failed turn with an error and then still completes it as failed. The error settled the compaction and released its claim on the provider turn, so the completion read that turn as an ordinary one and wrote a stray record. The claim now lasts until the completion, which adds nothing to a command the error already ended. * test(native-chat): the mid-command exit case resumes its next child as a real one does The case's fake started every child as a newly created thread with the same generation. The store refuses a created link once the conversation has a thread, so the next child's start failed and wrote its own error row, which landed before or after the case read the journal. The next child now resumes the thread under its own generation, and the case reads the journal once the waiting message is delivered, which also proves the loop moved on. * test(native-chat): wait for a send's background start before the refusal oracle removes its store An accepted send wakes the delivery loop, which starts the agent in the background. The oracle's teardown disposed the loop but did not wait for that start, so its lease write could create a temp file in the store directory while the directory was being removed, failing the test with ENOTEMPTY about one run in four. The teardown now drains tracked starts before it closes the journals. * fix(native-chat): a start a message waited on gets one failure row, the delivery loop's When a queued message's start failed, two writers could report it under the same row: the delivery loop, when the adapter settled the start without proving it, and the exit settlement, when the child's exit landed. The last one won, so the chat's row could name a different cause than the one the message was rejected with, or be written twice. The exit settlement now writes the start's row only when no message is queued and the loop has not already recorded that start. A start for a command, goal change or rewind, with nothing queued, still gets its row from the exit. * fix(native-chat): a /compact whose start failed says to run /compact again The failure-words context named only /clear as a command to retry, so a /compact whose agent failed to start read "Send your message to try again." on its row, its rejected message and the command reply. The context now carries any conversation command; the host derives it from the oldest message still waiting on the provider, which is the one a failed start fails first, and the /compact reply names it directly. * fix(native-chat): a Codex /compact ends only on its turn's completion, below Codex's own error row Since only turn/completed ends a Codex turn, Codex's turn-ending `error` is a row inside the still-open command turn, and the failed completion that follows it is the command's end: completed, outcome failure, at the completion's receipt time. The command's own "Compaction failed" row was written on that completion too, so a failed /compact read its reason twice. The command turn now notes when Codex's turn-ending error for the turn it carries was written as a row, and its end then adds no second row. A retried stream error ends nothing and is not counted. The flag that let the error end the command and kept the claim until the completion is gone with the error-driven end. A test replays the captured failed compaction from the real app-server through a claimed command turn. * test(native-chat): main's crash-turn test states its row's turn, and a dead /compact settles on its recorded exit Two tests the main merge brought together: - The crash-turn test from #23456 writes a turn record through the event sink without options; every row here states its turn scope, and a turn record's is the thread. - The /compact whose exit settlement could not be written no longer stays running until the next start: main now settles an open chat from the exit it recorded, so the command reads interrupted before the next message, which is then delivered. * test(native-chat): main's new journal tests state each row's turn The crash-turn, stale-turn and sink-queue tests main added wrote rows without a turn scope, which every item write now states. Rows written inside a running turn name that turn; the sink-queue batch and a send handed over with no live turn name the thread. * fix(native-chat): draw a queued turn's message after the earlier turn's rows A message sent while A runs is written to the journal when it is sent. When the provider queues it (Claude answers it after A), A's remaining rows - its last tool run and its answer - are written after that message, and the message's own turn opens only after them. Grouping put those rows in A's turn, but the transcript still drew them in journal order, below B's bubble and bar, where A's answer read as B's reply. This is the residual #23671 left open. A message that opened a turn now draws after the earlier turns' rows the journal wrote after it, just before its own turn's rows (nativeChatTurnDrawOrder, returned by nativeChatTurnMembership as drawOrder). Desktop and mobile both draw in that order. A steer, and a message that has opened no turn yet, stay where they were written. It applies on hosts that state turn scopes and, through journal order, on older ones. * test(native-chat): run #23026's Stop tests against #23059's command turns Two of #23026's tests call APIs #23059 changed, and failed after the merge: - codex-structured-conversation-stop: a compaction now goes through adapter.compact with the command run the host wrote (#23059), not a bare turn id, and answers with the provider's receipt. With the command claimed, a Stop that names no turn while the compaction's provider turn has not opened still interrupts nothing. - main-agent-working-agreement: a provider row states its turn scope (#23059's appendItem contract); the retry and subagent rows are conversation-scoped. * fix(native-chat): typecheck main's Stop and restore-grouping code against #23059 A Stop's compaction interrupt reads the narrowed requested turn, and the restore-grouping test states whether each row reports its turn's outcome. --------- Co-authored-by: Claude --- .../src/session/MobileNativeChatOverlay.tsx | 3 +- mobile/src/session/MobileNativeChatView.tsx | 40 +- .../MobileNativeChatView.waiting-rows.test.ts | 169 ++++ .../mobile-native-chat-controller-contract.ts | 7 +- .../mobile-native-chat-list-footer.tsx | 23 + .../use-mobile-native-chat-controller.ts | 5 +- ...obile-native-chat-turn-disclosure.test.tsx | 655 ++++++++++----- .../use-mobile-native-chat-turn-disclosure.ts | 117 +-- .../use-mobile-native-chat-turn-status.ts | 30 +- ...bile-structured-agent-turn-timing.test.tsx | 3 +- ...use-mobile-structured-agent-turn-timing.ts | 21 +- ...claude-background-task-row-journal.test.ts | 40 +- .../claude-background-task-row-journal.ts | 11 +- ...claude-background-task-row-test-support.ts | 4 +- .../claude-background-task-row-writer.ts | 4 + .../claude/claude-background-task-rows.ts | 9 +- ...de-captured-compact-frames.test-fixture.ts | 219 +++++ src/main/claude/claude-command-turn.test.ts | 367 +++++++++ src/main/claude/claude-command-turn.ts | 153 ++++ src/main/claude/claude-context-usage.ts | 2 +- .../claude-journal-translator-contract.ts | 9 + .../claude/claude-journal-turn-scope.test.ts | 78 ++ src/main/claude/claude-open-turn.ts | 54 +- src/main/claude/claude-prompt-journaling.ts | 46 -- ...claude-provisional-row-corrections.test.ts | 8 +- .../claude-provisional-row-corrections.ts | 35 +- src/main/claude/claude-result-journaling.ts | 88 ++ .../claude-structured-command-dispatch.ts | 38 + .../claude-structured-compaction.test.ts | 66 -- .../claude/claude-structured-compaction.ts | 58 -- .../claude-structured-control-actions.test.ts | 4 + src/main/claude/claude-structured-dispatch.ts | 10 +- .../claude-structured-journal-prompts.ts | 25 +- ...urnal-translation-background-tasks.test.ts | 4 +- ...ured-journal-translation-subagents.test.ts | 4 +- ...ed-journal-translation-turn-timing.test.ts | 4 +- .../claude-structured-journal-translation.ts | 70 +- ...claude-structured-prompt-ownership.test.ts | 12 +- .../claude-structured-prompt-ownership.ts | 16 +- .../claude-structured-provider-fallback.ts | 11 +- .../claude-structured-session-adapter.ts | 9 +- .../claude/claude-structured-session-close.ts | 2 +- ...aude-structured-session-journal-control.ts | 2 +- .../claude-structured-session-publication.ts | 2 +- ...structured-session-reading-control.test.ts | 4 +- .../claude/claude-structured-session-state.ts | 2 +- src/main/claude/claude-subagent-group-row.ts | 2 +- .../claude/claude-subagent-roster-state.ts | 7 +- .../claude/claude-subagent-roster.test.ts | 8 +- src/main/claude/claude-subagent-roster.ts | 4 + src/main/claude/claude-turn-lifecycle-item.ts | 10 +- src/main/claude/claude-turn-ownership.test.ts | 11 +- src/main/claude/claude-turn-row-revision.ts | 13 +- .../codex/codex-command-turn-claim.test.ts | 375 +++++++++ src/main/codex/codex-command-turn-claim.ts | 32 + src/main/codex/codex-journal-command-turn.ts | 165 ++++ src/main/codex/codex-journal-turn-scopes.ts | 45 ++ ...codex-persistent-command-retention.test.ts | 7 +- ...odex-structured-compaction-refusal.test.ts | 20 +- ...codex-structured-conversation-stop.test.ts | 14 +- .../codex-structured-item-stream-contracts.ts | 4 +- .../codex/codex-structured-item-streams.ts | 16 +- ...dex-structured-journal-compactions.test.ts | 3 +- .../codex-structured-journal-compactions.ts | 19 +- .../codex-structured-journal-contracts.ts | 8 + ...codex-structured-journal-generic-frames.ts | 16 +- ...-structured-journal-goal-admission.test.ts | 7 +- ...dex-structured-journal-goal-resume.test.ts | 33 +- ...x-structured-journal-goal-revision.test.ts | 15 +- ...codex-structured-journal-goal-rows.test.ts | 8 +- .../codex/codex-structured-journal-goals.ts | 6 +- .../codex/codex-structured-journal-items.ts | 23 +- .../codex/codex-structured-journal-prompts.ts | 10 +- ...ured-journal-restore-turn-grouping.test.ts | 100 ++- .../codex-structured-journal-settlement.ts | 52 +- .../codex/codex-structured-journal-sink.ts | 28 +- ...tructured-journal-subagent-readers.test.ts | 2 +- ...red-journal-translation-settlement.test.ts | 3 +- ...red-journal-translation-turn-boundaries.ts | 69 +- ...ex-structured-journal-translation-turns.ts | 14 +- ...-structured-journal-translation-writers.ts | 31 +- .../codex-structured-journal-translation.ts | 34 +- .../codex-structured-prompt-ownership.ts | 17 +- .../codex-structured-question-order.test.ts | 7 +- .../codex/codex-structured-session-acquire.ts | 17 +- .../codex/codex-structured-session-adapter.ts | 61 +- .../codex/codex-structured-session-state.ts | 10 + src/main/codex/codex-subagent-linkage.ts | 15 +- src/main/codex/codex-subagent-roster-state.ts | 36 + src/main/codex/codex-subagent-roster.test.ts | 6 + src/main/codex/codex-subagent-roster.ts | 47 +- .../journal-corruption-repair.test.ts | 61 +- .../journal-crash-boundary.test.ts | 6 +- .../journal-derived-turn-scope.ts | 51 ++ .../journal-dispatch-reducer.ts | 2 + .../journal-epoch-replacement.test.ts | 13 +- .../journal-epoch-replacement.ts | 11 +- .../journal-file-format-remnant.test.ts | 3 +- .../journal-handle-ownership.test.ts | 12 +- .../journal-item-fold.ts | 97 +++ .../journal-legacy-import.test.ts | 3 +- .../journal-producer-inheritance.test.ts | 41 +- .../journal-reducer-producer-linkage.test.ts | 214 +++++ .../journal-reducer.test.ts | 214 +---- .../agent-session-journal/journal-reducer.ts | 145 +--- .../journal-render-item.ts | 5 +- .../journal-restart-reconciliation.test.ts | 4 +- .../journal-restart-reconciliation.ts | 2 + .../journal-row-builders.ts | 28 +- .../journal-row-context-usage.test.ts | 13 +- .../journal-row-schema-version.test.ts | 5 +- .../journal-row-schema.test.ts | 27 +- .../journal-row-schema.ts | 28 + .../journal-store-close.test.ts | 51 +- .../journal-store-contracts.ts | 25 +- .../journal-store-restore.ts | 5 +- .../journal-store-schema.test.ts | 39 +- .../journal-store.test.ts | 157 +++- .../agent-session-journal/journal-store.ts | 8 +- .../journal-subagent-liveness.test.ts | 21 +- .../journal-submission-fold.ts | 97 +++ .../journal-turn-scope.test.ts | 245 ++++++ ...gent-session-empty-delta-retention.test.ts | 3 +- ...nt-session-history-byte-accounting.test.ts | 6 +- .../agent-session-history-page.test.ts | 52 +- .../agent-session-journal-recovery.test.ts | 8 +- ...-agent-session-accept-then-deliver.test.ts | 16 +- .../structured-agent-session-adapter.ts | 27 +- ...tured-agent-session-adopted-import.test.ts | 3 +- ...ured-agent-session-append-delivery.test.ts | 20 +- ...ured-agent-session-attach-orchestration.ts | 2 - ...-agent-session-claude-compact-stop.test.ts | 291 +++++++ ...ructured-agent-session-close-retry.test.ts | 7 +- ...ured-agent-session-command-readers.test.ts | 191 +++++ .../structured-agent-session-command-turn.ts | 320 ++++++++ ...ctured-agent-session-conversations.test.ts | 3 +- ...tured-agent-session-crash-turn-end.test.ts | 17 +- ...session-dead-generation-settlement.test.ts | 20 +- ...gent-session-dead-generation-settlement.ts | 38 +- .../structured-agent-session-delivery-loop.ts | 21 +- ...tructured-agent-session-event-sink.test.ts | 104 ++- .../structured-agent-session-event-sink.ts | 28 +- ...tructured-agent-session-exit-turn-scope.ts | 36 + ...ctured-agent-session-forget-status.test.ts | 5 +- ...tured-agent-session-grouped-prompt.test.ts | 53 +- .../structured-agent-session-host-delivery.ts | 47 +- ...structured-agent-session-host-mutations.ts | 36 +- ...uctured-agent-session-host-test-harness.ts | 19 +- .../structured-agent-session-host.test.ts | 6 +- .../structured-agent-session-host.ts | 9 +- ...tructured-agent-session-idle-sweep.test.ts | 10 +- ...ed-agent-session-journal-append-options.ts | 5 +- ...ured-agent-session-journal-handles.test.ts | 3 +- ...ured-agent-session-late-settlement.test.ts | 6 +- ...ssion-main-agent-working-agreement.test.ts | 7 +- ...structured-agent-session-mutation-plans.ts | 48 +- ...ctured-agent-session-prompt-cancel.test.ts | 5 +- ...gent-session-provider-child-record.test.ts | 3 +- ...agent-session-recovered-turn-clock.test.ts | 7 +- ...ured-agent-session-resolved-append.test.ts | 31 +- ...tructured-agent-session-resolved-append.ts | 12 +- ...ctured-agent-session-restart-candidates.ts | 2 +- ...-session-restart-continuation-wait.test.ts | 3 +- ...ured-agent-session-restart-continuation.ts | 8 +- ...ssion-restart-interruption-test-harness.ts | 14 +- ...ed-agent-session-restart-ownership.test.ts | 7 +- ...red-agent-session-restart-teardown.test.ts | 21 +- ...tured-agent-session-rewind-at-rest.test.ts | 9 +- .../structured-agent-session-rewind.test.ts | 139 +++- .../structured-agent-session-rewind.ts | 25 +- ...tructured-agent-session-send-block.test.ts | 14 +- ...agent-session-send-open-stale-turn.test.ts | 4 +- ...ructured-agent-session-send-preparation.ts | 39 +- ...ured-agent-session-send-settlement.test.ts | 65 ++ ...tructured-agent-session-send-settlement.ts | 20 +- ...ured-agent-session-stale-turn-open.test.ts | 4 +- ...d-agent-session-stale-turn-verdict.test.ts | 56 +- ...ctured-agent-session-stale-turn-verdict.ts | 16 +- ...uctured-agent-session-start-failure-row.ts | 5 +- ...agent-session-startup-failure-exit.test.ts | 28 + ...ed-agent-session-status-feed-clock.test.ts | 3 +- ...ructured-agent-session-status-feed.test.ts | 61 +- ...tured-agent-session-status-reentry.test.ts | 3 +- ...red-agent-session-subagent-recency.test.ts | 3 +- ...ructured-agent-session-subscribers.test.ts | 13 +- ...red-agent-session-surface-lifetime.test.ts | 13 +- ...ructured-agent-session-thread-goal.test.ts | 3 +- .../structured-agent-session-thread-goal.ts | 3 +- ...agent-session-turn-completion-feed.test.ts | 33 + .../structured-agent-session-turns-cancel.ts | 125 +++ .../structured-agent-session-turns-prompt.ts | 7 +- .../structured-agent-session-turns.test.ts | 9 +- .../structured-agent-session-turns.ts | 136 +--- ...ured-agent-session-unexpected-exit.test.ts | 27 +- ...tructured-agent-session-unexpected-exit.ts | 4 +- ...t-session-wedged-profile-migration.test.ts | 5 +- ...tured-agent-session-wire-admission.test.ts | 10 +- ...tured-agent-session-working-at-teardown.ts | 31 +- .../structured-compaction-recovery.test.ts | 48 -- .../structured-compaction-recovery.ts | 52 -- ...red-conversation-command-admission.test.ts | 37 + ...ructured-conversation-command-admission.ts | 22 +- ...uctured-conversation-command-controller.ts | 11 +- ...tured-conversation-command-outcome.test.ts | 46 ++ ...structured-conversation-command-outcome.ts | 44 + .../structured-conversation-command.test.ts | 151 +--- .../structured-conversation-command.ts | 112 +-- ...structured-conversation-compaction.test.ts | 751 ++++++++++++++++++ .../structured-conversation-compaction.ts | 156 ++++ .../structured-rewind-recovery.ts | 14 +- ...ructured-rewind-retained-host-rows.test.ts | 195 +++++ .../structured-rewind-retained-host-rows.ts | 94 ++- .../structured-session-compaction.test.ts | 141 ---- .../structured-session-compaction.ts | 173 ---- .../structured-agent-session-at-rest.test.ts | 7 +- ...ructured-agent-session-rpc.test-fixture.ts | 1 + ...d-agent-session-send-compatibility.test.ts | 2 +- ...ctured-agent-session-send-compatibility.ts | 8 +- ...ured-agent-session-turn-item-capability.ts | 27 +- .../rpc/methods/structured-agent-session.ts | 20 +- ...untime-worktree-agent-rows-verdict.test.ts | 5 +- ...ree-structured-agent-rows-liveness.test.ts | 5 +- ...d-agent-session-codex-compact-exit.test.ts | 154 ++++ ...d-agent-session-integration-replay.test.ts | 317 +------- ...session-legacy-send-behind-command.test.ts | 106 +++ .../structured-claude-runtime-adapter.ts | 2 + ...ructured-codex-session-rpc-test-harness.ts | 278 +++++++ ...ChatMessageList.queued-turn-order.test.tsx | 180 +++++ .../native-chat/NativeChatMessageList.tsx | 67 +- .../NativeChatMessageList.turn-fold.test.tsx | 111 ++- ...iveChatMessageList.turn-indicator.test.tsx | 221 ++++-- ...veChatMessageList.turn-membership.test.tsx | 432 ++++++++++ .../NativeChatStructuredSession.tsx | 3 +- .../native-chat/NativeChatTranscriptItems.tsx | 17 + .../native-chat-message-rail-items.test.ts | 3 +- .../native-chat-rail-outline-parity.test.ts | 3 +- .../native-chat-session-assembler.ts | 6 +- .../native-chat-transcript-slots.test.ts | 228 +++++- .../native-chat-transcript-slots.ts | 63 +- .../use-native-chat-message-rail.test.ts | 3 +- .../use-native-chat-turn-membership.ts | 36 + .../use-native-chat-turn-status.ts | 25 +- ...d-agent-session-command-send-gate.test.tsx | 93 +++ .../use-structured-agent-session.ts | 8 +- .../use-structured-agent-turn-timing.test.tsx | 3 +- .../use-structured-agent-turn-timing.ts | 22 +- .../agent-session-failure-words.test.ts | 16 + src/shared/agent-session-failure-words.ts | 9 +- src/shared/agent-session-failure.ts | 5 +- src/shared/agent-session-journal-schemas.ts | 14 +- src/shared/agent-session-journal-types.ts | 20 + src/shared/agent-session-rewind.ts | 11 +- src/shared/agent-session-turn-record.ts | 11 +- src/shared/native-chat-turn-fold.test.ts | 18 + src/shared/native-chat-turn-fold.ts | 6 +- src/shared/native-chat-turn-grouping.test.ts | 67 +- src/shared/native-chat-turn-grouping.ts | 152 +++- .../native-chat-turn-membership.test.ts | 242 ++++++ src/shared/native-chat-turn-membership.ts | 228 ++++++ src/shared/native-chat-turn-status.ts | 16 - src/shared/native-chat-types.ts | 2 + .../structured-agent-session-command-entry.ts | 54 ++ ...d-agent-session-dispatch-rejection.test.ts | 4 +- ...ctured-agent-session-dispatch-rejection.ts | 2 + ...tured-agent-session-latest-request.test.ts | 14 +- ...structured-agent-session-latest-request.ts | 111 ++- .../structured-agent-session-live-turn.ts | 24 +- ...ctured-agent-session-message-projection.ts | 20 +- ...tructured-agent-session-projection.test.ts | 2 +- .../structured-agent-session-projection.ts | 61 +- ...structured-agent-session-turn-bars.test.ts | 188 +++-- .../structured-agent-session-turn-timing.ts | 154 +--- .../structured-agent-session-host-fixture.ts | 1 + 273 files changed, 10710 insertions(+), 3344 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatView.waiting-rows.test.ts create mode 100644 mobile/src/session/mobile-native-chat-list-footer.tsx create mode 100644 src/main/claude/claude-captured-compact-frames.test-fixture.ts create mode 100644 src/main/claude/claude-command-turn.test.ts create mode 100644 src/main/claude/claude-command-turn.ts create mode 100644 src/main/claude/claude-journal-turn-scope.test.ts delete mode 100644 src/main/claude/claude-prompt-journaling.ts create mode 100644 src/main/claude/claude-result-journaling.ts create mode 100644 src/main/claude/claude-structured-command-dispatch.ts delete mode 100644 src/main/claude/claude-structured-compaction.test.ts delete mode 100644 src/main/claude/claude-structured-compaction.ts create mode 100644 src/main/codex/codex-command-turn-claim.test.ts create mode 100644 src/main/codex/codex-command-turn-claim.ts create mode 100644 src/main/codex/codex-journal-command-turn.ts create mode 100644 src/main/codex/codex-journal-turn-scopes.ts create mode 100644 src/main/codex/codex-subagent-roster-state.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-item-fold.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-fold.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-session-compaction.ts create mode 100644 src/main/runtime/structured-agent-session-codex-compact-exit.test.ts create mode 100644 src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts create mode 100644 src/main/runtime/structured-codex-session-rpc-test-harness.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx create mode 100644 src/shared/native-chat-turn-membership.test.ts create mode 100644 src/shared/native-chat-turn-membership.ts create mode 100644 src/shared/structured-agent-session-command-entry.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 00598e6a967..9640608114b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -76,8 +76,7 @@ export function MobileNativeChatOverlay({ turnIndicator={controller.nativeChatTurnIndicator} workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} - activeTurnOpenedBy={controller.nativeChatActiveTurnOpenedBy} - turnKeysByItemId={controller.nativeChatTurnKeysByItemId} + turnJournal={controller.nativeChatTurnJournal} streaming={streaming} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index d0a6c39cba6..6247c5f6c7c 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -21,8 +21,10 @@ import type { NativeChatLiveTurnIndicator, NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' +import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer' import { buildMobileNativeChatTransientData, mobileNativeChatEmptyState, @@ -66,10 +68,8 @@ type Props = { /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null - /** Structured lane: the key the host says anchors the running turn's bar. */ - activeTurnOpenedBy?: string | null - /** Structured lane: host-attributed turn ownership per journal item id. */ - turnKeysByItemId?: ReadonlyMap | null + /** Structured lane: the journal that places each row in its turn. */ + turnJournal?: NativeChatTurnJournal | null /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ /** Interrupt a provider turn. */ onStop?: () => void @@ -155,8 +155,7 @@ export function MobileNativeChatView({ turnIndicator = null, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, + turnJournal = null, onStop, streaming, hasMore, @@ -276,8 +275,7 @@ export function MobileNativeChatView({ isWorking: agentWorking === true, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId, + turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId @@ -301,6 +299,14 @@ export function MobileNativeChatView({ [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns] ) + const liveStatus = + structuredActivityUi && agentWorking && !hasPendingStructuredInteraction && turns.active ? ( + + ) : null + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 @@ -317,7 +323,7 @@ export function MobileNativeChatView({ item.id} renderItem={renderItem} contentContainerStyle={styles.listContent} @@ -347,17 +353,11 @@ export function MobileNativeChatView({ ) : null } - ListFooterComponent={ - structuredActivityUi && - agentWorking && - !hasPendingStructuredInteraction && - turns.active ? ( - - ) : null - } + ListFooterComponent={mobileNativeChatListFooter( + liveStatus, + turns.waitingRows, + renderItem + )} ListEmptyComponent={ emptyState ? ( diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts new file mode 100644 index 00000000000..c0b7fbb5fa2 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -0,0 +1,169 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatView } from './MobileNativeChatView' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) +vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +let renderer: ReactTestRenderer | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +function user(id: string, text: string, queued = false): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'hook', + ...(queued ? { queued: true as const } : {}) + } +} + +function row(itemId: string, sequence: number, body: AgentJournalRenderItem['body']) { + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + turnScope: { kind: 'thread' as const }, + body + } +} + +function renderView( + folded: NativeChatMessage[], + items: AgentJournalRenderItem[] +): { listed: string[]; footer: string[] } { + act(() => { + renderer = create( + createElement(MobileNativeChatView, { + messages: [], + folded, + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + structuredActivityUi: true, + agentWorking: true, + turnJournal: { items, submissions: [] } + }) + ) + }) + const list = renderer!.root.find((node) => String(node.type) === 'FlatList') + let footer: ReactTestRenderer | null = null + act(() => { + footer = create(list.props.ListFooterComponent) + }) + const drawn = footer!.root + .findAll((node) => String(node.type) === 'LiveStatus' || String(node.type) === 'ChatMessage') + .map((node) => (String(node.type) === 'LiveStatus' ? 'status' : node.props.message.id)) + act(() => footer!.unmount()) + return { + listed: list.props.data.map((message: NativeChatMessage) => message.id), + footer: drawn + } +} + +it('draws a message waiting behind a running /compact after the live status, not in the list', () => { + const drawn = renderView( + [user('compact', '/compact'), user('held', 'Say DONE', true)], + [ + row('compact', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + row('command-turn', 2, { + kind: 'turn', + turnId: 'compact:1', + state: 'running', + userItemId: 'compact', + startedAt: 1 + }), + row('held', 3, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Say DONE' }] + }) + ] + ) + + expect(drawn).toEqual({ listed: ['compact'], footer: ['status', 'held'] }) +}) + +it('keeps a steer on its way into an ordinary running turn in the list, above the live status', () => { + const drawn = renderView( + [user('ask', 'List three fruits'), user('steer', 'Make it four', true)], + [ + row('ask', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'List three fruits' }] + }), + row('turn', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'running', + userItemId: 'ask', + startedAt: 1 + }), + row('steer', 3, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Make it four' }] + }) + ] + ) + + expect(drawn).toEqual({ listed: ['ask', 'steer'], footer: ['status'] }) +}) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 30bf33d7d51..8e4cffe158f 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -10,6 +10,7 @@ import type { NativeChatLiveTurnIndicator, NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' @@ -37,10 +38,8 @@ export type MobileNativeChatController = { /** Structured lane: host-recorded turn timing for the per-turn status rows. */ nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null - /** Structured lane: the user message the host says opened the running turn. */ - nativeChatActiveTurnOpenedBy: string | null - /** Structured lane: the turn that owns each journal item's row. */ - nativeChatTurnKeysByItemId: ReadonlyMap | null + /** Structured lane: the journal that places each transcript row in its turn. */ + nativeChatTurnJournal: NativeChatTurnJournal | null nativeChatCanStop: boolean nativeChatStreamingText?: string /** Agent mid-turn, regardless of whether chat is the visible view. */ diff --git a/mobile/src/session/mobile-native-chat-list-footer.tsx b/mobile/src/session/mobile-native-chat-list-footer.tsx new file mode 100644 index 00000000000..df81930e5fb --- /dev/null +++ b/mobile/src/session/mobile-native-chat-list-footer.tsx @@ -0,0 +1,23 @@ +import { Fragment, type ReactElement } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +type MobileNativeChatListRow = { item: NativeChatMessage; index: number } + +/** The live turn's status, then the messages waiting behind that turn, drawn below it. */ +export function mobileNativeChatListFooter( + liveStatus: ReactElement | null, + waitingRows: readonly MobileNativeChatListRow[], + renderRow: (row: MobileNativeChatListRow) => ReactElement +): ReactElement | null { + if (waitingRows.length === 0) { + return liveStatus + } + return ( + <> + {liveStatus} + {waitingRows.map((row) => ( + {renderRow(row)} + ))} + + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c14c3e1251c..0d236770be3 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -288,10 +288,7 @@ export function useMobileNativeChatController(args: { nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, - nativeChatActiveTurnOpenedBy: activeChatStructured - ? structuredNativeChat.activeTurnOpenedBy - : null, - nativeChatTurnKeysByItemId: activeChatStructured ? structuredNativeChat.turnKeysByItemId : null, + nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null, nativeChatCanStop: activeChatStructured ? structuredNativeChat.turnId !== null : nativeChatAgentWorking, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index ed0b22dfc1f..f5cc0ff8c02 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -1,7 +1,13 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' @@ -20,18 +26,16 @@ function Harness({ enabled, isWorking = true, settledTurns, + turnJournal, workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] enabled: boolean isWorking?: boolean settledTurns?: NativeChatSettledTurns + turnJournal?: NativeChatTurnJournal workingStartedAt?: number | null - activeTurnOpenedBy?: string | null - turnKeysByItemId?: ReadonlyMap | null scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -39,9 +43,8 @@ function Harness({ enabled, isWorking, settledTurns, + turnJournal, workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId, scopeKey }) return createElement('result', { disclosure }) @@ -211,199 +214,432 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) - it('keeps the live bar under the prompt that opened the running turn, not a mid-turn send', () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const tool: NativeChatMessage = { - id: 'tool-a', - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } - ], - timestamp: null, - source: 'transcript' + describe('keeps the live bar under the prompt that opened the running turn, not a mid-turn send', () => { + const tool: NativeChatMessage = { + id: 'tool-a', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('A'), tool, userMessage('B')] + const user = ( + id: string, + sequence: number, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: id, + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + ...(turnScope ? { turnScope } : {}) + }) + const turn = ( + turnId: string, + sequence: number, + state: 'running' | 'interrupted', + userItemId: string, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: turnId, + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'turn', turnId, state, userItemId }, + ...(turnScope ? { turnScope } : {}) + }) + const toolItem = ( + sequence: number, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: 'tool-a', + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'tool-call', + name: 'Bash', + input: { command: 'sleep 15' }, + state: 'running' + }, + ...(turnScope ? { turnScope } : {}) + }) + const rows = () => { + const disclosure = renderer!.root.findByType('result').props.disclosure + return messages.map((message, index) => disclosure.resolveRow(index, message)) + } + const render = (props: Parameters[0]) => + act(() => { + if (renderer) { + renderer.update(createElement(Harness, props)) + } else { + renderer = create(createElement(Harness, props)) + } + }) + + it("on a host that states each row's turn, the steered message and the work are one live turn", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const t1 = { kind: 'turn', turnItemId: 't1' } as const + const thread = { kind: 'thread' } as const + // B was handed over while A's turn runs, so the host scopes it to that turn. + const whileA = [ + user('A', 1, thread), + turn('t1', 2, 'running', 'A', thread), + toolItem(3, t1), + user('B', 4, t1) + ] + render({ + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { items: whileA, submissions: [] } + }) + let [rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + expect(rowB.turnStatus).toBeNull() + expect([rowA, rowTool, rowB].map((row) => row.activeTurnIsWorking)).toEqual([ + true, + true, + true + ]) + + // B's own turn opens: A takes the host's settled duration, B counts from A's end. + const whileB = [ + user('A', 1, thread), + turn('t1', 2, 'interrupted', 'A', thread), + toolItem(3, t1), + user('B', 4, t1), + turn('t2', 5, 'running', 'B', thread) + ] + render({ + messages, + enabled: true, + workingStartedAt: 22_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal: { items: whileB, submissions: [] } + }) + ;[rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) + expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) + expect(rowB.activeTurnIsWorking).toBe(true) + expect(rowTool.activeTurnIsWorking).toBe(false) + } finally { + vi.useRealTimers() } - const messages = [userMessage('A'), tool, userMessage('B')] - const rows = () => { + }) + + it('on a host that states no turn, the bar and liveness follow the running record by journal order', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const whileA = [user('A', 1), turn('t1', 2, 'running', 'A'), toolItem(3), user('B', 4)] + render({ + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { items: whileA, submissions: [] } + }) + let [rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + expect(rowB.turnStatus).toBeNull() + // Liveness follows the owning turn: A's tool row stays live while B waits. + expect(rowTool.activeTurnIsWorking).toBe(true) + expect(rowB.activeTurnIsWorking).toBe(false) + + const whileB = [ + user('A', 1), + turn('t1', 2, 'interrupted', 'A'), + toolItem(3), + user('B', 4), + turn('t2', 5, 'running', 'B') + ] + render({ + messages, + enabled: true, + workingStartedAt: 22_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal: { items: whileB, submissions: [] } + }) + ;[rowA, , rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) + expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) + } finally { + vi.useRealTimers() + } + }) + }) + + /** A journal of `entries` in order, each naming the turn it belongs to (null for none). With + * `statesScope` false the rows carry no scope, as an older host writes them. */ + function journalOf( + entries: readonly [itemId: string, body: AgentJournalItemBody, turnItemId: string | null][], + statesScope: boolean + ): NativeChatTurnJournal { + return { + items: entries.map(([itemId, body, turnItemId], index) => ({ + itemId, + revision: 0, + sequence: index + 1, + observedAt: index + 1, + body, + ...(statesScope + ? { + turnScope: turnItemId + ? { kind: 'turn' as const, turnItemId } + : { kind: 'thread' as const } + } + : {}) + })), + submissions: [] + } + } + const said = (role: 'user' | 'assistant'): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [] + }) + const record = ( + turnId: string, + state: 'running' | 'completed', + userItemId: string + ): AgentJournalItemBody => ({ kind: 'turn', turnId, state, userItemId }) + const hosts = [ + ['states each row’s turn', true], + ['states no scope', false] + ] as const + + it.each(hosts)( + "keeps a running turn's rows live across a mid-turn send the host folded in, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const tool = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + }) + // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. + const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] + const turnJournal = journalOf( + [ + ['A', said('user'), null], + ['tA', record('tA', 'running', 'A'), null], + ['t1', said('assistant'), 'tA'], + ['B', said('user'), 'tA'], + ['t2', said('assistant'), 'tA'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal + }) + ) + }) const disclosure = renderer!.root.findByType('result').props.disclosure - return messages.map((message, index) => disclosure.resolveRow(index, message)) + const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + // The steered bubble shares A's turn but never carries a bar of its own. + expect(rowB.turnStatus).toBeNull() + expect(rowT1.activeTurnIsWorking).toBe(true) + expect(rowT2.activeTurnIsWorking).toBe(true) + } finally { + vi.useRealTimers() } - // B was sent while A's turn runs; the host still names A as the running turn's opener. - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'A' - }) - ) - }) - let [rowA, rowTool, rowB] = rows() - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) - expect(rowB.turnStatus).toBeNull() - // Liveness follows the owning turn: A's tool row stays live while B waits. - expect(rowTool.activeTurnIsWorking).toBe(true) - expect(rowB.activeTurnIsWorking).toBe(false) - - // B's own turn opens: A takes the host's settled duration, B counts from A's end. - act(() => { - renderer?.update( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 22_000, - settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), - activeTurnOpenedBy: 'B' - }) - ) - }) - ;[rowA, , rowB] = rows() - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) - expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) - } finally { - vi.useRealTimers() } - }) + ) - it("keeps a running turn's rows live across a mid-turn send the host folded in", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const tool = (id: string): NativeChatMessage => ({ - id, - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } - ], - timestamp: null, - source: 'transcript' - }) - // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. - const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] - const owned = new Map([ - ['A', 'A'], - ['t1', 'A'], - ['B', 'A'], - ['t2', 'A'] - ]) - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'A', - turnKeysByItemId: owned - }) + it.each(hosts)( + "anchors a provider-opened turn's bar above its first row, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const woke: NativeChatMessage = { + id: 'woke', + role: 'assistant', + blocks: [{ type: 'text', text: 'Woke up.' }], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('u1'), woke] + const turnJournal = journalOf( + [ + ['u1', said('user'), null], + ['wake', record('wake', 'completed', 'claude:wake'), null], + ['woke', said('assistant'), 'wake'] + ], + statesScope ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => - disclosure.resolveRow(index, message) - ) - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) - // The steered bubble shares A's turn but never carries a bar of its own. - expect(rowB.turnStatus).toBeNull() - expect(rowT1.activeTurnIsWorking).toBe(true) - expect(rowT2.activeTurnIsWorking).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it("anchors a provider-opened turn's bar above its first row", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const woke: NativeChatMessage = { - id: 'woke', - role: 'assistant', - blocks: [{ type: 'text', text: 'Woke up.' }], - timestamp: null, - source: 'transcript' + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), + turnJournal + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowU1, rowWoke] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowU1.turnStatus).toBeNull() + expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) + expect(rowWoke.turnStatusAbove).toBe(true) + } finally { + vi.useRealTimers() } - const messages = [userMessage('u1'), woke] - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - isWorking: false, - settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), - turnKeysByItemId: new Map([ - ['u1', 'u1'], - ['woke', 'wake'] - ]) - }) - ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowU1, rowWoke] = messages.map((message, index) => - disclosure.resolveRow(index, message) - ) - expect(rowU1.turnStatus).toBeNull() - expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) - expect(rowWoke.turnStatusAbove).toBe(true) - } finally { - vi.useRealTimers() } - }) + ) - it("never hands a provider-opened turn's clock to a message sent during it", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const row = (id: string): NativeChatMessage => ({ - id, - role: 'assistant', - blocks: [{ type: 'text', text: id }], - timestamp: null, - source: 'transcript' - }) - // A wake turn runs; B is sent during it and folded in, so B opened nothing. - const messages = [row('w1'), userMessage('B'), row('w2')] - const owned = new Map([ - ['w1', 'wake'], - ['B', 'wake'], - ['w2', 'wake'] - ]) - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'wake', - turnKeysByItemId: owned, - settledTurns: new Map([['wake', null]]) - }) + it.each(hosts)( + "never hands a provider-opened turn's clock to a message sent during it, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const row = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + // A wake turn runs; B is sent during it and folded in, so B opened nothing. + const messages = [row('w1'), userMessage('B'), row('w2')] + const journal = (state: 'running' | 'completed') => + journalOf( + [ + ['wake', record('wake', state, 'claude:wake'), null], + ['w1', said('assistant'), 'wake'], + ['B', said('user'), 'wake'], + ['w2', said('assistant'), 'wake'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: journal('running'), + settledTurns: new Map([['wake', null]]) + }) + ) + }) + // The wake turn settles: the host no longer names a running turn. + vi.setSystemTime(20_000) + act(() => { + renderer!.update( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + workingStartedAt: null, + turnJournal: journal('completed'), + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowW1, rowB] = messages.map((message, index) => + disclosure.resolveRow(index, message) ) - }) - // The wake turn settles: the host no longer names a running turn. - vi.setSystemTime(20_000) - act(() => { - renderer!.update( - createElement(Harness, { - messages, - enabled: true, - isWorking: false, - workingStartedAt: null, - activeTurnOpenedBy: null, - turnKeysByItemId: owned, - settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) - }) - ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowW1, rowB] = messages.map((message, index) => disclosure.resolveRow(index, message)) - expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) - expect(rowB.turnStatus).toBeNull() - } finally { - vi.useRealTimers() + expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) + expect(rowB.turnStatus).toBeNull() + } finally { + vi.useRealTimers() + } } - }) + ) + + // Claude runs A; B is sent mid-turn and Claude answers it after A. The journal writes B when it + // is sent, so A's remaining tool run and its answer follow B there, and B's turn opens after them. + it.each(hosts)( + "draws A's remaining rows under A's bar, then B's bubble and turn, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(30_000) + const message = (id: string, role: 'user' | 'assistant'): NativeChatMessage => ({ + id, + role, + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + const messages = [ + message('A', 'user'), + message('a-tool-1', 'assistant'), + message('B', 'user'), + message('a-tool-2', 'assistant'), + message('FIRST DONE', 'assistant'), + message('b-answer', 'assistant') + ] + const turnJournal = journalOf( + [ + ['A', said('user'), null], + ['tA', record('tA', 'completed', 'A'), null], + ['a-tool-1', said('assistant'), 'tA'], + // Handed over while A runs, so the host scopes it to A's turn until its own opens. + ['B', said('user'), 'tA'], + ['a-tool-2', said('assistant'), 'tA'], + ['FIRST DONE', said('assistant'), 'tA'], + ['tB', record('tB', 'running', 'B'), null], + ['b-answer', said('assistant'), 'tB'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 29_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const list: NativeChatMessage[] = disclosure.listMessages + const drawn = list.map((entry, index) => { + const row = disclosure.resolveRow(index, entry) + return [entry.id, row.turnStatus?.workedSeconds, row.activeTurnIsWorking] + }) + expect(drawn).toEqual([ + ['A', 17, false], + ['a-tool-1', undefined, false], + ['a-tool-2', undefined, false], + ['FIRST DONE', undefined, false], + ['B', null, true], + ['b-answer', undefined, true] + ]) + } finally { + vi.useRealTimers() + } + } + ) it('keeps at most the latest 128 turns expanded', () => { vi.useFakeTimers() @@ -439,4 +675,47 @@ describe('useMobileNativeChatTurnDisclosure', () => { vi.useRealTimers() } }) + + it('keeps a turn the provider opened live while it runs, and the turn before it settled', () => { + let sequence = 0 + const entry = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = { kind: 'thread' } + ): AgentJournalRenderItem => { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } + } + const said = (itemId: string, turnItemId: string) => + entry( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + { kind: 'turn', turnItemId } + ) + const items = [ + entry('u1', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }), + entry('t1', { kind: 'turn', turnId: 't1', state: 'completed', userItemId: 'u1' }), + said('a1', 't1'), + entry('wake', { kind: 'turn', turnId: 'wake', state: 'running', userItemId: 'claude:wake' }), + said('wake-note', 'wake') + ] + const messages: NativeChatMessage[] = ['u1', 'a1', 'wake-note'].map((id, index) => ({ + ...userMessage(id), + role: index === 0 ? 'user' : 'assistant' + })) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + settledTurns: new Map([['u1', { startedAt: 500, workedSeconds: 4 }]]), + turnJournal: { items, submissions: [] } + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const rows = messages.map((message, index) => disclosure.resolveRow(index, message)) + expect(rows.map((row) => row.activeTurnIsWorking)).toEqual([false, false, true]) + expect(rows[0].turnStatus?.workedSeconds).toBe(4) + }) }) 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 cc61ba63d89..6e1d4df395e 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,10 +1,14 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { NATIVE_CHAT_UNANCHORED_TURN_KEY } from '../../../src/shared/native-chat-turn-status' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { - nativeChatRowTurnKeys, - nativeChatSelfAnchoredTurnRows + nativeChatMessagesWaitingBehindLiveTurn, + nativeChatTurnMembership, + type NativeChatTurnJournal +} from '../../../src/shared/native-chat-turn-membership' +import { + nativeChatRowsInDrawOrder, + nativeChatTurnBarRows } from '../../../src/shared/native-chat-turn-grouping' import { useMobileNativeChatTurnStatus, @@ -12,8 +16,7 @@ import { } from './use-mobile-native-chat-turn-status' const EMPTY_TURN_IDS: ReadonlySet = new Set() -const EMPTY_TURN_KEYS: readonly undefined[] = [] -const EMPTY_TURN_ANCHORS: ReadonlyMap = new Map() +const NO_TURN_KEYS: readonly undefined[] = [] const MAX_EXPANDED_TURNS = 128 export type MobileNativeChatTurnRow = { @@ -35,8 +38,7 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, + turnJournal = null, thinking = false, activityText = null, scopeKey @@ -47,11 +49,8 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The key the host says anchors the running turn's bar; absent, the latest user message. */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership per journal item id; unnamed rows keep - * positional preceding-user grouping. */ - turnKeysByItemId?: ReadonlyMap | null + /** The journal that places each row in its turn; absent groups rows by position. */ + turnJournal?: NativeChatTurnJournal | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ @@ -64,15 +63,43 @@ export function useMobileNativeChatTurnDisclosure({ activeActivityText: string | null onToggleTurn: (turnKey: string) => void resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow + /** The list's rows, in the order they draw, less those waiting behind the live turn. */ + listMessages: readonly NativeChatMessage[] + /** Rows waiting behind the live turn, drawn after its live status. */ + waitingRows: readonly { item: NativeChatMessage; index: number }[] } { + // Resolve each row's turn, which turn is live, and the order the rows draw in, once: from the + // turn record when the host states scopes, else by journal order. + const { rows, turnKeys, liveTurnKey } = useMemo(() => { + if (!enabled) { + return { rows: messages, turnKeys: NO_TURN_KEYS, liveTurnKey: undefined } + } + const membership = nativeChatTurnMembership(messages, turnJournal) + return { + rows: nativeChatRowsInDrawOrder(messages, membership.drawOrder), + turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder), + liveTurnKey: membership.liveTurnKey + } + }, [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 + if (!ids?.size) { + return { listMessages: rows, waitingRows: [], indexById: null } + } + return { + listMessages: rows.filter((message) => !ids.has(message.id)), + waitingRows: rows.flatMap((item, index) => (ids.has(item.id) ? [{ item, index }] : [])), + indexById: new Map(rows.map((message, index) => [message.id, index])) + } + }, [enabled, rows, turnJournal]) const turnStatuses = useMobileNativeChatTurnStatus({ - messages, + turnKeys, + liveTurnKey, enabled, isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId, thinking, scopeKey }) @@ -100,59 +127,43 @@ export function useMobileNativeChatTurnDisclosure({ }, [scopeKey] ) - // Resolve each row's owning turn once — a findLast per row is quadratic on a - // long transcript. - const turnKeys = useMemo( - () => (enabled ? nativeChatRowTurnKeys(messages, turnKeysByItemId) : EMPTY_TURN_KEYS), - [enabled, messages, turnKeysByItemId] - ) - // A turn with no user bubble (provider-opened) anchors its bar at its first row. - const selfAnchors = useMemo( - () => (enabled ? nativeChatSelfAnchoredTurnRows(messages, turnKeys) : EMPTY_TURN_ANCHORS), - [enabled, messages, turnKeys] - ) + const bars = useMemo(() => nativeChatTurnBarRows(rows, turnKeys), [rows, turnKeys]) const { active, activeTurnKey, completedByTurn } = turnStatuses const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( - (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { + (listIndex: number, message: NativeChatMessage): MobileNativeChatTurnRow => { + const index = waiting.indexById?.get(message.id) ?? listIndex const turnKey = turnKeys[index] - const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index - // The live turn's bar carries its running clock; it settles in place. Only - // the bubble that opened a turn carries its bar: a message the provider - // folded into a running turn shares the turn's key but not its bar. - const turnStatus = !enabled - ? null - : message.role === 'user' && message.id === activeTurnKey - ? active - : message.role === 'user' && turnKey === message.id - ? (completedByTurn[turnKey] ?? null) - : anchorsTurnHere - ? turnKey === activeTurnKey - ? (active ?? completedByTurn[turnKey] ?? null) - : (completedByTurn[turnKey] ?? null) - : null + const bar = turnKey === undefined ? undefined : bars.get(turnKey) + // A turn's bar draws at its first row; the live turn's carries its running clock and settles + // in place. A message folded into a turn (a steer) carries none. + const turnStatus = + enabled && turnKey !== undefined && bar?.index === index + ? turnKey === activeTurnKey + ? active + : (completedByTurn[turnKey] ?? null) + : null return { turnStatus, - ...(anchorsTurnHere && turnStatus !== null ? { turnStatusAbove: true } : {}), + ...(bar?.above === true && turnStatus !== null ? { turnStatusAbove: true } : {}), turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, // Why: the key travels and the row calls one stable handler with it. A // closure per row would be a new identity every render of a streaming // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, - // Liveness is the owning turn's, not the newest prompt's: a running turn's - // rows stay live while a newer message waits behind it. - activeTurnIsWorking: - enabled && - isWorking && - (turnKey === activeTurnKey || - (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) + // Liveness is the live turn's rows, not the newest prompt's: a running turn's rows stay live + // while a newer message waits behind it. With no user boundary at all, the session's + // working state stays authoritative. + activeTurnIsWorking: enabled && isWorking && turnKey === liveTurnKey } }, [ turnKeys, - selfAnchors, + waiting, + bars, + liveTurnKey, enabled, activeTurnKey, active, @@ -167,6 +178,8 @@ export function useMobileNativeChatTurnDisclosure({ activeActivityText, /** Stable for a given chat scope, so it never disturbs a row's memo. */ onToggleTurn: toggleExpandedTurn, - resolveRow + resolveRow, + listMessages: waiting.listMessages, + waitingRows: waiting.waitingRows } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 62f6ada8d5e..f7d440dd738 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -1,9 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { NATIVE_CHAT_UNANCHORED_TURN_KEY, reduceNativeChatTurnTiming, - selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatSettledTurns, type NativeChatTurnStatus, @@ -22,27 +20,24 @@ type ScopedTurnTiming = { /** Per-turn "Thinking / Working for N / Worked for N" timing, on the same shared * state machine the desktop renderer uses so the two surfaces stamp turns alike. */ export function useMobileNativeChatTurnStatus({ - messages, + turnKeys, + liveTurnKey, enabled, isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, thinking = false, scopeKey }: { - messages: readonly NativeChatMessage[] + /** Each row's turn, as `nativeChatTurnMembership` places it. */ + turnKeys: readonly (string | undefined)[] + /** The live turn, whose bar carries the running clock (`nativeChatTurnMembership`). */ + liveTurnKey: string | undefined enabled: boolean isWorking: boolean workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The user message the host says opened the running turn; absent, the latest one. */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership; a turn keyed to its own record is still in - * the transcript, so its clock is not an echo to hand to the next prompt. */ - turnKeysByItemId?: ReadonlyMap | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ @@ -52,9 +47,7 @@ export function useMobileNativeChatTurnStatus({ completedByTurn: Readonly> activeTurnKey: string } { - const activeTurnKey = enabled - ? selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) - : NATIVE_CHAT_UNANCHORED_TURN_KEY + const activeTurnKey = (enabled ? liveTurnKey : undefined) ?? NATIVE_CHAT_UNANCHORED_TURN_KEY const [scopedTiming, setScopedTiming] = useState(() => ({ scopeKey, timingByTurn: {} @@ -73,12 +66,7 @@ export function useMobileNativeChatTurnStatus({ if (!enabled) { return } - const validTurnKeys = new Set( - messages.filter((message) => message.role === 'user').map((message) => message.id) - ) - for (const turnKey of turnKeysByItemId?.values() ?? []) { - validTurnKeys.add(turnKey) - } + const validTurnKeys = new Set(turnKeys.filter((turnKey) => turnKey !== undefined)) const previousActiveTurnKey = previousActiveTurn.current?.scopeKey === scopeKey ? previousActiveTurn.current.turnKey @@ -99,7 +87,7 @@ export function useMobileNativeChatTurnStatus({ ? current : { scopeKey, timingByTurn: nextTiming } }) - }, [activeTurnKey, enabled, isWorking, messages, scopeKey, turnKeysByItemId, workingStartedAt]) + }, [activeTurnKey, enabled, isWorking, scopeKey, turnKeys, workingStartedAt]) // Why: the selection rebuilds its status objects on every call, and a streaming // turn re-renders ~20x/s. Without this, every settled turn's row gets fresh diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx index 944b1d9be13..bc14028c872 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx @@ -86,7 +86,8 @@ describe('useMobileStructuredAgentTurnTiming', () => { vi.useFakeTimers() vi.setSystemTime(CLIENT_NOW) const items = [ - user('u1', 1), + // The acknowledged send: its entry's key is the submission's, not the provider's. + user('orca:first', 1), lifecycle( 't1', 2, diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index 9181856cc0d..34f0df7edff 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { StructuredAgentHostClock } from '../../../src/shared/structured-agent-session-reducer' import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' import { @@ -12,10 +13,8 @@ import { } from '../../../src/shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the transcript key that owns the running turn's bar, each - * row's owning turn, and a - * skew-free start for the live counter whose host-to-local conversion is latched - * once per turn. */ + * off the journal, and a skew-free start for the live counter whose host-to-local + * conversion is latched once per turn. */ export function useMobileStructuredAgentTurnTiming( { items, @@ -29,14 +28,15 @@ export function useMobileStructuredAgentTurnTiming( turnId: string | null ): { settledTurns: NativeChatSettledTurns + /** What places each transcript row in its turn; the same read desktop makes. */ + turnJournal: NativeChatTurnJournal workingStartedAt: number | null - activeTurnOpenedBy: string | null - turnKeysByItemId: ReadonlyMap } { - const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( + const { settledTurns, runningTiming } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) + const turnJournal = useMemo(() => ({ items, submissions }), [items, submissions]) const [latch, setLatch] = useState(null) // Stamp during render (React's derive-from-props pattern) so the first paint of // a new turn already counts from the right instant. @@ -50,10 +50,5 @@ export function useMobileStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { - settledTurns, - workingStartedAt: step.workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId - } + return { settledTurns, turnJournal, workingStartedAt: step.workingStartedAt } } diff --git a/src/main/claude/claude-background-task-row-journal.test.ts b/src/main/claude/claude-background-task-row-journal.test.ts index ecd23480605..736583d0324 100644 --- a/src/main/claude/claude-background-task-row-journal.test.ts +++ b/src/main/claude/claude-background-task-row-journal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionEventSink, @@ -94,12 +95,16 @@ describe('Claude background task row journal', () => { } const resolver = new ClaudeBackgroundTaskIdentityResolver() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: false, reason: 'backpressure' }) expect(task.lastSerialized).toBeNull() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: true }) expect(task.lastSerialized).not.toBeNull() @@ -130,7 +135,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: true }) expect(calls).toEqual([ { @@ -153,7 +164,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -175,7 +187,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe({ @@ -203,7 +216,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) expect(rows.observe(START_BASH)).toBe(true) @@ -227,7 +241,8 @@ describe('Claude background task row journal', () => { tryAppendResolvedItemAndPublish: appendAndPublish }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -249,7 +264,8 @@ describe('Claude background task row journal', () => { })) }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) for (let index = 0; index < 512; index += 1) { @@ -287,7 +303,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: false, reason }) expect(task.lastSerialized).toBeNull() }) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index a4779898204..956bc770f74 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -1,6 +1,7 @@ import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' import { @@ -141,6 +142,8 @@ export function writeClaudeBackgroundTaskRow( identities: ClaudeBackgroundTaskIdentityResolver, id: string, row: ClaudeBackgroundTaskRow, + /** The turn the row belongs to, read once `beforeAppend` has opened it. */ + turnScope: () => AgentJournalTurnScope, /** Runs before admission to preserve turn-before-row ordering; duplicate * delivery skips it, and a retry reuses the turn the first attempt opened. */ beforeAppend?: () => void, @@ -157,7 +160,11 @@ export function writeClaudeBackgroundTaskRow( // recreated. Keep unresolved writes from distinct provider runs queued side // by using the provider's parent tool identity as the coalescing discriminator. const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null]) - const appendOptions = { coalescingKey, ...(lifecycle ? { lifecycle: true } : {}) } + const appendOptions = { + coalescingKey, + turnScope: turnScope(), + ...(lifecycle ? { lifecycle: true } : {}) + } const publishOptions = lifecycle ? { lifecycle: true } : {} const resolveIdentity = (journal: StructuredAgentSessionLifecycleJournal) => identities.resolve(journal, id, row.toolUseId) diff --git a/src/main/claude/claude-background-task-row-test-support.ts b/src/main/claude/claude-background-task-row-test-support.ts index 450f4ebe292..af03378331b 100644 --- a/src/main/claude/claude-background-task-row-test-support.ts +++ b/src/main/claude/claude-background-task-row-test-support.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' // Shared fixtures and the sink harness the background-task row suites drive. // // Moved out of claude-background-task-rows.test.ts verbatim when that suite @@ -85,7 +86,8 @@ export function harness( sink, isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId), openOutputTurn: () => turnOpens.push(1), - now: () => (clock += 10) + now: () => (clock += 10), + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const keys = (): string[] => items.map((item) => diff --git a/src/main/claude/claude-background-task-row-writer.ts b/src/main/claude/claude-background-task-row-writer.ts index bb973a1ae70..0415503b9c2 100644 --- a/src/main/claude/claude-background-task-row-writer.ts +++ b/src/main/claude/claude-background-task-row-writer.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -19,6 +20,7 @@ export class ClaudeBackgroundTaskRowWriter { constructor( private readonly sink: StructuredAgentSessionEventSink, + private readonly turnScope: () => AgentJournalTurnScope, private readonly onPersistenceFailure?: (error: Error) => void ) {} @@ -33,6 +35,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, id, row, + this.turnScope, beforeAppend, lifecycle ) @@ -61,6 +64,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, pending.id, pending.row, + this.turnScope, undefined, pending.lifecycle ) diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts index 64829d0493a..d6f50f3cfc0 100644 --- a/src/main/claude/claude-background-task-rows.ts +++ b/src/main/claude/claude-background-task-rows.ts @@ -1,6 +1,7 @@ // One durable row per Claude background `task_id`, revised in place from the // lifecycle frames so a failed command prints once with the provider sentence. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { record, taskAliasId } from './claude-background-task-frames' @@ -46,6 +47,8 @@ export type ClaudeBackgroundTaskRowsDeps = { * output, so writing one must reopen a turn the provider resumed itself — * otherwise the session renders the row while reporting idle. */ openOutputTurn?: (frame: Record, observedAt: number) => void + /** The turn a row written now belongs to: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope onPersistenceFailure?: (error: Error) => void now?: () => number } @@ -60,7 +63,11 @@ export class ClaudeBackgroundTaskRows { constructor(private readonly deps: ClaudeBackgroundTaskRowsDeps) { this.now = deps.now ?? (() => Date.now()) - this.writer = new ClaudeBackgroundTaskRowWriter(deps.sink, deps.onPersistenceFailure) + this.writer = new ClaudeBackgroundTaskRowWriter( + deps.sink, + deps.turnScope, + deps.onPersistenceFailure + ) this.overflowTerminalRows = new ClaudeOverflowTerminalRows( this.ledgers, this.now, diff --git a/src/main/claude/claude-captured-compact-frames.test-fixture.ts b/src/main/claude/claude-captured-compact-frames.test-fixture.ts new file mode 100644 index 00000000000..059ed560d6c --- /dev/null +++ b/src/main/claude/claude-captured-compact-frames.test-fixture.ts @@ -0,0 +1,219 @@ +// Claude Code 2.1.283 (SDK 0.3.251) stream-json captures of `/compact`, driven through `query()` with +// streaming input and cut to the protocol fields Orca reads. The session id and the summary text +// are replaced, and every frame carried `session_id: CAPTURED_COMPACT_SESSION_ID`; frame order, +// uuids and the relative clock (`at`, ms) are the captured ones. +// +// Verified from these frames: a finished and a stopped `/compact` both end in a `success` result +// with no `terminal_reason`, naming the command's input in `user_message_uuid`. Only the finished +// one carries a `compact_boundary`; the stopped one answers with a synthetic "Compaction canceled." +// instead. `command_lifecycle` frames bracket every input. + +export type CapturedCompactEvent = + | { at: number; sent: { text: string; uuid: string } } + | { at: number; interrupt: true } + | { at: number; frame: Record } + +export const CAPTURED_COMPACT_SESSION_ID = '00000000-0000-4000-8000-000000000001' + +const lifecycle = (commandUuid: string, state: string) => ({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state +}) + +/** A `/compact` left to finish, from its send to its result. */ +export const CAPTURED_COMPACT_SUCCEEDS: CapturedCompactEvent[] = [ + { at: 1750, sent: { text: '/compact', uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de' } }, + { at: 1751, frame: lifecycle('968b34fc-76cc-41bc-adfc-34f8840637a0', 'completed') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'queued') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'started') }, + { + at: 1757, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'de45cbf4-0f29-4ad7-a2a4-f9d9d8847f88' + } + }, + { + at: 6602, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'success', + uuid: 'c9cd62b1-02d9-48fc-bb07-1ccb4889c0af' + } + }, + { + at: 6608, + frame: { + type: 'system', + subtype: 'init', + uuid: '9cebf016-94d0-43d5-a23b-acbd606efeae', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 6609, + frame: { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { + trigger: 'manual', + pre_tokens: 13576, + post_tokens: 1011, + cumulative_dropped_tokens: 12565, + duration_ms: 4711 + }, + uuid: '1ed30bd1-8113-4c3d-b4ef-8020a2f80bfe' + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: false, + isSynthetic: true, + uuid: '4fa68de9-8e28-40d1-a332-da8dba6edd14', + message: { + role: 'user', + content: + 'This session is being continued from a previous conversation that ran out of context. [summary scrubbed]' + } + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: true, + uuid: '302b225c-05fd-45a5-ac2d-0435f9b50845', + message: { role: 'user', content: 'Compacted' } + } + }, + { + at: 6609, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de', + uuid: 'd4c86a55-621b-40ae-9f26-612c842d3ca2' + } + } +] + +/** A `/compact` interrupted 1.5 s in, then the next send and its own answer. */ +export const CAPTURED_COMPACT_STOPPED_THEN_SEND: CapturedCompactEvent[] = [ + { at: 9663, sent: { text: '/compact', uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d' } }, + { at: 9664, frame: lifecycle('0be27e6d-49b7-4ee8-a784-744b3fb3b55c', 'completed') }, + { at: 9664, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'queued') }, + { at: 9665, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'started') }, + { + at: 9666, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'c85f1131-b4a5-424e-af6a-85e8bd98b2fc' + } + }, + { at: 11166, interrupt: true }, + { + at: 11168, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'failed', + compact_error: 'API Error: Request was aborted.', + uuid: '7fede6b2-afdf-4455-8c69-096ae3fa6f55' + } + }, + { + at: 11170, + frame: { + type: 'system', + subtype: 'init', + uuid: '067d1daa-f0da-4ca0-a9de-bedaa72c606a', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 11170, + frame: { + type: 'assistant', + parent_tool_use_id: null, + uuid: '0d252793-d95f-4c80-b4a1-7309614b7eeb', + message: { + id: '9c14e2f0-6480-4760-92f8-aa73e461bc47', + model: '', + role: 'assistant', + content: [{ type: 'text', text: 'Compaction canceled.' }] + } + } + }, + { + at: 11170, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', + uuid: '523a8c1d-5054-422e-b81e-f5f350d992fd' + } + }, + { + at: 11170, + sent: { + text: 'Reply with the single word AFTERSTOP.', + uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e' + } + }, + { at: 11170, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'cancelled') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'queued') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'started') }, + { + at: 11200, + frame: { + type: 'system', + subtype: 'init', + uuid: '1eedc4e5-d48c-4682-b593-144466641d13', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 12778, + frame: { + type: 'assistant', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + parent_tool_use_id: null, + uuid: '39969651-5abd-4dba-ae80-ab1299c07a47', + message: { + id: 'msg_011CfUXeXFfvAtj1PLLjB1H4', + model: 'claude-opus-5-5', + role: 'assistant', + content: [{ type: 'text', text: 'AFTERSTOP' }] + } + } + }, + { + at: 12844, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + terminal_reason: 'completed', + result: 'AFTERSTOP', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + uuid: 'd9b4faaa-f9be-4d7b-9f81-a5106bd003f3' + } + }, + { at: 12844, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'completed') } +] diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts new file mode 100644 index 00000000000..d777e8a423d --- /dev/null +++ b/src/main/claude/claude-command-turn.test.ts @@ -0,0 +1,367 @@ +// A `/compact` as the Claude translator's open turn, driven by real captured frames: which of them +// end the command, how, and what the timeline draws. + +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CAPTURED_COMPACT_SESSION_ID, + CAPTURED_COMPACT_STOPPED_THEN_SEND, + CAPTURED_COMPACT_SUCCEEDS, + type CapturedCompactEvent +} from './claude-captured-compact-frames.test-fixture' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const COMMAND_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_KEY = agentJournalItemKey(COMMAND_IDENTITY) +const IN_COMMAND: AgentJournalTurnScope = { kind: 'turn', turnItemId: COMMAND_KEY } +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 900, + startedAt: 1_000 +} + +type Row = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** A sink over a journal that holds the host's running command turn, revising rows in place. */ +function harness() { + const rows = new Map([[COMMAND_KEY, { key: COMMAND_KEY, body: RUNNING }]]) + const writes: Row[] = [] + const write = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => { + const row = { key: agentJournalItemKey(identity), body, ...(turnScope ? { turnScope } : {}) } + rows.set(row.key, row) + writes.push(row) + } + const journal: StructuredAgentSessionRevisionJournal = { + epoch: 'epoch-1', + itemBody: (itemId) => rows.get(itemId)?.body ?? null, + visitItems: (visit) => { + let sequence = 0 + for (const row of rows.values()) { + visit(row.key, sequence++, row.body) + } + } + } + const revise: NonNullable = ( + _bytes, + resolve, + options + ) => { + const resolved = resolve(journal) + if (resolved) { + write(resolved.identity, resolved.body, options.turnScope) + } + return { accepted: true } + } + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => write(identity, body, options.turnScope), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryReviseResolvedItem: revise, + tryReviseResolvedItemAndPublish: revise + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const frame = (message: Record, extra: Record = {}) => + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { session_id: CAPTURED_COMPACT_SESSION_ID, ...message }, + observedAt: 5_000, + ...extra + }) + const begin = (sentUuid: string) => + translator.beginCommand({ + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_IDENTITY, + resultIdentity: { provider: 'orca', clientMessageId: 'command-result:cmd-1' }, + running: RUNNING, + providerSessionId: CAPTURED_COMPACT_SESSION_ID, + sentUuid + }) + /** Replays a capture as the adapter delivers it: `/compact` goes through `beginCommand`, and a + * Stop marks the interrupt the way the cancel path does. */ + const replay = (events: readonly CapturedCompactEvent[]) => { + for (const event of events) { + if ('sent' in event) { + if (event.sent.text === '/compact') { + begin(event.sent.uuid) + } + } else if ('interrupt' in event) { + translator.commandInterruptRequested(RUNNING.turnId) + } else { + frame(event.frame) + } + } + } + const commandTurn = () => readAgentJournalTurn(rows.get(COMMAND_KEY)?.body) + const drawn = () => + [...rows.values()].filter((row) => row.key !== COMMAND_KEY && row.body.kind !== 'turn') + return { translator, rows, writes, frame, begin, replay, commandTurn, drawn } +} + +describe('a captured /compact as the open Claude turn', () => { + it('ends a finished compaction as a success, drawing only the compaction separator', () => { + const { replay, commandTurn, drawn, translator } = harness() + replay(CAPTURED_COMPACT_SUCCEEDS) + + expect(commandTurn()).toMatchObject({ + turnId: RUNNING.turnId, + state: 'completed', + outcome: 'success', + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt, + startedAt: RUNNING.startedAt + }) + // Not the continuation summary, not the command's echo, not a lifecycle opcode row. + expect(drawn()).toEqual([ + { + key: agentJournalItemKey({ provider: 'orca', clientMessageId: 'command-result:cmd-1' }), + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: IN_COMMAND + } + ]) + expect(translator.commandTurnId).toBeNull() + }) + + it("ends a stopped compaction as the user's cancellation, then answers the next send in its own turn", () => { + const { replay, commandTurn, drawn, rows } = harness() + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND) + + // Claude's result for the stopped command is `success` like a finished one's. + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + const answer = drawn().find((row) => row.body.kind === 'message') + expect(answer?.body).toMatchObject({ role: 'assistant' }) + expect(answer?.turnScope).not.toEqual(IN_COMMAND) + const answered = readAgentJournalTurn( + answer?.turnScope?.kind === 'turn' ? rows.get(answer.turnScope.turnItemId)?.body : undefined + ) + expect(answered).toMatchObject({ state: 'completed', outcome: 'success' }) + // "Compaction canceled." is the command's own output, and a stop earns no failure row. + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('reads a compaction with no boundary and no stop as a failure, with Claude’s reason', () => { + const { replay, commandTurn, drawn } = harness() + // The stopped capture without Orca's stop: Claude reported the compaction failed. + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND.filter((event) => !('interrupt' in event))) + + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + drawn().filter( + (row) => row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === COMMAND_KEY + ) + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: API Error: Request was aborted.', + failure: { + kind: 'compactionFailed', + detail: { text: 'API Error: Request was aborted.', audience: 'person' } + }, + tone: 'error' + } + }) + ]) + }) + + it('reads a stop that lands after the summary but before the boundary as a cancellation', () => { + const { replay, commandTurn, drawn } = harness() + // Only the boundary says the conversation was replaced; the status that precedes it does not. + replay( + CAPTURED_COMPACT_SUCCEEDS.flatMap((event): CapturedCompactEvent[] => + 'frame' in event && event.frame.subtype === 'compact_boundary' + ? [] + : 'frame' in event && event.frame.compact_result === 'success' + ? [event, { at: event.at, interrupt: true }] + : [event] + ) + ) + + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('leaves the command running at a result that names another input', () => { + const { begin, frame, commandTurn, translator } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ type: 'result', subtype: 'success', is_error: false, user_message_uuid: 'earlier' }) + expect(commandTurn()?.state).toBe('running') + expect(translator.commandTurnId).toBe(RUNNING.turnId) + + frame({ + type: 'result', + subtype: 'success', + is_error: false, + user_message_uuid: 'compact-input' + }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'success' }) + }) +}) + +describe('the command turn at each point the ordinary result path threads through', () => { + it('suppresses a provider reopen after a command that failed, as after any failed turn', () => { + const failed = harness() + failed.begin('compact-input') + failed.frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + failed.frame({ + type: 'assistant', + uuid: 'stray', + parent_tool_use_id: null, + message: { id: 'msg-stray', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(failed.translator.currentTurnId).toBeNull() + + const finished = harness() + finished.begin('compact-input') + finished.frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + finished.frame({ type: 'result', subtype: 'success', is_error: false }) + finished.frame({ + type: 'assistant', + uuid: 'resumed', + parent_tool_use_id: null, + message: { id: 'msg-resumed', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(finished.translator.currentTurnId).not.toBeNull() + }) + + it("settles a child still working in the command's turn when the command ends", () => { + const { begin, frame, rows } = harness() + begin('compact-input') + frame({ + type: 'system', + subtype: 'task_started', + task_id: 'task-1', + task_type: 'local_agent', + description: 'Map the lane' + }) + frame({ type: 'result', subtype: 'success', is_error: false }) + const group = rows.get( + agentJournalItemKey({ + provider: 'orca', + clientMessageId: `claude-subagents:${CAPTURED_COMPACT_SESSION_ID}:${RUNNING.turnId}` + }) + ) + const agents = + group?.body.kind === 'message' + ? group.body.blocks.flatMap((block) => + block.type === 'subagent-group' ? block.agents : [] + ) + : [] + expect(agents).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it("records the context window the command's result reports on the command's turn", () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'init', model: 'claude-opus-5-5[1m]', uuid: 'init' }) + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ + type: 'result', + subtype: 'success', + is_error: false, + modelUsage: { 'claude-opus-5-5[1m]': { contextWindow: 1_000_000 } } + }) + expect(commandTurn()).toMatchObject({ + state: 'completed', + contextUsage: { window: { tokens: 1_000_000 } } + }) + }) + + it("reports a compaction Claude refused in Claude's own words, and one with none plainly", () => { + const worded = harness() + worded.begin('compact-input') + worded.frame({ + type: 'system', + subtype: 'status', + compact_result: 'failed', + compact_error: 'Not enough messages to compact.' + }) + worded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(worded.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(worded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + }, + tone: 'error' + } + ]) + + const unworded = harness() + unworded.begin('compact-input') + unworded.frame({ type: 'system', subtype: 'status', compact_result: 'failed' }) + unworded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(unworded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a result with no compaction and no failure as a compaction Claude never confirmed', () => { + const { begin, frame, commandTurn, drawn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'success', is_error: false }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('fails a command whose result ended in error', () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it("draws one error row, the provider's, for a command whose result is an error", () => { + const { begin, frame, drawn } = harness() + begin('compact-input') + frame({ + type: 'result', + subtype: 'success', + is_error: true, + result: 'API Error: 529 upstream overloaded' + }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ text: 'API Error: 529 upstream overloaded' }), + turnScope: IN_COMMAND + }) + ]) + }) +}) diff --git a/src/main/claude/claude-command-turn.ts b/src/main/claude/claude-command-turn.ts new file mode 100644 index 00000000000..be846dc7858 --- /dev/null +++ b/src/main/claude/claude-command-turn.ts @@ -0,0 +1,153 @@ +// A conversation command Claude runs as the session's open turn. The turn is the host's record, and +// the command's own result ends it. Everything Claude writes for the command meanwhile — the +// summary it continues from, the command's echo, a "Compaction canceled." — is the command's +// output, never a reply, so none of it draws. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { claudeText } from './claude-structured-item-translation' +import { claudeResultFailure } from './claude-structured-provider-fallback' +import type { ClaudeCurrentTurn, ClaudeTurnEnd } from './claude-turn-lifecycle-item' +import { isRootClaudeFrame } from './claude-turn-opening' +import type { ClaudeOpenTurn } from './claude-open-turn' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +export type ClaudeCommandTurn = { + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The command input's uuid, which the result answering it names. */ + sentUuid: string + compacted: boolean + /** Orca asked Claude to stop the command. */ + interruptRequested: boolean + /** Claude reported the compaction failed, with its words for a person when it gave any. */ + failed: { detail?: ProviderDiagnostic } | null +} + +export type ClaudeCommandStart = StructuredAgentSessionCommandRun & { + providerSessionId: string + sentUuid: string +} + +export function claudeCommandCurrentTurn(start: ClaudeCommandStart): ClaudeCurrentTurn { + const { running } = start + return { + sessionId: start.providerSessionId, + turnId: start.turnId, + startedAt: running.startedAt ?? Date.now(), + ...(running.requestedAt === undefined ? {} : { requestedAt: running.requestedAt }), + userItemId: running.userItemId ?? agentJournalItemKey(start.identity), + command: { + identity: start.identity, + resultIdentity: start.resultIdentity, + sentUuid: start.sentUuid, + compacted: false, + interruptRequested: false, + failed: null + } + } +} + +/** Reads a running command's evidence off a frame. True for the command's own output, which + * draws nothing. */ +export function observeClaudeCommandFrame( + command: ClaudeCommandTurn | null, + message: Record +): boolean { + if (!command) { + return false + } + if (message.type === 'system') { + // Only the boundary says the history was replaced; `compact_result: 'success'` precedes it. + if (message.subtype === 'compact_boundary') { + command.compacted = true + } else if (message.compact_result === 'failed') { + const words = claudeText(message.compact_error) + const detail = words === null ? undefined : providerDiagnostic(words, 'person') + command.failed = detail ? { detail } : {} + } + return false + } + return ( + isRootClaudeFrame(message) && + (message.type === 'user' || message.type === 'assistant' || message.type === 'stream_event') + ) +} + +/** The command's end from a root result, and the one row that reports it; null when the result + * names another input. */ +export function claudeCommandEnd( + command: ClaudeCommandTurn, + message: Record, + completedAt: number +): { end: ClaudeTurnEnd; row: AgentJournalItemBody | null } | null { + const answers = claudeText(message.user_message_uuid) + if (answers !== null && answers !== command.sentUuid) { + return null + } + // A stopped `/compact` ends in the same success result as a finished one: only the provider's + // report of the compaction tells them apart. + const shown = claudeResultFailure(message) + const verdict = structuredCompactionOutcome({ + compacted: command.compacted, + interruptRequested: command.interruptRequested, + failed: command.failed ?? (shown ? {} : null) + }) + const durationMs = message.duration_ms + const end: ClaudeTurnEnd = { + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + completedAt, + outcome: verdict.outcome, + ...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 + ? { durationMs } + : {}) + } + if (verdict.outcome === 'success') { + return { end, row: { kind: 'status', text: 'Context compacted', presentation: 'compaction' } } + } + // An error result already draws its own row through the provider fallback. + return verdict.failure && !shown + ? { + end, + row: { + kind: 'status', + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.claude + }), + tone: 'error' + } + } + : { end, row: null } +} + +/** A root result, when a command is the open turn: the command's end, with its one result row + * already written; `another-input` for a result that answers something else; null when no + * command is open. */ +export function claudeCommandResultEnd( + turn: Pick, + sink: Pick, + message: Record, + completedAt: number +): ClaudeTurnEnd | 'another-input' | null { + const { command } = turn + if (!command) { + return null + } + const ended = claudeCommandEnd(command, message, completedAt) + if (!ended) { + return 'another-input' + } + if (ended.row) { + sink.appendItem(command.resultIdentity, ended.row, { turnScope: turn.turnScope }) + } + return ended.end +} diff --git a/src/main/claude/claude-context-usage.ts b/src/main/claude/claude-context-usage.ts index 010620cf09d..43e1ef5c31e 100644 --- a/src/main/claude/claude-context-usage.ts +++ b/src/main/claude/claude-context-usage.ts @@ -10,7 +10,7 @@ import { type AgentSessionTokenUsage } from '../../shared/agent-session-context-usage' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' /** A report later than this describes a context the user has likely moved past. */ export const CLAUDE_CONTEXT_USAGE_TIMEOUT_MS = 5_000 diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 1ccf24e1393..45feeb84364 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -6,6 +6,7 @@ import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import type { ClaudeCommandStart } from './claude-command-turn' export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void @@ -13,6 +14,14 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** The open turn's id while it is a conversation command's. */ + readonly commandTurnId: string | null + /** Makes the host's command turn the open one until the command's result ends it. */ + beginCommand: (start: ClaudeCommandStart) => void + /** The command was never sent. */ + forgetCommand: (turnId: string) => void + /** Orca asked Claude to stop the command `turnId` names. */ + commandInterruptRequested: (turnId: string) => void /** True while a turn is open and the provider's current request cycle has * done root work since its init — the state in which the CLI folds an * arriving send into the turn. */ diff --git a/src/main/claude/claude-journal-turn-scope.test.ts b/src/main/claude/claude-journal-turn-scope.test.ts new file mode 100644 index 00000000000..fef833ced57 --- /dev/null +++ b/src/main/claude/claude-journal-turn-scope.test.ts @@ -0,0 +1,78 @@ +// Which turn a Claude row belongs to (B8): the root turn open when the row is written, whoever +// produced it, and the conversation once that turn has ended. + +import { expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionItemAppendOptions +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function recorder() { + const writes: { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options: StructuredAgentSessionItemAppendOptions + }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => writes.push({ identity, body, options }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const scopeOfProse = (text: string) => + writes.findLast( + (write) => + write.body.kind === 'message' && + write.body.blocks.some((block) => block.type === 'text' && block.text === text) + )?.options.turnScope + return { sink, writes, scopeOfProse } +} + +function frame(message: Record, startsTurn = false) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(startsTurn ? { startsTurn: true as const } : {}), + message: { session_id: 'claude-session', ...message } + } +} + +function prose(uuid: string, text: string, parentToolUseId: string | null = null) { + return frame({ + type: 'assistant', + uuid, + parent_tool_use_id: parentToolUseId, + message: { role: 'assistant', content: [{ type: 'text', text }] } + }) +} + +it("scopes the session's and a subagent's rows to the open turn, and a late one to none", () => { + const { sink, writes, scopeOfProse } = recorder() + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + translator.handle( + frame( + { + type: 'user', + uuid: 'user-1', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'go' }] } + }, + true + ) + ) + translator.handle(prose('assistant-1', 'Working on it')) + translator.handle(prose('child-1', 'Child mid-turn', 'toolu_1')) + translator.handle(frame({ type: 'result', subtype: 'success', uuid: 'result-1', result: 'ok' })) + translator.handle(prose('child-2', 'Child after the turn', 'toolu_1')) + + const turnRecord = writes.find((write) => write.body.kind === 'turn') + expect(turnRecord?.options.turnScope).toEqual({ kind: 'thread' }) + const turnKey = expect.stringContaining('turn-lifecycle') + expect(scopeOfProse('Working on it')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child mid-turn')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child after the turn')).toEqual({ kind: 'thread' }) +}) diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 8a9dc30cd5a..864624fe59e 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -5,15 +5,21 @@ // keeping a copy, so there is nothing to disagree with. import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { - claudeTurnLifecycleIdentity, + claudeCurrentTurnIdentity, claudeTurnLifecycleItem, type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' import { writeClaudeTurnRow } from './claude-turn-row-revision' +import type { ClaudeCommandTurn } from './claude-command-turn' import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-opening' export type ClaudeOpenTurnDeps = { @@ -55,9 +61,21 @@ export class ClaudeOpenTurn { /** The open turn's row, where a fact about the running turn lands. */ get identity(): AgentJournalItemIdentity | null { - return this.current - ? claudeTurnLifecycleIdentity(this.current.sessionId, this.current.turnId) - : null + return this.current ? claudeCurrentTurnIdentity(this.current) : null + } + + /** The conversation command the open turn is, if it is one. */ + get command(): ClaudeCommandTurn | null { + return this.current?.command ?? null + } + + /** Which turn a row written now belongs to: the open one, or none. A subagent's rows too — + * its work is its parent turn's. */ + get turnScope(): AgentJournalTurnScope { + const identity = this.identity + return identity + ? { kind: 'turn', turnItemId: agentJournalItemKey(identity) } + : AGENT_JOURNAL_THREAD_SCOPE } get groupKey(): string | null { @@ -99,6 +117,32 @@ export class ClaudeOpenTurn { this.deps.sink.setActivity?.(null) } + /** A conversation command the host opened a turn for. Its row is the host's, already written, + * so only an end is published; the command's result is what ends it. */ + beginCommand(turn: ClaudeCurrentTurn): void { + this.deps.onOpen?.() + if (this.current) { + this.deps.settleChildren(this.groupKey) + this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt }) + } + this.current = turn + this.deps.sink.setActivity?.(null) + } + + /** Orca asked the provider to stop the command `turnId` names. */ + commandInterruptRequested(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current.command.interruptRequested = true + } + } + + /** The command was never sent; its turn is the host's to settle. */ + forgetCommand(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current = null + } + } + /** The provider produced, so a turn is running. Idempotent: every frame of one * reply stays inside the turn its first frame opened. A subagent's output is * its parent turn's work and never a turn of its own. */ diff --git a/src/main/claude/claude-prompt-journaling.ts b/src/main/claude/claude-prompt-journaling.ts deleted file mode 100644 index 6d3bfe148cf..00000000000 --- a/src/main/claude/claude-prompt-journaling.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Journaling an approval or question prompt, and remembering the rows it wrote -// so a cancellation can tombstone exactly those. - -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - claudeApprovalItem, - claudePromptIdentity, - claudeQuestionItems -} from './claude-structured-prompt-items' - -export type ClaudePromptJournalDeps = { - sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string) => void - /** Prompt key → the rows it wrote, owned by the translator so a cancel can sweep them. */ - promptItems: Map -} - -export function journalClaudePrompt( - deps: ClaudePromptJournalDeps, - event: Extract -): void { - const identities: AgentJournalItemIdentity[] = [] - if (event.prompt.kind === 'question') { - for (const question of claudeQuestionItems({ - sessionId: event.sessionId, - prompt: event.prompt - })) { - identities.push(question.identity) - deps.sink.appendItem(question.identity, question.body) - deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) - } - } else { - const identity = claudePromptIdentity({ - sessionId: event.sessionId, - promptKey: event.prompt.promptKey - }) - identities.push(identity) - deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) - deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) - } - deps.promptItems.set(event.prompt.promptKey, identities) - deps.sink.publish() -} diff --git a/src/main/claude/claude-provisional-row-corrections.test.ts b/src/main/claude/claude-provisional-row-corrections.test.ts index d18a71a17fe..cb317c5ca86 100644 --- a/src/main/claude/claude-provisional-row-corrections.test.ts +++ b/src/main/claude/claude-provisional-row-corrections.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody, @@ -38,6 +39,7 @@ function ledger(initial: Record = {}) { : { kind: 'linked', linkage: { agentId: ref, providerParentRef: ref, producerKind: 'agent' } } } const corrections = new ClaudeProvisionalRowCorrections({ + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE, linkageFor: (ref) => verdicts.get(ref) ?? { kind: 'pending' }, settledLinkageFor: (ref) => { const verdict = settledFor(ref) @@ -71,9 +73,11 @@ function ledger(initial: Record = {}) { } describe('ClaudeProvisionalRowCorrections', () => { - it("stamps the session's own rows with nothing and owes them nothing", () => { + it("stamps the session's own rows with no producer and owes them nothing", () => { const { corrections, rewrites } = ledger() - expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({}) + expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(corrections.pending).toBe(0) corrections.retry() expect(rewrites).toEqual([]) diff --git a/src/main/claude/claude-provisional-row-corrections.ts b/src/main/claude/claude-provisional-row-corrections.ts index a4b7d039902..92064e23f02 100644 --- a/src/main/claude/claude-provisional-row-corrections.ts +++ b/src/main/claude/claude-provisional-row-corrections.ts @@ -21,9 +21,11 @@ import { agentJournalLinkageFields } from '../../shared/agent-session-journal-pr import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' /** @@ -36,12 +38,7 @@ import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' export type ClaudeRowStamp = ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody -) => StructuredAgentSessionAppendOptions - -/** The session's own agent wrote this row: nothing is stamped, nothing is owed. - * Only for a site with no ledger to consult; a ledger-backed root write goes - * through `stampFor(null)`, which also supersedes anything owed to the row. */ -export const rootClaudeRowStamp: ClaudeRowStamp = () => ({}) +) => StructuredAgentSessionItemAppendOptions /** Corrections outstanding at once, across every producer. */ const MAX_OUTSTANDING_CORRECTIONS = 256 @@ -57,17 +54,19 @@ type OutstandingCorrection = { * a tool call and its result do — and a correction carrying the older body * would revert the row it is only meant to re-attribute. */ body: AgentJournalItemBody - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions } export type ClaudeProvisionalRowCorrectionsDeps = ClaudeSubagentLinkageSource & { + /** The turn a row written now belongs to: the open root turn, whoever produced the row. */ + turnScope: () => AgentJournalTurnScope /** Re-appends a row under its own identity, which revises it in place. * Returns whether the write was ADMITTED: a sink under backpressure refuses, * and a correction dropped on a refusal is an obligation nothing re-derives. */ rewrite: ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ) => boolean publish: () => void } @@ -92,12 +91,12 @@ export class ClaudeProvisionalRowCorrections { if (parentToolUseId === null) { return (identity) => { this.supersede(identity) - return {} + return { turnScope: this.deps.turnScope() } } } return (identity, body) => { const provisional = this.deps.linkageFor(parentToolUseId).kind === 'pending' - const options = this.stamp(parentToolUseId) + const options = { ...this.stamp(parentToolUseId), turnScope: this.deps.turnScope() } if (provisional) { this.remember(parentToolUseId, identity, body, options) } else { @@ -160,7 +159,7 @@ export class ClaudeProvisionalRowCorrections { this.outstanding.delete(agentJournalItemKey(identity)) } - private stamp(parentToolUseId: string): StructuredAgentSessionAppendOptions { + private stamp(parentToolUseId: string): ReturnType { return agentJournalLinkageFields(this.deps.settledLinkageFor(parentToolUseId).linkage) } @@ -171,14 +170,16 @@ export class ClaudeProvisionalRowCorrections { if (sameLinkage(options, correction.stamped)) { return 'unchanged' } - return this.deps.rewrite(correction.identity, correction.body, options) ? 'wrote' : 'refused' + // A re-stamp revises the row, so its scope stays the one it was created with. + const restamped = { ...options, turnScope: correction.stamped.turnScope } + return this.deps.rewrite(correction.identity, correction.body, restamped) ? 'wrote' : 'refused' } private remember( ref: string, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions ): void { if (this.givenUp.has(ref)) { return @@ -233,8 +234,8 @@ export class ClaudeProvisionalRowCorrections { } function sameLinkage( - left: StructuredAgentSessionAppendOptions, - right: StructuredAgentSessionAppendOptions + left: AgentJournalProducerLinkage, + right: AgentJournalProducerLinkage ): boolean { return ( left.agentId === right.agentId && diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts new file mode 100644 index 00000000000..14fd2e94eb6 --- /dev/null +++ b/src/main/claude/claude-result-journaling.ts @@ -0,0 +1,88 @@ +// Journaling ONE Claude `result` frame: the end of the root turn it settles, and the diagnostic +// row a failed or unrecognised result leaves. +// +// Split out of the translator when that file reached its line budget; the translator still owns +// the open turn and every collaborator this writes through. + +import type { ClaudeContextFacts } from './claude-context-facts' +import { claudeCommandResultEnd } from './claude-command-turn' +import type { ClaudeMessageJournalContext } from './claude-message-journaling' +import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { + claudeProviderFrameKind, + claudeResultFailure, + isSettledClaudeResultKind +} from './claude-structured-provider-fallback' +import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' +import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' + +export type ClaudeResultJournalContext = Pick< + ClaudeMessageJournalContext, + | 'sink' + | 'streamedBlocks' + | 'streamedText' + | 'subagents' + | 'providerFallback' + | 'corrections' + | 'turn' +> & { + prompts: ClaudeJournalPrompts + context: ClaudeContextFacts +} + +export function journalClaudeResult( + { + sink, + streamedBlocks, + streamedText, + subagents, + providerFallback, + corrections, + turn, + prompts, + context + }: ClaudeResultJournalContext, + message: Record, + observedAt: number +): void { + // Every turn this translator opens is root by construction, so a nested + // result settles the child that produced it and never the turn. The + // diagnostic below still runs: a child's failure is reportable even when + // it ends no turn. + const settlesTurn = isRootClaudeFrame(message) + const commandEnd = settlesTurn ? claudeCommandResultEnd(turn, sink, message, observedAt) : null + if (commandEnd === 'another-input') { + return + } + // Read before the settle below closes it: the result reports that turn's end. + const endedTurnScope = turn.turnScope + if (settlesTurn) { + prompts.retryPendingCancellations() + turn.suppressReopenOnFailure(message.is_error === true) + // The turn is over however it ended, so a foreground child still + // reported as working will never be settled by an event. + subagents.settleTurn(turn.groupKey) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt)) + // The turn is over. A block still awaiting its final keeps the text the + // flush above journaled, but its live state goes: an interrupted turn + // would otherwise retain that text for the life of the session. + streamedBlocks.clear() + streamedText.settle() + } + const kind = claudeProviderFrameKind(message) + const failure = claudeResultFailure(message) + if (failure || !isSettledClaudeResultKind(kind)) { + providerFallback.append( + kind, + message, + failure?.text, + undefined, + undefined, + // A result that settles no turn is a CHILD's result: this + // translator only ever opens root turns. + settlesTurn + ? () => ({ turnScope: endedTurnScope }) + : corrections.stampFor(claudeFrameParentRef(message)) + ) + } +} diff --git a/src/main/claude/claude-structured-command-dispatch.ts b/src/main/claude/claude-structured-command-dispatch.ts new file mode 100644 index 00000000000..3d03c18178c --- /dev/null +++ b/src/main/claude/claude-structured-command-dispatch.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'node:crypto' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Sent like any message, so the slash-command waiter settles it on its result. The translator + * makes the command's turn the open one before the send, and that same result ends it. */ +export async function dispatchClaudeCommand( + session: ClaudeSession, + command: StructuredAgentSessionCommandRun +): Promise { + const sentUuid = randomUUID() + session.translator?.beginCommand({ + ...command, + providerSessionId: session.providerSessionId, + sentUuid + }) + try { + const admission = await dispatchClaudeTurn(session, { + clientMessageId: command.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + ...(command.running.requestedAt === undefined + ? {} + : { requestedAt: command.running.requestedAt }), + sentUuid + }) + if (admission.state === 'rejected') { + session.translator?.forgetCommand(command.turnId) + } + return admission + } catch (error) { + session.translator?.forgetCommand(command.turnId) + throw error + } +} diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts deleted file mode 100644 index 70aabc5ae50..00000000000 --- a/src/main/claude/claude-structured-compaction.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' -import { compactClaudeSession, isClaudeCompactionContent } from './claude-structured-compaction' -import { sessionFor } from './claude-structured-dispatch-test-support' - -afterEach(() => { - vi.useRealTimers() -}) - -describe('Claude compaction transcript content', () => { - it('keeps generated summaries and command echoes out of the transcript only during explicit compaction', async () => { - const tracker = new StructuredSessionCompaction() - const event = { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'user', - session_id: 'provider', - uuid: 'summary', - message: { role: 'user', content: 'generated compaction summary' } - } - } - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - const completion = tracker.run('orca-session', 'provider', async () => undefined) - expect(isClaudeCompactionContent(tracker, event)).toBe(true) - expect(isClaudeCompactionContent(tracker, { ...event, sessionId: 'other' })).toBe(false) - expect(isClaudeCompactionContent(tracker, { ...event, message: { type: 'result' } })).toBe( - false - ) - tracker.ended('orca-session') - await completion - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - }) - - it('fails a provably unwritten command without waiting for the completion deadline', async () => { - vi.useFakeTimers() - const session = sessionFor( - vi.fn().mockRejectedValue(claudeUnwrittenUserMessageError(new Error('input closed'))) - ) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(60_000), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - - await vi.advanceTimersByTimeAsync(1) - - await expect(pending).resolves.toEqual({ outcome: 'failed' }) - }) - - it('keeps waiting when the command write outcome is ambiguous', async () => { - vi.useFakeTimers() - const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped'))) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(10), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - const rejection = expect(pending).rejects.toThrow('Compaction completion is unconfirmed.') - - await vi.advanceTimersByTimeAsync(10) - - await rejection - }) -}) diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts deleted file mode 100644 index 5b1f67e2e11..00000000000 --- a/src/main/claude/claude-structured-compaction.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import type { - StructuredSessionCompaction, - StructuredSessionCompactionResult -} from '../native-chat/agent-session-wire/structured-session-compaction' -import { dispatchClaudeTurn } from './claude-structured-dispatch' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -/** Compaction needs no ack deadline of its own: `compactions.run` keeps its own - * 180s completion window and settles on Claude's terminal `result` frame, so - * the dispatch here only has to report a refusal to send. */ -export function compactClaudeSession( - session: ClaudeSession, - compactions: StructuredSessionCompaction, - input: Parameters>[0] -): Promise { - return compactions.run( - input.sessionId, - session.providerSessionId, - async () => { - const result = await dispatchClaudeTurn(session, { - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } - }) - if (result.state === 'rejected') { - return { outcome: 'failed' as const } - } - return undefined - }, - input.onLateResult, - input.turnId - ) -} - -export function observeClaudeCompaction( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent, - translator: ClaudeSession['translator'] | undefined -): void { - if (!isClaudeCompactionContent(compactions, event)) { - translator?.handle(event) - } - if (event.type === 'message') { - compactions.claude(event.sessionId, event.message) - } - if (event.type === 'ended') { - compactions.ended(event.sessionId) - } -} - -export function isClaudeCompactionContent( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent -): boolean { - return ( - event.type === 'message' && - compactions.hasPending(event.sessionId) && - ['user', 'assistant', 'stream_event'].includes(String(event.message.type)) - ) -} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 7f197788b68..4ba08d3b642 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -249,6 +249,10 @@ describe('answerClaudePrompt', () => { resolve: resolvePrompt }, currentTurnId: null, + commandTurnId: null, + beginCommand: vi.fn(), + forgetCommand: vi.fn(), + commandInterruptRequested: vi.fn(), flush: vi.fn(), contextActivity: 0, markContextActivity: vi.fn(), diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 612f6391ced..c208cceccdb 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -79,7 +79,13 @@ function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchReject export async function dispatchClaudeTurn( session: ClaudeSession, - input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, + input: { + clientMessageId?: string + body: AgentJournalMessageItem + requestedAt?: number + /** The frame's uuid, for a caller that correlates the provider's answer to it. */ + sentUuid?: string + }, beforeDispatch?: () => Promise ): Promise { let content: unknown[] @@ -98,7 +104,7 @@ export async function dispatchClaudeTurn( // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) - const sentUuid = randomUUID() + const sentUuid = input.sentUuid ?? randomUUID() const arm = () => { ++session.dispatchSequence // A context report asked for before this send may land after it and misstate the context. diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 73dd35071ba..92301af106c 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -1,7 +1,8 @@ import type { AgentJournalApprovalItem, AgentJournalItemIdentity, - AgentJournalQuestionItem + AgentJournalQuestionItem, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' @@ -22,6 +23,7 @@ const ADMITTED = { accepted: true } as const type ClaudeJournalPrompt = { identity: AgentJournalItemIdentity body: AgentJournalApprovalItem | AgentJournalQuestionItem + turnScope: AgentJournalTurnScope } type ClaudeJournalPromptEntry = { @@ -54,6 +56,8 @@ export class ClaudeJournalPrompts { constructor( private readonly deps: { sink: StructuredAgentSessionEventSink + /** The turn that raised the prompt: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope bindPromptItemId?: (journalItemId: string, promptKey: string) => void questionItems?: (input: { sessionId: string @@ -75,13 +79,14 @@ export class ClaudeJournalPrompts { */ handle(event: Extract): void { const items: ClaudeJournalPrompt[] = [] + const turnScope = this.deps.turnScope() if (event.prompt.kind === 'question') { for (const question of (this.deps.questionItems ?? claudeQuestionItems)({ sessionId: event.sessionId, prompt: event.prompt })) { - items.push(question) - this.deps.sink.appendItem(question.identity, question.body) + items.push({ ...question, turnScope }) + this.deps.sink.appendItem(question.identity, question.body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) } } else { @@ -90,8 +95,8 @@ export class ClaudeJournalPrompts { promptKey: event.prompt.promptKey }) const body = claudeApprovalItem(event.prompt) - items.push({ identity, body }) - this.deps.sink.appendItem(identity, body) + items.push({ identity, body, turnScope }) + this.deps.sink.appendItem(identity, body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) } this.deletePrompt(event.prompt.promptKey) @@ -104,10 +109,11 @@ export class ClaudeJournalPrompts { if (items.length === 0) { return ADMITTED } - const mutations = items.map(({ identity, body }) => ({ + const mutations = items.map(({ identity, body, turnScope }) => ({ kind: 'item' as const, identity, - body: cancelledPromptBody(body) + body: cancelledPromptBody(body), + turnScope })) let admission: StructuredAgentSessionSinkAdmission if (this.deps.sink.tryAppendLifecycleBatch) { @@ -129,9 +135,10 @@ export class ClaudeJournalPrompts { return ADMITTED } const body = cancelledPromptBody(item.body) + const options = { lifecycle: true, turnScope: item.turnScope } admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(item.identity, body, { lifecycle: true }) - : (this.deps.sink.appendItem(item.identity, body, { lifecycle: true }), ADMITTED) + ? this.deps.sink.tryAppendItem(item.identity, body, options) + : (this.deps.sink.appendItem(item.identity, body, options), ADMITTED) } else { return { accepted: false, reason: 'failed' } } diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index 170f520e9ec..7b224e8c51a 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -201,7 +202,8 @@ describe('claude journal translation — background task rows', () => { deferred.bind(target) deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = systemFrame({ diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index d122d0af7a2..89df86122b8 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -529,7 +529,9 @@ describe('claude journal translation — which agent produced a row', () => { (entry) => orcaClientMessageId(entry.identity) === 'claude-tool:claude-session:toolu_bash' ) expect(row?.body).toMatchObject({ kind: 'tool-call', state: 'completed' }) - expect(row?.options).toEqual({}) + expect(row?.options).toEqual({ + turnScope: { kind: 'turn', turnItemId: expect.any(String) } + }) }) /** One streamed text block, as the SDK sends it: a message start, then deltas. diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index ad76cd2792b..ee0f4970f4d 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -161,7 +161,7 @@ describe('Claude structured turn timing', () => { state: 'running', startedAt: 1_000, userItemId: USER_1_KEY, - options: { observedAt: 1_000 } + options: { observedAt: 1_000, turnScope: { kind: 'thread' } } } ]) }) @@ -194,7 +194,7 @@ describe('Claude structured turn timing', () => { startedAt: 1_000, completedAt: 4_500, userItemId: USER_1_KEY, - options: {} + options: { turnScope: { kind: 'thread' } } }) expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 082b0024061..8c8ff1470f4 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -9,9 +9,7 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { claudeProviderFrameKind, - claudeResultFailure, - createClaudeProviderFrameFallback, - isSettledClaudeResultKind + createClaudeProviderFrameFallback } from './claude-structured-provider-fallback' import { taskFrameSentence } from './claude-background-task-frames' import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' @@ -26,13 +24,14 @@ import { claudeStreamTurnSource, isRootClaudeFrame } from './claude-turn-opening' -import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { ClaudeOpenTurn } from './claude-open-turn' +import { claudeCommandCurrentTurn, observeClaudeCommandFrame } from './claude-command-turn' import { ClaudeContextFacts } from './claude-context-facts' import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import { claudeChildToolQueries } from './claude-child-tool-queries' import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' +import { journalClaudeResult, type ClaudeResultJournalContext } from './claude-result-journaling' export type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -65,7 +64,9 @@ export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() - const prompts = new ClaudeJournalPrompts(deps) + // Every row joins the root turn open when it is written, whoever produced it. + const turnScope = () => turn.turnScope + const prompts = new ClaudeJournalPrompts({ ...deps, turnScope }) const streamedBlocks = createClaudeStreamedBlockRegistry() const turn = new ClaudeOpenTurn({ sink: deps.sink, @@ -73,14 +74,13 @@ export function createClaudeJournalTranslator( onOpen: () => context.markActivity() }) const context = new ClaudeContextFacts(turn, deps.sink) - const providerFallback = createClaudeProviderFrameFallback( - deps.sink, - deps.fallbackIdPrefix ?? 'acquisition' - ) + const fallbackId = deps.fallbackIdPrefix ?? 'acquisition' + const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope) const toolOrigins = new ClaudeToolOriginRegistry() const subagents = new ClaudeSubagentRoster({ sink: deps.sink, currentGroupKey: () => turn.groupKey, + currentTurnScope: turnScope, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId), // A settled group can receive no further announcement, so a correction @@ -90,6 +90,7 @@ export function createClaudeJournalTranslator( const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage }) const corrections = new ClaudeProvisionalRowCorrections({ ...subagents.linkage, + turnScope, rewrite: (identity, body, options) => { // The admission-returning path, so a correction the sink refuses under // backpressure stays owed instead of vanishing. Sinks without it accept @@ -110,6 +111,7 @@ export function createClaudeJournalTranslator( // turn, or the session shows the row while reading idle. openOutputTurn: (frame, observedAt) => turn.ensureOpen(frame, claudeStreamTurnSource(frame), observedAt), + turnScope, ...(deps.onBackgroundTaskJournalFailure ? { onPersistenceFailure: deps.onBackgroundTaskJournalFailure } : {}) @@ -119,7 +121,8 @@ export function createClaudeJournalTranslator( ...(deps.schedule ? { schedule: deps.schedule } : {}), producer: subagents.linkage, persist: (identity, text, options) => { - deps.sink.appendItem(identity, claudeStreamingMessageBody(text), options) + const body = claudeStreamingMessageBody(text) + deps.sink.appendItem(identity, body, { ...options, turnScope: turnScope() }) deps.sink.publish() } }) @@ -161,6 +164,8 @@ export function createClaudeJournalTranslator( turn } + const resultContext: ClaudeResultJournalContext = { ...messageContext, prompts, context } + const handleMessage = ( message: Record, startsTurn: boolean, @@ -200,6 +205,9 @@ export function createClaudeJournalTranslator( } } } + if (event.type === 'message' && observeClaudeCommandFrame(turn.command, event.message)) { + return + } if (event.type === 'message' && handleStream(event.message, event.observedAt ?? Date.now())) { return } @@ -218,41 +226,7 @@ export function createClaudeJournalTranslator( prompts.retryPendingCancellations() prompts.cancel(event.promptKey) } else if (event.type === 'message' && event.message.type === 'result') { - // Every turn this translator opens is root by construction, so a nested - // result settles the child that produced it and never the turn. The - // diagnostic below still runs: a child's failure is reportable even when - // it ends no turn. - const settlesTurn = isRootClaudeFrame(event.message) - if (settlesTurn) { - prompts.retryPendingCancellations() - turn.suppressReopenOnFailure(event.message.is_error === true) - // The turn is over however it ended, so a foreground child still - // reported as working will never be settled by an event. - subagents.settleTurn(turn.groupKey) - context.settle( - event.message, - claudeTurnEndForResult(event.message, event.observedAt ?? Date.now()) - ) - // The turn is over. A block still awaiting its final keeps the text the - // flush above journaled, but its live state goes: an interrupted turn - // would otherwise retain that text for the life of the session. - streamedBlocks.clear() - streamedText.settle() - } - const kind = claudeProviderFrameKind(event.message) - const failure = claudeResultFailure(event.message) - if (failure || !isSettledClaudeResultKind(kind)) { - providerFallback.append( - kind, - event.message, - failure?.text, - undefined, - undefined, - // A result that settles no turn is a CHILD's result: this - // translator only ever opens root turns. - settlesTurn ? undefined : corrections.stampFor(claudeFrameParentRef(event.message)) - ) - } + journalClaudeResult(resultContext, event.message, event.observedAt ?? Date.now()) } else if (event.type === 'message') { const backgroundTaskCovered = backgroundTasks.observe( event.message, @@ -296,6 +270,12 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + get commandTurnId() { + return turn.command ? turn.id : null + }, + beginCommand: (start) => turn.beginCommand(claudeCommandCurrentTurn(start)), + forgetCommand: (turnId) => turn.forgetCommand(turnId), + commandInterruptRequested: (turnId) => turn.commandInterruptRequested(turnId), get openTurnInLiveProviderCycle() { return turn.openedInLiveProviderCycle }, diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 53d6f63623d..31fd703088f 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -341,7 +342,10 @@ describe('Claude live prompt ownership', () => { }) it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { - const prompts = new ClaudeJournalPrompts({ sink: lifecycleRecorder().sink }) + const prompts = new ClaudeJournalPrompts({ + sink: lifecycleRecorder().sink, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) for (let index = 0; index < 128; index += 1) { const promptKey = `resolved-${index}` @@ -673,7 +677,8 @@ describe('Claude live prompt ownership', () => { } ] : [] - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const prompt: ClaudePendingPrompt = { requestId: 'grouped-request', @@ -714,7 +719,8 @@ describe('Claude live prompt ownership', () => { lifecycleAttempts += 1 return backpressured ? { accepted: false, reason: 'backpressure' } : { accepted: true } } - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const registerCancellation = (index: number): void => { const promptKey = `permission-${index}` diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 01af8ec9fc0..6d45e77bbbb 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -3,7 +3,6 @@ import { AgentSessionPromptUnavailableError, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { answerClaudePrompt, @@ -116,12 +115,11 @@ function cancelClaudeConversation( export async function cancelClaudeStructuredTurn(input: { request: CancelInput sessions: Map - compactions: StructuredSessionCompaction timeoutMs?: number admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean onDispatchSettledLate?: ClaudeLateDispatchSettlement }): Promise<{ cancelled: boolean }> { - const { request, sessions, compactions, timeoutMs } = input + const { request, sessions, timeoutMs } = input const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration const prompt = request.prompt @@ -172,7 +170,8 @@ export async function cancelClaudeStructuredTurn(input: { const dispatchAdmissionAllowsCancellation = (): boolean => dispatchAdmissionIsCurrent() || (Boolean(prompt) && supportsClaudeQueuedInterruptCancellation(session)) - const compactionOwnsTurn = (): boolean => compactions.ownsTurn(request.sessionId, requestedTurnId) + const compactionOwnsTurn = (): boolean => + session.translator !== null && session.translator.commandTurnId === requestedTurnId const currentDispatchHasRetiredWaiter = (): boolean => session.retiredDispatchWaiters.some( (waiter) => waiter.dispatchSequence === session.dispatchSequence @@ -204,7 +203,14 @@ export async function cancelClaudeStructuredTurn(input: { const result = await cancelClaudeTurn( session, timeoutMs, - isCurrent, + () => { + const current = isCurrent() + // Read with the result that ends it: a stopped command reports no compaction. + if (current && compactionOwnsTurn()) { + session.translator?.commandInterruptRequested(requestedTurnId) + } + return current + }, input.onDispatchSettledLate ) if (result.cancelled && claim && cancellationObserved) { diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index e107633173c..cd5441d7ec1 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { boundInlineText, @@ -15,7 +16,7 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' -import { rootClaudeRowStamp, type ClaudeRowStamp } from './claude-provisional-row-corrections' +import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, claudeApiRetryRowBody, @@ -109,7 +110,9 @@ export function isModeledClaudeContent(value: unknown): boolean { export function createClaudeProviderFrameFallback( sink: StructuredAgentSessionEventSink, - acquisitionId: string + acquisitionId: string, + /** The frame's turn — the open one once `beforeAppend` ran — or the conversation. */ + turnScope: () => AgentJournalTurnScope ): { /** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */ append: ( @@ -139,7 +142,7 @@ export function createClaudeProviderFrameFallback( clientMessageId: `provider-retry:claude:${acquisitionId}:${retryRun(retrying)}` } as const const body = claudeApiRetryRowBody(retrying) - sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() return true } @@ -162,7 +165,7 @@ export function createClaudeProviderFrameFallback( clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` } as const const body = bounded ? { ...translated.body, text: bounded } : translated.body - sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() return true } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 29b51e730ae..64d3c67c227 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,5 +1,5 @@ import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' -import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' +import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, @@ -7,7 +7,6 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' @@ -55,7 +54,6 @@ function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTask } export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new ClaudeAcquisitionRegistry() private readonly exits = new Map() @@ -156,7 +154,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda if (event.type === 'message' && session?.commands.observe(event.message)) { session.events?.publish() } - observeClaudeCompaction(this.compactions, event, session?.translator) + session?.translator?.handle(event) this.deps.onEvent?.(event) if (backgroundTasksChanged) { this.deps.onBackgroundTasksChanged?.( @@ -193,13 +191,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) compact: NonNullable = (input) => - compactClaudeSession(this.session(input.sessionId), this.compactions, input) + dispatchClaudeCommand(this.session(input.sessionId), input.command) cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => cancelClaudeStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, admitPromptCancellation: (session, promptKey) => admitClaudePromptCancellation(session, promptKey), onDispatchSettledLate: (settlement) => diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index e27b2a41e93..f49b6a8d88f 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -12,7 +12,7 @@ import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index 9a4d3611435..9e3ee0289cf 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -4,7 +4,7 @@ import { } from './claude-context-usage' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeInitProof } from './claude-structured-session-startup' import type { ClaudeAcquisitionAttempt, diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index f20bc0c7907..c685c2cd2ae 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -1,7 +1,7 @@ import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeProviderHandleLink } from './claude-structured-owner-identity' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index 1084c2c8626..7677462303e 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createDeferredStructuredAgentSessionEventSink, @@ -236,7 +237,8 @@ describe('Claude structured reading control', () => { const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 6848d9a1943..82332f72383 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -11,7 +11,7 @@ import type { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import { randomUUID } from 'node:crypto' diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts index ab44350606e..8be89d15e19 100644 --- a/src/main/claude/claude-subagent-group-row.ts +++ b/src/main/claude/claude-subagent-group-row.ts @@ -46,7 +46,7 @@ export function writeClaudeSubagentGroupRow( group: RosterGroup ): void { const agents = [...group.entries.values()].map((tracked) => tracked.entry) - const options = { coalescingKey: `claude-subagents:${group.groupId}` } + const options = { coalescingKey: `claude-subagents:${group.groupId}`, turnScope: group.turnScope } if (agents.length === 0) { // The row's last child turned out not to be a subagent. An empty roster is // not a roster of nothing, so the row goes rather than reading "Ran 0". diff --git a/src/main/claude/claude-subagent-roster-state.ts b/src/main/claude/claude-subagent-roster-state.ts index f7fc3374661..dcaa72c3000 100644 --- a/src/main/claude/claude-subagent-roster-state.ts +++ b/src/main/claude/claude-subagent-roster-state.ts @@ -1,4 +1,7 @@ -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames' @@ -22,6 +25,8 @@ export type TrackedEntry = { export type RosterGroup = { groupId: string identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope /** Insertion order is the display order; the map holds the state. */ entries: Map /** Lifetime admissions bound retained labels even when entries are removed. */ diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index da1f16d0a0e..0d5747d2f4f 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -43,6 +44,7 @@ function harness(groupKey: string | null = TURN_1) { const roster = new ClaudeSubagentRoster({ sink, currentGroupKey: () => key, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, now: () => (clock += 1) }) const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) @@ -464,7 +466,11 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { published += 1 } }) - const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 }) + const roster = new ClaudeSubagentRoster({ + sink: deferred.sink, + currentGroupKey: () => TURN_1, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) // The first append is in flight while the rest are submitted, so a publish // sharing the row's coalescing key would evict them. diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts index d45a17e15e9..be6274ce054 100644 --- a/src/main/claude/claude-subagent-roster.ts +++ b/src/main/claude/claude-subagent-roster.ts @@ -11,6 +11,7 @@ // child on every resume. Outcomes latch within an invocation; a new spawn // alias can reopen it, and authoritative evidence can correct lost contact. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { canReplaceSubagentState, isTerminalSubagentState @@ -46,6 +47,8 @@ export type ClaudeSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The turn that owns children spawned right now; null outside any turn. */ currentGroupKey: () => string | null + /** That turn's scope, which the roster row it spawns belongs to. */ + currentTurnScope: () => AgentJournalTurnScope /** Whether a tool id was forwarded at the TOP level. A child parented to one * was spawned by a call the transcript shows, so its announcement is still * expected; a child parented to anything else names an id that only ever @@ -364,6 +367,7 @@ export class ClaudeSubagentRoster { const group: RosterGroup = { groupId, identity: claudeSubagentGroupIdentity(groupId), + turnScope: this.deps.currentTurnScope(), entries: new Map(), admittedEntries: 0, claimedLabels: new Set(), diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 2a31b49afdb..be7150531fe 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -7,6 +7,7 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' +import type { ClaudeCommandTurn } from './claude-command-turn' export type ClaudeCurrentTurn = { sessionId: string @@ -18,6 +19,13 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string + /** Present when the turn is the host's record of a conversation command. */ + command?: ClaudeCommandTurn +} + +/** The row a turn's lifecycle lives on: the host's record for a command, else the lane's own. */ +export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournalItemIdentity { + return turn.command?.identity ?? claudeTurnLifecycleIdentity(turn.sessionId, turn.turnId) } export type ClaudeTurnEnd = { @@ -84,7 +92,7 @@ export function claudeTurnLifecycleItem( // already carried, because both are built from the same open turn. const requested = requestedAt === undefined ? {} : { requestedAt } return { - identity: claudeTurnLifecycleIdentity(sessionId, turnId), + identity: claudeCurrentTurnIdentity(turn), body: agentJournalTurnBody( end ? { diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 81a84989aa4..c1915d9bf94 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -6,7 +6,6 @@ import type { AgentJournalItemBody } from '../../shared/agent-session-journal-ty import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS, cancelClaudeStructuredTurn @@ -99,6 +98,10 @@ function sessionHoldingTurn(turnId: string | null): ReturnType true }) } @@ -281,6 +283,10 @@ describe('Claude turn ownership', () => { openTurnInLiveProviderCycle: false, journalPrompts: { cancel: vi.fn(), resolve: vi.fn() }, currentTurnId: 'turn-1', + commandTurnId: null, + beginCommand: vi.fn(), + forgetCommand: vi.fn(), + commandInterruptRequested: vi.fn(), flush: vi.fn(), contextActivity: 0, markContextActivity: vi.fn(), @@ -308,7 +314,6 @@ describe('Claude turn ownership', () => { const cancellation = cancelClaudeStructuredTurn({ request: { sessionId: 'session-1', turnId: 'turn-1', fence: 1 }, sessions: new Map([['session-1', session]]), - compactions: new StructuredSessionCompaction(), admitPromptCancellation: () => true }) await vi.advanceTimersByTimeAsync(100) diff --git a/src/main/claude/claude-turn-row-revision.ts b/src/main/claude/claude-turn-row-revision.ts index 1bee6bd0162..1b684ba89aa 100644 --- a/src/main/claude/claude-turn-row-revision.ts +++ b/src/main/claude/claude-turn-row-revision.ts @@ -14,9 +14,10 @@ import { agentJournalItemKey, parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/structured-agent-session-event-sink-estimate' @@ -149,7 +150,7 @@ function findTurnRow( export type ClaudeTurnRowDelivery = { /** False only for a writer that publishes each write itself right after queueing it. */ publish: boolean - options?: StructuredAgentSessionRevisionOptions + options?: Omit } /** @@ -161,8 +162,10 @@ export function writeClaudeTurnRow( sink: StructuredAgentSessionEventSink, target: ClaudeTurnRowTarget, write: ClaudeTurnRowWrite, - { publish, options = {} }: ClaudeTurnRowDelivery + { publish, options: delivery = {} }: ClaudeTurnRowDelivery ): void { + // A turn record belongs to no turn. + const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem if (!revise) { if (write.lifecycle && 'identity' in target) { diff --git a/src/main/codex/codex-command-turn-claim.test.ts b/src/main/codex/codex-command-turn-claim.test.ts new file mode 100644 index 00000000000..078060497f2 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.test.ts @@ -0,0 +1,375 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION = 'session-1' +const THREAD = 'thread-1' +const PROVIDER_TURN = 'compact-turn' +const COMMAND_TURN_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_TURN_KEY = agentJournalItemKey(COMMAND_TURN_IDENTITY) +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 1, + startedAt: 1 +} +const COMMAND = { + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_TURN_IDENTITY, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'command-result:cmd-1' }, + running: RUNNING +} +const ACCEPTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +type Written = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** Records every write with the scope it states; the journal holds the running command turn. */ +function recorder(refuseRevisions = 0) { + const writes: Written[] = [] + const record = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => writes.push({ key: agentJournalItemKey(identity), body, turnScope }) + const commandTurn: AgentJournalItemBody = RUNNING + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the claim resolver reads only `itemBody`. + const journal = { + itemBody: (itemId: string) => (itemId === COMMAND_TURN_KEY ? commandTurn : null) + } as unknown as StructuredAgentSessionRevisionJournal + let refusals = refuseRevisions + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => record(identity, body, options.turnScope), + appendTombstone: () => {}, + publish: () => {}, + tryPublish: () => ACCEPTED, + tryAppendItem: (identity, body, options) => { + record(identity, body, options.turnScope) + return ACCEPTED + }, + appendLifecycleBatch: (_settlementId, mutations) => { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + record(mutation.identity, mutation.body, mutation.turnScope) + } + } + }, + tryReviseResolvedItemAndPublish: (_bytes, resolve, options) => { + if (refusals > 0) { + refusals -= 1 + return { accepted: false, reason: 'backpressure' } + } + const resolved = resolve(journal) + if (resolved) { + record(resolved.identity, resolved.body, options.turnScope) + } + return ACCEPTED + } + } + return { sink, writes } +} + +/** Codex names the thread inside every notification's params too. */ +function notification( + method: string, + params: Record +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params: { threadId: THREAD, ...params } + } +} + +function harness(refuseRevisions = 0) { + const tap = recorder(refuseRevisions) + const translator = createCodexJournalTranslator({ + sink: tap.sink, + sessionId: SESSION, + primaryThreadId: () => THREAD + }) + return { + ...tap, + translator, + emit: (event: CodexStructuredSessionEvent) => translator.handle(event) + } +} + +const codexTurnRecords = (writes: readonly Written[]) => + writes.filter((write) => write.key !== COMMAND_TURN_KEY && readAgentJournalTurn(write.body)) + +/** The command turn's newest body, as the journal would hold it. */ +const commandTurn = (writes: readonly Written[]) => + readAgentJournalTurn(writes.findLast((write) => write.key === COMMAND_TURN_KEY)?.body) + +const resultRows = (writes: readonly Written[]) => + writes.filter((write) => write.body.kind === 'status') + +describe('a Codex turn a conversation command claims', () => { + it('is the command turn: writes no root turn, scopes its content there, and ends it', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + // The claim is persisted on the command turn: nothing else re-derives it later. + expect(commandTurn(writes)).toMatchObject({ state: 'running', providerTurnId: PROVIDER_TURN }) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'agentMessage', id: 'summary', text: 'Summary of the conversation.' } + }) + ) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(codexTurnRecords(writes)).toEqual([]) + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const summary = writes.find( + (write) => write.body.kind === 'message' && write.body.role === 'assistant' + ) + expect(summary?.turnScope).toEqual(scope) + // Codex's own marker is the command's result row. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: scope + }) + ]) + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'success', + providerTurnId: PROVIDER_TURN, + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt + }) + }) + + it('reads an interrupted turn as the command cancelled, with no result row', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'interrupted' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(resultRows(writes)).toEqual([]) + }) + + it('reads a turn that completed without compacting as a failure, with the reason', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Unavailable.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Unavailable', audience: 'person' } + }, + tone: 'error' + } + ]) + }) + + it('says only that the compaction failed when a turn that did not complete gave no words', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a turn that completed without Codex reporting a compaction as unconfirmed', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('ends a compaction Codex failed with an error once, however late its completion', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { + turnId: PROVIDER_TURN, + willRetry: false, + error: { message: 'Unavailable' } + }) + ) + // The error is a row; only the completion ends the turn. + expect(commandTurn(writes)).toMatchObject({ state: 'running' }) + // Codex still completes the turn it failed, as status failed. + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + writes.filter( + (write) => write.key === COMMAND_TURN_KEY && readAgentJournalTurn(write.body)?.completedAt + ) + ).toHaveLength(1) + // Codex's own error row, in the command's turn; the completion adds none. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ kind: 'status', text: 'Unavailable', tone: 'error' }), + turnScope: { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + }) + ]) + }) + + it("ends a captured failed compaction on its completion, below one row of Codex's own", () => { + // A real app-server's compaction turn: two retried stream errors, the turn-ending error, then + // the failed completion. + const captured: { case: string; t: number; method: string; params: object }[] = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + .filter((frame) => frame.case === '0.158.0-compact-overloaded') + const compactionTurn = captured.findLastIndex((frame) => frame.method === 'turn/started') + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + for (const frame of captured.slice(compactionTurn)) { + emit({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method: frame.method, + params: { ...frame.params, threadId: THREAD }, + observedAt: frame.t + }) + } + + expect(codexTurnRecords(writes)).toEqual([]) + const completion = captured.at(-1)! + expect(completion.method).toBe('turn/completed') + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'failure', + completedAt: completion.t + }) + // Every row is one of Codex's frames, inside the command's turn; the completion adds none. + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const rows = resultRows(writes) + expect(rows.map((write) => write.key)).not.toContain( + agentJournalItemKey(COMMAND.resultIdentity) + ) + expect(rows.map((write) => write.turnScope)).toEqual(rows.map(() => scope)) + expect( + rows.some( + (write) => + write.body.kind === 'status' && + write.body.tone === 'error' && + write.body.text.includes('Selected model is at capacity') + ) + ).toBe(true) + }) + + it('leaves the next turn to write its own record after a failed compaction', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { turnId: PROVIDER_TURN, willRetry: false, error: { message: 'x' } }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.turnId) + ).toEqual(['ordinary', 'ordinary']) + }) + + it('names no provider turn for a Stop until Codex opens one, then the one it opened', () => { + const { translator, emit } = harness() + translator.beginCommand(COMMAND) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBeUndefined() + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + expect(translator.commandProviderTurnId('ordinary')).toBe('ordinary') + }) + + it('claims the same provider turn when the refused start is retried', () => { + const { writes, translator, emit } = harness(1) + translator.beginCommand(COMMAND) + const started = notification('turn/started', { turn: { id: PROVIDER_TURN } }) + + expect(emit(started)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(emit(started)).toEqual(ACCEPTED) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ providerTurnId: PROVIDER_TURN }) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + }) + + it('leaves a primary turn no command claims to write its own record', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + translator.forgetCommand(COMMAND.turnId) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.state) + ).toEqual(['running', 'completed']) + }) +}) diff --git a/src/main/codex/codex-command-turn-claim.ts b/src/main/codex/codex-command-turn-claim.ts new file mode 100644 index 00000000000..e1278a8b5c4 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.ts @@ -0,0 +1,32 @@ +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +const CLAIM_BYTES = 4_096 + +/** Records on the command's turn which provider turn carried it out. Persisted because nothing + * else re-derives it once the command settles, and a rewind has to place that turn's rows. */ +export function recordCodexCommandTurnClaim( + sink: StructuredAgentSessionEventSink, + commandTurnItemId: string, + providerTurnId: string +): StructuredAgentSessionSinkAdmission { + const identity = parseAgentJournalItemKey(commandTurnItemId) + if (!identity || !sink.tryReviseResolvedItemAndPublish) { + return { accepted: true } + } + return sink.tryReviseResolvedItemAndPublish( + CLAIM_BYTES, + (journal) => { + const body = journal.itemBody(commandTurnItemId) + // Settled already — by the host, or by a child's death — leaves nothing to annotate. + return body?.kind === 'turn' && body.state === 'running' + ? { identity, body: { ...body, providerTurnId } } + : null + }, + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} diff --git a/src/main/codex/codex-journal-command-turn.ts b/src/main/codex/codex-journal-command-turn.ts new file mode 100644 index 00000000000..90e2f556b84 --- /dev/null +++ b/src/main/codex/codex-journal-command-turn.ts @@ -0,0 +1,165 @@ +// The conversation command a Codex child is running. Codex carries a command out as a turn of its +// own; that turn writes no record, its rows join the command's turn, and its end is the command's. +// Held by the child's translator, so it ends with the child and nothing has to release it. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import { providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { readCodexJournalRecord } from './codex-structured-journal-translation-values' +import { readCodexTurnId } from './codex-structured-thread-facts' + +type CarriedCommand = { + command: StructuredAgentSessionCommandRun + compacted: boolean + /** Codex's own error row already says why the command failed. */ + failureShown: boolean +} + +export class CodexJournalCommandTurn { + /** Sent, and not yet carried by a provider turn. */ + private awaiting: StructuredAgentSessionCommandRun | null = null + private readonly carried = new Map() + + /** Before Orca sends the command: the next primary turn to start carries it out. */ + begin(command: StructuredAgentSessionCommandRun): void { + this.awaiting = command + } + + /** The command was never taken. */ + forget(turnId: string): void { + if (this.awaiting?.turnId === turnId) { + this.awaiting = null + } + } + + /** The command the primary turn `providerTurnId` carries out. Idempotent per provider turn, so a + * refused frame's retry gets the same answer. */ + claim(providerTurnId: string): StructuredAgentSessionCommandRun | null { + const carried = this.carried.get(providerTurnId) + if (carried || !this.awaiting) { + return carried?.command ?? null + } + const command = this.awaiting + this.awaiting = null + this.carried.set(providerTurnId, { command, compacted: false, failureShown: false }) + return command + } + + isCarrying(providerTurnId: string): boolean { + return this.carried.has(providerTurnId) + } + + scopeFor(providerTurnId: string): AgentJournalTurnScope | null { + const carried = this.carried.get(providerTurnId) + return carried + ? { kind: 'turn', turnItemId: agentJournalItemKey(carried.command.identity) } + : null + } + + /** The provider turn a Stop on `turnId` interrupts: none while the command has not started one. */ + providerTurnId(turnId: string): string | undefined { + if (this.awaiting?.turnId === turnId) { + return undefined + } + for (const [providerTurnId, { command }] of this.carried) { + if (command.turnId === turnId) { + return providerTurnId + } + } + return turnId + } + + /** Codex reported the compaction, in the turn carrying the command. */ + compacted(providerTurnId: string): void { + const carried = this.carried.get(providerTurnId) + if (carried) { + carried.compacted = true + } + } + + /** The command's end, for the batch that settles the provider turn carrying it. */ + end( + providerTurnId: string, + ended: { + status: string | null + error: string | null + completedAt: number + } + ): JournalLifecycleMutationInput[] { + const carried = this.carried.get(providerTurnId) + if (!carried) { + return [] + } + const { command } = carried + const detail = ended.error === null ? undefined : providerDiagnostic(ended.error, 'person') + const verdict = structuredCompactionOutcome({ + compacted: carried.compacted, + // Codex reports the user's stop as the turn's own status. + interruptRequested: ended.status === 'interrupted', + // A turn that did not complete failed, not merely went unconfirmed. + failed: ended.status !== 'completed' || detail ? (detail ? { detail } : {}) : null + }) + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + return [ + // A success already drew Codex's own compaction marker inside the command's turn. + ...(verdict.failure && !carried.failureShown + ? [ + { + kind: 'item' as const, + identity: command.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.codex + }), + tone: 'error' as const + }, + turnScope + } + ] + : []), + { + kind: 'item', + identity: command.identity, + body: agentJournalTurnBody({ + ...command.running, + providerTurnId, + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: verdict.outcome, + completedAt: ended.completedAt + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + + /** Codex's `error` frame, already a row in the turn it names. A turn-ending one says why the + * command failed, so the failed completion that follows it adds no second row. */ + errorShown(params: unknown): void { + const providerTurnId = readCodexTurnId(params) + const carried = providerTurnId ? this.carried.get(providerTurnId) : undefined + // A stream error Codex is about to retry ends nothing. + if (carried && readCodexJournalRecord(params).willRetry !== true) { + carried.failureShown = true + } + } + + settled(providerTurnId: string): void { + this.carried.delete(providerTurnId) + } + + clear(): void { + this.awaiting = null + this.carried.clear() + } +} diff --git a/src/main/codex/codex-journal-turn-scopes.ts b/src/main/codex/codex-journal-turn-scopes.ts new file mode 100644 index 00000000000..eb49f243bf4 --- /dev/null +++ b/src/main/codex/codex-journal-turn-scopes.ts @@ -0,0 +1,45 @@ +// Which turn a Codex row belongs to, stated when the row is written. +// +// A primary-thread row belongs to the turn it names: that turn's lifecycle record, or the +// conversation command that claimed it. A child thread has turns of its own that the timeline +// does not draw, so its rows belong to the primary turn running when they arrive — the work the +// user is watching — or to no turn once the primary is idle. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' + +export class CodexJournalTurnScopes { + constructor( + private readonly deps: { + /** Without it no turn record is keyed, so every row reads as the thread's. */ + sessionId: string | undefined + primaryThreadId: () => string | null + activeTurn: (threadId: string) => string | null + /** The command turn's scope, for a primary turn carrying a conversation command. */ + commandScope: (turnId: string) => AgentJournalTurnScope | null + } + ) {} + + scopeFor(threadId: string, turnId: string | null): AgentJournalTurnScope { + const primary = this.deps.primaryThreadId() + const primaryTurnId = + primary === null ? null : threadId === primary ? turnId : this.deps.activeTurn(primary) + if (primaryTurnId === null) { + return AGENT_JOURNAL_THREAD_SCOPE + } + const { sessionId } = this.deps + return ( + this.deps.commandScope(primaryTurnId) ?? + (sessionId === undefined + ? AGENT_JOURNAL_THREAD_SCOPE + : { + kind: 'turn', + turnItemId: agentJournalItemKey(codexTurnLifecycleIdentity(sessionId, primaryTurnId)) + }) + ) + } +} diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 2c123a3b910..fa6aef48ae9 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' @@ -45,7 +46,7 @@ function fixture(maxMetadataBytes?: number) { { sink, maxMetadataBytes, - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: (run) => { scheduled.add(run) return () => { @@ -119,7 +120,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) } @@ -215,7 +216,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) expect(items.activeItems.size).toBe(1) diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts index 437f32f0a8f..fa8800f9605 100644 --- a/src/main/codex/codex-structured-compaction-refusal.test.ts +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -1,6 +1,7 @@ // A compaction Codex refuses up front keeps Codex's own words for the chat's failure row. import { describe, expect, it } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import { CodexAppServerRequestError } from './codex-app-server-connection' import { acquired, fakeCodex } from './codex-structured-session-adapter-fixture' @@ -17,12 +18,25 @@ describe('Codex compaction refused at the request', () => { } }) const adapter = await acquired(codex) + const turn = structuredAgentSessionCommandTurn('cmd-1') await expect( - adapter.compact({ turnId: 'compact-1', sessionId: 'session-1', fence: 7 }) + adapter.compact({ + sessionId: 'session-1', + fence: 7, + command: { + clientMessageId: 'cmd-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } + }) ).resolves.toEqual({ - outcome: 'failed', - detail: { text: 'thread has nothing to compact', audience: 'person' } + state: 'rejected', + reason: 'The provider did not accept this message: thread has nothing to compact.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread has nothing to compact', audience: 'person' } + } }) }) }) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 1842afada72..5d8a9c5627f 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -2,6 +2,7 @@ // `turn/start` before it opens the turn, and refuses an interrupt until then. import { describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import { CODEX_TEST_THREAD_ID, codexTurnLifecycleRig @@ -71,16 +72,21 @@ describe('a Codex Stop that names no turn', () => { const rig = await codexTurnLifecycleRig() await openedTurn(rig) rig.turns.end('completed') + const turn = structuredAgentSessionCommandTurn('operation-1') const compaction = rig.adapter.compact({ - turnId: 'compact:operation-1', sessionId: 'session-1', - fence: 7 + fence: 7, + command: { + clientMessageId: 'operation-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } }) await vi.waitFor(() => expect(rig.codex.connections[0]!.calls.at(-1)?.method).toBe('thread/compact/start') ) - await expect(stop(rig, () => 'compact:operation-1')).resolves.toEqual({ cancelled: false }) + await expect(stop(rig, () => turn.turnId)).resolves.toEqual({ cancelled: false }) expect(rig.interrupts()).toEqual([]) rig.notify('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-compact' } }) @@ -89,6 +95,6 @@ describe('a Codex Stop that names no turn', () => { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-compact', status: 'completed' } }) - await expect(compaction).resolves.toEqual({ outcome: 'compacted' }) + await expect(compaction).resolves.toEqual({ state: 'accepted', providerIdentity: null }) }) }) diff --git a/src/main/codex/codex-structured-item-stream-contracts.ts b/src/main/codex/codex-structured-item-stream-contracts.ts index ebbd05731d4..733a81455c3 100644 --- a/src/main/codex/codex-structured-item-stream-contracts.ts +++ b/src/main/codex/codex-structured-item-stream-contracts.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journa import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { codexJournalItem, CodexThreadItem } from './codex-structured-item-translation' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexItemStreamDeps = { sink: StructuredAgentSessionEventSink @@ -13,7 +13,7 @@ export type CodexItemStreamDeps = { turnId: string | null, item: CodexThreadItem ) => AgentJournalItemIdentity - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution coalesceMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 03085bf6913..306fcf27619 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -1,4 +1,8 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import { CodexItemStreamRetention } from './codex-item-stream-retention' import { appendCodexItemAndPublish } from './codex-structured-journal-sink' @@ -51,12 +55,14 @@ export function createCodexStructuredItemStreams( const states = new CodexItemStreamRetention(deps.maxMetadataBytes) const checkpointLengths = new Map() const pendingCheckpoints = new Set() - // Which thread and turn produced each stream, resolved to linkage per append + // Which thread and turn produced each stream, resolved to its attribution per append // so a parent learned after the first checkpoint still reaches the row. const producers = new Map() - const linkageOf = (key: string) => { + const attributionOf = (key: string): AgentJournalRowAttribution => { const producer = producers.get(key) - return producer ? deps.linkageFor(producer.threadId, producer.turnId) : {} + return producer + ? deps.attributionFor(producer.threadId, producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } // Patch updates are authoritative item snapshots. Keep the latest rejected // snapshot until the journal admits it; unlike streamed deltas, there is no @@ -114,7 +120,7 @@ export function createCodexStructuredItemStreams( } return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, { coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}`, - ...linkageOf(key) + ...attributionOf(key) }).accepted } @@ -191,7 +197,7 @@ export function createCodexStructuredItemStreams( deps.sink, pending.identity, pending.body, - linkageOf(key) + attributionOf(key) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-compactions.test.ts b/src/main/codex/codex-structured-journal-compactions.test.ts index b86cea2725d..4da87a0c75f 100644 --- a/src/main/codex/codex-structured-journal-compactions.test.ts +++ b/src/main/codex/codex-structured-journal-compactions.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -82,7 +83,7 @@ describe('compaction provider generation compatibility', () => { if (stage === 'append' && reject) { return { accepted: false, reason: 'backpressure' } } - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return { accepted: true } } sink.tryPublish = () => { diff --git a/src/main/codex/codex-structured-journal-compactions.ts b/src/main/codex/codex-structured-journal-compactions.ts index 9aa2e8f4cf1..61743c1996c 100644 --- a/src/main/codex/codex-structured-journal-compactions.ts +++ b/src/main/codex/codex-structured-journal-compactions.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto' -import { isCodexCompactionComplete } from '../native-chat/agent-session-wire/structured-session-compaction' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -8,7 +7,16 @@ import { import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { readRecord } from './codex-item-field-readers' +import type { CodexRowAttribution } from './codex-subagent-linkage' + +function isCodexCompactionComplete(method: string, params: unknown): boolean { + return ( + method === 'thread/compacted' || + (method === 'item/completed' && + readRecord(readRecord(params).item).type === 'contextCompaction') + ) +} export class CodexJournalCompactions { private readonly turns = new Map() @@ -16,7 +24,9 @@ export class CodexJournalCompactions { constructor( private readonly sink: StructuredAgentSessionEventSink, private readonly activeTurn: (threadId: string) => string | null, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution, + /** Evidence for a conversation command the turn carries; its marker is the command's result. */ + private readonly compacted: (turnId: string) => void ) {} handle(event: { @@ -31,6 +41,7 @@ export class CodexJournalCompactions { if (!turnId) { return null } + this.compacted(turnId) // Collapse compactions within a thread/turn; the canonical item replaces its legacy fallback. const key = createHash('sha256') .update(JSON.stringify([event.threadId, turnId])) @@ -44,7 +55,7 @@ export class CodexJournalCompactions { this.sink, { provider: 'orca', clientMessageId: `codex-compaction:${key}` }, { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, - this.linkageFor(event.threadId, turnId) + this.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 28b5583a9b4..74cf5013aff 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -4,6 +4,7 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-sessio import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -34,6 +35,13 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission + /** Before Orca sends a conversation command: the next primary turn carries it out, and that + * turn's end is the command's. */ + beginCommand: (command: StructuredAgentSessionCommandRun) => void + /** The command was never taken. */ + forgetCommand: (turnId: string) => void + /** The provider turn a Stop naming `turnId` interrupts; undefined while a command has none. */ + commandProviderTurnId: (turnId: string) => string | undefined cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission restoreThread: ( threadId: string, diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index 7d9f54462fc..a8b400a6d93 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -12,7 +12,8 @@ import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { CodexRowAttribution } from './codex-subagent-linkage' const OVERFLOW_BUCKET = '__codex-generic-overflow__' /** `producer` is absent only on the overflow bucket, which pools every thread's @@ -59,7 +60,7 @@ export class CodexJournalGenericFrames { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null ) { @@ -102,10 +103,10 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame:codex:${this.fallbackSequence}` } - const linkage = this.deps.linkageFor(threadId, frameTurnId) + const attribution = this.deps.attributionFor(threadId, frameTurnId) const admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(identity, translated.body, linkage) - : (this.deps.sink.appendItem(identity, translated.body, linkage), CODEX_JOURNAL_ADMITTED) + ? this.deps.sink.tryAppendItem(identity, translated.body, attribution) + : (this.deps.sink.appendItem(identity, translated.body, attribution), CODEX_JOURNAL_ADMITTED) if (!admission.accepted) { this.fallbackSequence -= 1 return admission @@ -139,11 +140,12 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame-suppressed:codex:${bucket}` } + // A summary across evicted turns names no producer and belongs to no turn. const options = { coalescingKey: `provider-frame-suppressed:codex:${bucket}`, ...(summary.producer - ? this.deps.linkageFor(summary.producer.threadId, summary.producer.turnId) - : {}) + ? this.deps.attributionFor(summary.producer.threadId, summary.producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, { kind: 'status', text }, options) diff --git a/src/main/codex/codex-structured-journal-goal-admission.test.ts b/src/main/codex/codex-structured-journal-goal-admission.test.ts index b0a9e50ba2c..f2a3a9b166c 100644 --- a/src/main/codex/codex-structured-journal-goal-admission.test.ts +++ b/src/main/codex/codex-structured-journal-goal-admission.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -137,7 +138,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const update = (goal: Record = {}) => goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame(goal) }) const clear = () => @@ -178,7 +179,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const send = (threadId: string) => goals.handle({ threadId, method: 'thread/goal/updated', params: goalFrame() }) @@ -211,7 +212,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 4609a06fb0d..6df0ba01575 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -130,7 +131,9 @@ describe('codex goal lifecycle resume', () => { it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => { const journal = goalJournal() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) expect( resumed.handle({ @@ -151,7 +154,9 @@ describe('codex goal lifecycle resume', () => { it('does not revisit durable history for accounting-only updates', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await journal.drained() const visits = journal.visits() @@ -176,7 +181,9 @@ describe('codex goal lifecycle resume', () => { it('rebuilds dedupe state after the journal epoch is replaced', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) @@ -198,7 +205,9 @@ describe('codex goal lifecycle resume', () => { it('visits a large journal once per epoch when thread churn exceeds the transient LRU', async () => { const journal = goalJournal() journal.seedProviderItems(10_000) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const threadCount = MAX_CODEX_GOAL_THREADS + 1 const sendRound = () => { for (let index = 0; index < threadCount; index += 1) { @@ -227,7 +236,9 @@ describe('codex goal lifecycle resume', () => { const journal = goalJournal() journal.seedProviderItems(10_000) journal.unbind() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { goals.handle({ @@ -249,7 +260,9 @@ describe('codex goal lifecycle resume', () => { it('retries a journal-derived transition after lifecycle backpressure', async () => { const journal = goalJournal({ watermarks: { maxLifecycleQueuedOperations: 1 } }) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) journal.unbind() expect( @@ -317,7 +330,9 @@ describe('codex goal lifecycle resume', () => { }) } - const prior = new CodexJournalGoals(journal.sink, () => ({})) + const prior = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (const state of scenario.beforeResume) { send(prior, state) } @@ -329,7 +344,9 @@ describe('codex goal lifecycle resume', () => { prior.dispose() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) resumed.handle({ threadId: THREAD, method: scenario.resumed.method, diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index e6f96ad5503..2fbe33ab901 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -93,10 +94,10 @@ describe('codex goal accounting revisions', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'earlier' }, { kind: 'status', text: 'Context compacted' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { sink, drained, subscribe, published } = journalSink(journal) - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await drained() @@ -104,7 +105,7 @@ describe('codex goal accounting revisions', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'later' }, { kind: 'status', text: 'Something after the goal' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribe() @@ -160,14 +161,18 @@ describe('codex goal accounting revisions', () => { root = await mkdtemp(join(tmpdir(), 'orca-goal-resume-revision-')) const journal = await journals.open({ identity: IDENTITY, journalDir: root }) const first = journalSink(journal) - const prior = new CodexJournalGoals(first.sink, () => ({})) + const prior = new CodexJournalGoals(first.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) prior.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await first.drained() prior.dispose() const [created] = journal.snapshot().items const second = journalSink(journal) - const resumed = new CodexJournalGoals(second.sink, () => ({})) + const resumed = new CodexJournalGoals(second.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) // Only a few seconds more: a resume snapshot still refreshes, since no live tick follows. const snapshot = goalFrame({ timeUsedSeconds: 3, updatedAt: 1789067991 }) resumed.handle({ threadId: THREAD, method: 'thread/goal/updated', params: snapshot }) diff --git a/src/main/codex/codex-structured-journal-goal-rows.test.ts b/src/main/codex/codex-structured-journal-goal-rows.test.ts index ff234c7d5fb..053376e30e0 100644 --- a/src/main/codex/codex-structured-journal-goal-rows.test.ts +++ b/src/main/codex/codex-structured-journal-goal-rows.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -35,8 +36,11 @@ function frames(): { }, publish: vi.fn() } as unknown as StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) - const generic = new CodexJournalGenericFrames({ sink, linkageFor: () => ({}) }, () => null) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + const generic = new CodexJournalGenericFrames( + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, + () => null + ) return { rows, frames: { diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts index 681a483c12f..d73b163765e 100644 --- a/src/main/codex/codex-structured-journal-goals.ts +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -24,7 +24,7 @@ import { } from './codex-structured-journal-contracts' import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' import { appendCodexLifecycleTransition } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' type GoalAccounting = { key: string; timeUsedSeconds: number; tokenBudget: number | null } @@ -70,7 +70,7 @@ export class CodexJournalGoals { constructor( private readonly sink: StructuredAgentSessionEventSink, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution ) {} handle(event: { @@ -132,7 +132,7 @@ export class CodexJournalGoals { event.method === 'thread/goal/cleared' ), // A goal belongs to its thread, not to one run of it, so no turn is named. - this.linkageFor(event.threadId, null) + this.attributionFor(event.threadId, null) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 6717f3edabe..4e6ebe8e5e0 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -1,7 +1,7 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement' import { @@ -31,7 +31,7 @@ import type { CodexActiveJournalItem } from './codex-structured-journal-settleme import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { readonly ordinals = new CodexTurnOrdinals() @@ -44,7 +44,7 @@ export class CodexJournalItems { private readonly deps: Pick< CodexJournalTranslatorDeps, 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' - > & { maxMetadataBytes?: number; linkageFor: CodexRowLinkage }, + > & { maxMetadataBytes?: number; attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void ) { @@ -56,7 +56,7 @@ export class CodexJournalItems { maxMetadataBytes: deps.maxMetadataBytes, turnIdFor: (threadId, params) => readCodexTurnId(params) ?? this.activeTurn(threadId), identityFor: (threadId, turnId, item) => this.identityFor(threadId, turnId, item), - linkageFor: deps.linkageFor + attributionFor: deps.attributionFor }) } @@ -122,7 +122,7 @@ export class CodexJournalItems { event.method, identity, translated, - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return { handled: true, admission } @@ -151,17 +151,22 @@ export class CodexJournalItems { method: string, identity: AgentJournalItemIdentity, translated: ReturnType, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (!translated.body) { return CODEX_JOURNAL_ADMITTED } if (method === 'item/completed') { - const admission = appendCodexLifecycleItem(this.deps.sink, identity, translated.body, linkage) + const admission = appendCodexLifecycleItem( + this.deps.sink, + identity, + translated.body, + attribution + ) return admission.accepted ? publishCodexLifecycle(this.deps.sink) : admission } const options = requiresTerminalSettlement(translated.body) ? { lifecycle: true } : {} - const appendOptions = { ...options, ...linkage } + const appendOptions = { ...options, ...attribution } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, translated.body, appendOptions) : (this.deps.sink.appendItem(identity, translated.body, appendOptions), @@ -235,7 +240,7 @@ export class CodexJournalItems { this.deps.sink, evicted.identity, evictedActiveBody(translated), - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 96c58f9b8b7..9c145915fe5 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -20,7 +20,7 @@ import { } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' import { journalLifecycleItemMutation } from '../native-chat/agent-session-journal/journal-row-builders' type CodexGroupedPendingJournalPrompt = CodexPendingJournalPrompt & { promptKey: string } @@ -30,7 +30,7 @@ export class CodexJournalPrompts { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly detailFor: (threadId: string, itemId: string) => string | null, private readonly activeTurn: (threadId: string) => string | null @@ -118,7 +118,7 @@ export class CodexJournalPrompts { ) const mutations = group.flatMap(([, prompt]) => { const body = cancelledJournalPromptBody(prompt.body) - const producer = this.deps.linkageFor(prompt.threadId, prompt.turnId) + const producer = this.deps.attributionFor(prompt.threadId, prompt.turnId) return body ? [journalLifecycleItemMutation(producer, prompt.identity, body)] : [] }) const admission = appendCodexLifecycleMutations( @@ -152,7 +152,7 @@ export class CodexJournalPrompts { )}:${encodeURIComponent(event.promptKey)}`, items, // A child's approval arrives on the child's own thread, so it names the asker. - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) } @@ -170,7 +170,7 @@ export class CodexJournalPrompts { this.deps.sink, evicted.identity, cancelled, - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts index 8ce2591f57c..e513655cd8f 100644 --- a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { nativeChatTurnFold } from '../../shared/native-chat-turn-fold' -import { nativeChatRowTurnKeys } from '../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' import type { NativeChatMessage } from '../../shared/native-chat-types' import { projectStructuredItemToNativeChat } from '../../shared/structured-agent-session-projection' import { selectStructuredAgentTurnBars } from '../../shared/structured-agent-session-turn-timing' +import { JournalDerivedTurnScope } from '../native-chat/agent-session-journal/journal-derived-turn-scope' import { createCodexJournalTranslator } from './codex-structured-journal-translation' const THREAD_ID = 'thread-abc' @@ -29,19 +30,25 @@ function historicalTurn(index: number): Record { } } -/** What the journal hands a reader after a restore: rows in append order. */ -function restoredJournal(turnCount: number): AgentJournalRenderItem[] { +/** What the journal hands a reader after a restore: rows in append order. With `statesScope`, each + * row carries the turn scope the journal gives a row the translator writes without one. */ +function restoredJournal(turnCount: number, statesScope: boolean): AgentJournalRenderItem[] { const items: AgentJournalRenderItem[] = [] + const derived = new JournalDerivedTurnScope() const translator = createCodexJournalTranslator({ sink: { - appendItem: (identity, body) => { + appendItem: (identity, body, options) => { const sequence = items.length + 1 + const itemId = agentJournalItemKey(identity) + const turnScope = options.turnScope ?? derived.scopeFor(body) + derived.observe(itemId, true, undefined, body) items.push({ - itemId: agentJournalItemKey(identity), + itemId, revision: 1, body, sequence, - observedAt: sequence + observedAt: sequence, + ...(statesScope ? { turnScope } : {}) }) }, appendTombstone: () => {}, @@ -56,43 +63,50 @@ function restoredJournal(turnCount: number): AgentJournalRenderItem[] { } describe('grouping a Codex thread restored from full history', () => { - it('keeps each turn with its own rows and folds each to its own answer', () => { - const items = restoredJournal(3) - const bars = selectStructuredAgentTurnBars(items, [], null) - const messages = items - .map(projectStructuredItemToNativeChat) - .filter((message): message is NativeChatMessage => message !== null) - const turnKeys = nativeChatRowTurnKeys(messages, bars.turnKeysByItemId) - const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + 'keeps each turn with its own rows and folds each to its own answer on a host that %s', + (_host, statesScope) => { + const items = restoredJournal(3, statesScope) + const bars = selectStructuredAgentTurnBars(items, [], null) + const messages = items + .map(projectStructuredItemToNativeChat) + .filter((message): message is NativeChatMessage => message !== null) + const { turnKeys } = nativeChatTurnMembership(messages, { items, submissions: [] }) + const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` - expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ - ['user', opener(1)], - ['assistant', opener(1)], - ['assistant', opener(1)], - ['user', opener(2)], - ['assistant', opener(2)], - ['assistant', opener(2)], - ['user', opener(3)], - ['assistant', opener(3)], - ['assistant', opener(3)] - ]) - expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) - - const { foldedRows } = nativeChatTurnFold({ - rows: messages.map((message, index) => ({ - turnKey: turnKeys[index], - role: message.role, - rendersProse: true, - outlivesTurn: false - })), - settledTurnKeys: new Set(bars.settledTurns.keys()), - expandedTurnKeys: new Set() - }) - const visible = messages.filter((_, index) => !foldedRows.has(index)) - expect(visible.map((message) => message.blocks)).toEqual( - ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ - { type: 'text', text } + expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ + ['user', opener(1)], + ['assistant', opener(1)], + ['assistant', opener(1)], + ['user', opener(2)], + ['assistant', opener(2)], + ['assistant', opener(2)], + ['user', opener(3)], + ['assistant', opener(3)], + ['assistant', opener(3)] ]) - ) - }) + expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) + + const { foldedRows } = nativeChatTurnFold({ + rows: messages.map((message, index) => ({ + turnKey: turnKeys[index], + role: message.role, + rendersProse: true, + outlivesTurn: false, + reportsTurnOutcome: false + })), + settledTurnKeys: new Set(bars.settledTurns.keys()), + expandedTurnKeys: new Set() + }) + const visible = messages.filter((_, index) => !foldedRows.has(index)) + expect(visible.map((message) => message.blocks)).toEqual( + ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ + { type: 'text', text } + ]) + ) + } + ) }) diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 39d52e6b047..a3f4c7f5599 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -1,7 +1,8 @@ -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' import { journalLifecycleItemMutation, @@ -26,7 +27,7 @@ import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { appendCodexLifecycleMutations } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexActiveJournalItem = { threadId: string @@ -53,9 +54,10 @@ export function settleCodexJournalSession(input: { currentTurnIds: ReadonlyMap> primaryThreadId: string | null ordinals: CodexTurnOrdinals - /** Terminal lifecycle for a turn the provider left running when it ended. */ - settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle - linkageFor: CodexRowLinkage + /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a + * conversation command claimed, whose record the host settles. */ + settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null + attributionFor: CodexRowAttribution }): StructuredAgentSessionSinkAdmission { // Rows from every thread settle in this one batch, so each names its own producer. const mutations: JournalLifecycleMutationInput[] = [] @@ -67,13 +69,13 @@ export function settleCodexJournalSession(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } } for (const prompt of input.pendingPrompts.values()) { const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } } for (const [threadId, turnIds] of input.currentTurnIds) { @@ -81,11 +83,15 @@ export function settleCodexJournalSession(input: { continue } for (const turnId of turnIds) { - mutations.push({ - kind: 'item', - identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), - body: codexTurnLifecycleBody(input.settledTurnLifecycle(threadId, turnId)) - }) + const turnLifecycle = input.settledTurnLifecycle(threadId, turnId) + if (turnLifecycle) { + mutations.push({ + kind: 'item', + identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), + body: codexTurnLifecycleBody(turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } turnOrdinalsToForget.push({ threadId, turnId }) } } @@ -114,7 +120,9 @@ export function settleCodexJournalTurn(input: { activeItems: Map pendingPrompts?: Map clearPromptTurn?: (threadId: string, turnId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + /** The end of a conversation command the turn carried, which settles with it. */ + commandEnd?: readonly JournalLifecycleMutationInput[] }): StructuredAgentSessionSinkAdmission { const mutations: JournalLifecycleMutationInput[] = [] const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = [] @@ -133,7 +141,7 @@ export function settleCodexJournalTurn(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) } @@ -143,7 +151,7 @@ export function settleCodexJournalTurn(input: { } const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } pendingPromptsToForget.push(key) } @@ -152,9 +160,11 @@ export function settleCodexJournalTurn(input: { mutations.push({ kind: 'item', identity: codexTurnLifecycleIdentity(input.sessionId, input.turnId), - body: codexTurnLifecycleBody(input.turnLifecycle) + body: codexTurnLifecycleBody(input.turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } + mutations.push(...(input.commandEnd ?? [])) const admission = appendCodexLifecycleMutations( input.sink, `turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`, @@ -176,11 +186,11 @@ export function settleCodexJournalTurn(input: { /** A settled item or prompt, naming its producer: the settlement can be the row's first write. */ function settledRow( - linkageFor: CodexRowLinkage, + attributionFor: CodexRowAttribution, row: { threadId: string; turnId: string | null; identity: AgentJournalItemIdentity }, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return journalLifecycleItemMutation(linkageFor(row.threadId, row.turnId), row.identity, body) + return journalLifecycleItemMutation(attributionFor(row.threadId, row.turnId), row.identity, body) } function interruptedBody(body: AgentJournalItemBody | null): AgentJournalItemBody | null { diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 95a27702365..0823e5679cf 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -1,10 +1,10 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionLifecycleIdentityResolver, StructuredAgentSessionSinkAdmission @@ -35,7 +35,11 @@ export function appendCodexLifecycleMutations( } else { for (const mutation of chunk) { if (mutation.kind === 'item') { - const options = { lifecycle: true, ...mutation.linkage } + const options = { + lifecycle: true, + ...mutation.linkage, + turnScope: mutation.turnScope + } if (sink.tryAppendItem) { admission = sink.tryAppendItem(mutation.identity, mutation.body, options) if (!admission.accepted) { @@ -74,7 +78,7 @@ export function appendCodexItemAndPublish( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission { const admission = sink.tryAppendItem ? sink.tryAppendItem(identity, body, options) @@ -95,9 +99,9 @@ export function appendCodexLifecycleItem( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { - const options = { lifecycle: true, ...linkage } + const options = { lifecycle: true, ...attribution } if (sink.tryAppendItem) { return criticalAdmission(sink.tryAppendItem(identity, body, options)) } @@ -110,14 +114,14 @@ export function appendCodexLifecycleTransition( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (sink.tryAppendLifecycleTransition) { return criticalAdmission( - sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, linkage) + sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, attribution) ) } - const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, linkage) + const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, attribution) return admission.accepted ? publishCodexLifecycle(sink) : admission } @@ -136,7 +140,7 @@ export function admitCodexLifecycleItems( sink: StructuredAgentSessionEventSink, settlementId: string, items: readonly Pick[], - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (items.length === 0) { return { accepted: false, reason: 'untranslated' } @@ -145,14 +149,14 @@ export function admitCodexLifecycleItems( const admission = criticalAdmission( sink.tryAppendLifecycleBatch( settlementId, - items.map((item) => journalLifecycleItemMutation(linkage, item.identity, item.body)), + items.map((item) => journalLifecycleItemMutation(attribution, item.identity, item.body)), { lifecycle: true } ) ) return admission.accepted ? publishCodexLifecycle(sink) : admission } for (const item of items) { - const admission = appendCodexLifecycleItem(sink, item.identity, item.body, linkage) + const admission = appendCodexLifecycleItem(sink, item.identity, item.body, attribution) if (!admission.accepted) { return admission } diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 117f204e963..7b7266345e7 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { selectStructuredAgentTurnActivity } from '../../shared/native-chat-turn-activity' -import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-latest-request' import { isStructuredAgentSessionThinking, statusStructuredAgentSessionToolCall diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index ab0e602e955..344d99e03b4 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -551,7 +551,8 @@ describe('codex journal translation', () => { userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, startedAt: expect.any(Number), completedAt: expect.any(Number) - } + }, + turnScope: { kind: 'thread' } } ] } diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index 3116a329c97..a853ca2bfd8 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -2,7 +2,10 @@ import type { AgentJournalTurnLifecycle, AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -27,7 +30,10 @@ import { readCodexTurnId, readCodexTurnStatus } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' +import type { CodexJournalCommandTurn } from './codex-journal-command-turn' +import { readRecord, readString } from './codex-item-field-readers' +import { recordCodexCommandTurnClaim } from './codex-command-turn-claim' type TurnBoundaryEvent = { sessionId: string @@ -59,7 +65,8 @@ export class CodexJournalTurnBoundaries { clearPromptTurn?: (threadId: string, turnId: string) => void flushSuppression: () => CodexJournalTranslationAdmission resetActivity: (threadId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + commands: CodexJournalCommandTurn now?: () => number } ) {} @@ -73,15 +80,20 @@ export class CodexJournalTurnBoundaries { return { accepted: false, reason: 'backpressure' } } const startedAt = this.receiptTime(event) - const admission = publishCodexTurnLifecycle({ - sink: this.deps.sink, - primaryThreadId: this.deps.primaryThreadId(), - sessionId: event.sessionId, - threadId: event.threadId, - turnId, - state: 'running', - startedAt - }) + const command = + event.threadId === this.deps.primaryThreadId() ? this.deps.commands.claim(turnId) : null + // The command's turn is the record: this turn writes none, and its rows join the command's. + const admission = command + ? recordCodexCommandTurnClaim(this.deps.sink, agentJournalItemKey(command.identity), turnId) + : publishCodexTurnLifecycle({ + sink: this.deps.sink, + primaryThreadId: this.deps.primaryThreadId(), + sessionId: event.sessionId, + threadId: event.threadId, + turnId, + state: 'running', + startedAt + }) if (admission.accepted) { this.deps.activeTurns.remember( event.threadId, @@ -156,15 +168,20 @@ export class CodexJournalTurnBoundaries { // turn boundary is no evidence contact was lost. Only `settleSession` may // write `unverifiable`. const status = readCodexTurnStatus(event.params) - const turnLifecycle = - event.threadId === this.deps.primaryThreadId() - ? this.settled(event.threadId, turnId, { - state: codexTurnLifecycleState(status), - outcome: codexTurnOutcome(status), - completedAt: this.receiptTime(event), - durationMs: readCodexTurnDurationMs(event.params) - }) - : null + const completedAt = this.receiptTime(event) + const commandEnd = this.deps.commands.end(turnId, { + status, + error: readString(readRecord(readRecord(readRecord(event.params).turn).error), 'message'), + completedAt + }) + const turnLifecycle = this.ownsRecord(event.threadId, turnId) + ? this.settled(event.threadId, turnId, { + state: codexTurnLifecycleState(status), + outcome: codexTurnOutcome(status), + completedAt, + durationMs: readCodexTurnDurationMs(event.params) + }) + : null const requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( event.threadId, @@ -180,7 +197,8 @@ export class CodexJournalTurnBoundaries { activeItems: this.deps.items.activeItems, pendingPrompts: this.deps.pendingPrompts, ...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {}), - linkageFor: this.deps.linkageFor + attributionFor: this.deps.attributionFor, + commandEnd }) if (admission.accepted) { if (turnLifecycle) { @@ -193,6 +211,7 @@ export class CodexJournalTurnBoundaries { } this.deps.items.ordinals.forgetTurn(event.threadId, turnId) this.deps.activeTurns.forget(event.threadId, turnId) + this.deps.commands.settled(turnId) this.deps.resetActivity(event.threadId) } return admission @@ -216,9 +235,15 @@ export class CodexJournalTurnBoundaries { } } + /** Whether this translator writes the turn's record: a primary turn no command claimed. */ + ownsRecord(threadId: string, turnId: string): boolean { + return threadId === this.deps.primaryThreadId() && !this.deps.commands.isCarrying(turnId) + } + clear(): void { this.deps.activeTurns.clear() this.recentTurns.clear() + this.deps.commands.clear() } private receiptTime(event: TurnBoundaryEvent): number { diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 52dca66a35b..349233a32d1 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -1,9 +1,10 @@ -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem, - AgentJournalTurnLifecycle, - AgentJournalTurnLifecycleState, - AgentJournalTurnOutcome +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem, + type AgentJournalTurnLifecycle, + type AgentJournalTurnLifecycleState, + type AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' @@ -109,6 +110,7 @@ export function publishCodexTurnLifecycle(input: { // The running row's `ts` is the host's turn-start receipt so clients can anchor a live counter. const appendOptions = { lifecycle: true, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...(input.state === 'running' && input.startedAt !== undefined ? { observedAt: input.startedAt } : {}) diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index 72c1026452d..b13a7212142 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -1,6 +1,6 @@ // The translator's writers, built once. Split out so the translator reads as // routing, and so one place shows that every writer is handed the same -// producer resolver: the roster's, which knows each child thread. +// row attribution: the roster's producer, which knows each child thread, and the turn scope. import { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' @@ -9,31 +9,50 @@ import { CodexJournalGoals } from './codex-structured-journal-goals' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' +import { CodexJournalTurnScopes } from './codex-journal-turn-scopes' +import { CodexJournalCommandTurn } from './codex-journal-command-turn' import { CodexSubagentRoster } from './codex-subagent-roster' +import type { CodexRowAttribution } from './codex-subagent-linkage' export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslatorDeps) { const activeTurns = new CodexJournalActiveTurns() const activeTurn = (threadId: string): string | null => activeTurns.current(threadId) + const primaryThreadId = (): string | null => deps.primaryThreadId?.() ?? null + const commands = new CodexJournalCommandTurn() + const turnScopes = new CodexJournalTurnScopes({ + sessionId: deps.sessionId, + primaryThreadId, + activeTurn, + commandScope: (turnId) => commands.scopeFor(turnId) + }) const subagents = new CodexSubagentRoster({ sink: deps.sink, - primaryThreadId: () => deps.primaryThreadId?.() ?? null, + primaryThreadId, activeTurn, + turnScopeFor: (threadId, turnId) => turnScopes.scopeFor(threadId, turnId), ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) }) const { linkageFor } = subagents.linkage - const producerDeps = { ...deps, linkageFor } + const attributionFor: CodexRowAttribution = (threadId, turnId) => ({ + ...linkageFor(threadId, turnId), + turnScope: turnScopes.scopeFor(threadId, turnId) + }) + const producerDeps = { ...deps, attributionFor } const genericFrames = new CodexJournalGenericFrames(producerDeps, activeTurn) const items = new CodexJournalItems(producerDeps, activeTurn, (threadId, turnId) => genericFrames.suppress(threadId, turnId) ) return { activeTurns, + commands, subagents, - linkageFor, + attributionFor, genericFrames, items, - compactions: new CodexJournalCompactions(deps.sink, activeTurn, linkageFor), - goals: new CodexJournalGoals(deps.sink, linkageFor), + compactions: new CodexJournalCompactions(deps.sink, activeTurn, attributionFor, (turnId) => + commands.compacted(turnId) + ), + goals: new CodexJournalGoals(deps.sink, attributionFor), prompts: new CodexJournalPrompts( producerDeps, (threadId, itemId) => items.detailFor(threadId, itemId), diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fadab8b4589..8e05506509a 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -35,8 +35,17 @@ export { export function createCodexJournalTranslator( deps: CodexJournalTranslatorDeps ): CodexJournalTranslator { - const { activeTurns, subagents, linkageFor, genericFrames, items, compactions, goals, prompts } = - createCodexJournalTranslatorWriters(deps) + const { + activeTurns, + commands, + subagents, + attributionFor, + genericFrames, + items, + compactions, + goals, + prompts + } = createCodexJournalTranslatorWriters(deps) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } let readActivity = createCodexProviderActivityReader() @@ -55,7 +64,8 @@ export function createCodexJournalTranslator( ...(deps.clearPromptTurn ? { clearPromptTurn: deps.clearPromptTurn } : {}), flushSuppression: () => genericFrames.flush(), resetActivity, - linkageFor, + attributionFor, + commands, ...(deps.now ? { now: deps.now } : {}) }) let primaryThreadStoppedRunning = false @@ -150,11 +160,13 @@ export function createCodexJournalTranslator( // The host saw the child go, not what Codex made of the turn, so the row // carries no outcome: the end is observed, the verdict is unknown. settledTurnLifecycle: (threadId, turnId) => - turnBoundaries.settled(threadId, turnId, { - state: 'interrupted', - completedAt: event.observedAt ?? deps.now?.() ?? Date.now() - }), - linkageFor + turnBoundaries.ownsRecord(threadId, turnId) + ? turnBoundaries.settled(threadId, turnId, { + state: 'interrupted', + completedAt: event.observedAt ?? deps.now?.() ?? Date.now() + }) + : null, + attributionFor }) if (!admission.accepted) { return admission @@ -253,8 +265,14 @@ export function createCodexJournalTranslator( event.params, event.threadId ) + if (unhandled.accepted && event.method === 'error') { + commands.errorShown(event.params) + } return publishActivity(event, unhandled) }, + beginCommand: (command) => commands.begin(command), + forgetCommand: (turnId) => commands.forget(turnId), + commandProviderTurnId: (turnId) => commands.providerTurnId(turnId), cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), resolvePrompt: (journalItemId) => prompts.resolve(journalItemId), flush: () => { diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 1742d5ced69..ff42a105fc6 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -4,7 +4,6 @@ import { type AgentSessionCancelOutcome, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { answerCodexPrompt, prepareCodexPromptAnswer, @@ -17,6 +16,11 @@ import type { CodexStructuredTurnCancellation } from './codex-structured-turn-ca type CancelInput = Parameters[0] type AnswerInput = Parameters[0] +/** A command's Stop interrupts the provider turn carrying it; any other turn is its own. */ +function providerTurnId(session: CodexSession, turnId: string): string | undefined { + return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId +} + /** How long a Stop waits for Codex to open the turn it answered a send into. Under the quit * path's eviction budget, which a close or quit queued behind the Stop spends. */ export const CODEX_STOP_TURN_OPEN_WAIT_MS = 5_000 @@ -30,13 +34,13 @@ async function cancelCodexConversation( input: Parameters[0], session: CodexSession ): Promise { - const { request, sessions, compactions, cancellation } = input + const { request, sessions, cancellation } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. const turnId = liveTurnId === null ? ([...(session.activeTurnIds ?? [])].at(-1) ?? (await openedAnsweredTurn(session))) - : compactions.providerTurnId(request.sessionId, liveTurnId) + : providerTurnId(session, liveTurnId) if (!turnId) { return { cancelled: false } } @@ -69,17 +73,16 @@ async function openedAnsweredTurn(session: CodexSession): Promise export async function cancelCodexStructuredTurn(input: { request: CancelInput sessions: Map - compactions: StructuredSessionCompaction cancellation: CodexStructuredTurnCancellation }): Promise { - const { request, sessions, compactions, cancellation } = input + const { request, sessions, cancellation } = input const session = requireLiveCodexSession(sessions, request.sessionId) const prompt = request.prompt const requestedTurnId = request.turnId if (requestedTurnId === undefined) { return prompt ? { cancelled: false } : cancelCodexConversation(input, session) } - const turnId = compactions.providerTurnId(request.sessionId, requestedTurnId) + const turnId = providerTurnId(session, requestedTurnId) if (!turnId) { return { cancelled: false } } @@ -103,7 +106,7 @@ export async function cancelCodexStructuredTurn(input: { !session.ended && session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && - compactions.providerTurnId(request.sessionId, requestedTurnId) === turnId && + providerTurnId(session, requestedTurnId) === turnId && session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) let interruptConfirmed = false try { diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index ff9a210e17a..59a36dbdf67 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -5,7 +5,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession, @@ -125,7 +128,7 @@ function codexPrompts(): CodexJournalPrompts { throw new Error('session was never acquired') } return new CodexJournalPrompts( - { sink, linkageFor: () => ({}) }, + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, () => null, () => 'turn-1' ) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 0cbe62224c5..e765955d53c 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -33,6 +33,7 @@ import { reportedCodexThreadOptions } from './codex-structured-fast-mode' import { + assertCodexConnectionOpen, codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, @@ -41,6 +42,7 @@ import { type CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' +import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { deliverCodexServerRequest } from './codex-structured-provider-events' @@ -64,12 +66,7 @@ export async function acquireCodexStructuredSession(input: { request: Parameters[2] ) => void handleUnhandledFrame: (sessionId: string, kind: string, payload: unknown) => void - forceCloseUnexpected: ( - sessionId: string, - fence: number, - acquisitionGeneration: string, - reason: Error - ) => Promise + forceCloseUnexpected: CodexStructuredSessionTeardown['forceCloseUnexpected'] }): Promise { const { input: acquireInput, @@ -224,9 +221,7 @@ export async function acquireCodexStructuredSession(input: { }), acquisitionGeneration: mintCodexAcquisitionGeneration(deps) } - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.assertCurrent(sessionId, attempt) const options = restoredCodexSessionOptions(acquireInput.options) const catalogAccess = codexAcquireCatalogAccess(deps, launch) @@ -238,9 +233,7 @@ export async function acquireCodexStructuredSession(input: { timeoutMs: deps.requestTimeoutMs }) acquisitions.assertCurrent(sessionId, attempt) - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.deleteIfCurrent(sessionId, attempt) // Where this session's child work goes: the host's records, after each frame is journaled. const sink = codexChildWorkSink(sessionId, deps) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index eebebe3aa44..82f927e0e86 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -3,7 +3,6 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { AgentSessionAcquisition, @@ -14,6 +13,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { @@ -34,7 +34,10 @@ import { translateCodexNotification } from './codex-structured-provider-events' import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' -import { settleCodexSendsInEndedTurn } from './codex-structured-turn-end-settlement' +import { + codexDispatchRejection, + settleCodexSendsInEndedTurn +} from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' import { changeCodexThreadGoal } from './codex-structured-thread-goal' @@ -50,7 +53,6 @@ export type { } from './codex-structured-session-state' export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new CodexAcquisitionRegistry() private readonly turnCancellation: CodexStructuredTurnCancellation @@ -154,7 +156,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return admission } if (event.type === 'notification') { - this.compactions.codex(event.sessionId, event.method, event.params) // Only an admitted turn end settles; a refused one settles on the retry that lands. settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) @@ -167,9 +168,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap // After the journal and the parent's republished row, never ahead of either. session.backgroundTasks.publishChildWork() } - if (event.type === 'ended') { - this.compactions.ended(event.sessionId) - } this.deps.onEvent?.(event) return admission } @@ -236,7 +234,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap cancelCodexStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, cancellation: this.turnCancellation }) @@ -251,33 +248,31 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap recoverRewind: NonNullable = (input) => codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs) - compact: NonNullable = (input) => { + /** The ack is Codex's receipt; the translator ends the command's turn from the turn it opens. */ + compact: NonNullable = async (input) => { const session = this.session(input.sessionId) - return this.compactions.run( - input.sessionId, - session.threadId, - async () => { - await this.turnCancellation.captureBaseline(session) - return session.connection - .request( - 'thread/compact/start', - { threadId: session.threadId }, - { timeoutMs: this.deps.requestTimeoutMs } + session.translator?.beginCommand(input.command) + try { + await this.turnCancellation.captureBaseline(session) + await session.connection.request( + 'thread/compact/start', + { threadId: session.threadId }, + { timeoutMs: this.deps.requestTimeoutMs } + ) + return { state: 'accepted', providerIdentity: null } + } catch (error) { + session.translator?.forgetCommand(input.command.turnId) + if (isCodexAppServerRequestError(error)) { + // Codex's own words, when it gave any, are the one part of the error a person can use. + return { + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) ) - .then( - () => undefined, - (error) => { - if (isCodexAppServerRequestError(error)) { - const detail = error.providerDiagnostic - return { outcome: 'failed' as const, ...(detail ? { detail } : {}) } - } - throw error - } - ) - }, - input.onLateResult, - input.turnId - ) + } + } + throw error + } } changeThreadGoal: NonNullable = (input) => diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 02c56b419bd..627e33dbfa9 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -170,6 +170,16 @@ export function codexSessionLifecycle( } } +/** A child that exited while being acquired never becomes the session's. */ +export function assertCodexConnectionOpen( + connection: Pick, + sessionId: string +): void { + if (connection.closed) { + throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) + } +} + export function requireLiveCodexSession( sessions: Map, sessionId: string diff --git a/src/main/codex/codex-subagent-linkage.ts b/src/main/codex/codex-subagent-linkage.ts index 59d57a8e4c0..52f6bdc2611 100644 --- a/src/main/codex/codex-subagent-linkage.ts +++ b/src/main/codex/codex-subagent-linkage.ts @@ -8,15 +8,18 @@ // run are learned from the roster's executions, and a later revision of the // row picks them up when they arrive. -import type { AgentJournalProducerLinkage } from '../../shared/agent-session-journal-types' +import type { + AgentJournalProducerLinkage, + AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import type { CodexSubagentExecutions } from './codex-subagent-executions' -/** Linkage for a row one thread produced, within one of that thread's turns - * (null outside any). Every Codex write site resolves through this. */ -export type CodexRowLinkage = ( +/** Who produced a row one thread wrote within one of that thread's turns (null outside any), + * and which turn the row belongs to. Every Codex write site resolves through this. */ +export type CodexRowAttribution = ( threadId: string, turnId: string | null -) => AgentJournalProducerLinkage +) => AgentJournalRowAttribution export class CodexSubagentLinkage { constructor( @@ -26,7 +29,7 @@ export class CodexSubagentLinkage { } ) {} - linkageFor: CodexRowLinkage = (threadId, turnId) => { + linkageFor = (threadId: string, turnId: string | null): AgentJournalProducerLinkage => { const primary = this.deps.primaryThreadId() // An unknown primary means the session's thread is still opening, and no // turn has run that could have spawned a child. diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts new file mode 100644 index 00000000000..02c79411b79 --- /dev/null +++ b/src/main/codex/codex-subagent-roster-state.ts @@ -0,0 +1,36 @@ +// One spawn group's roster state, and the ids its journal row is keyed on. + +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + +/** The turn a group belongs to when Codex reports activity outside any turn. + * Mirrors the generic-frame bucket name so the two read alike in the journal. */ +export type RosterGroup = { + groupId: string + identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope + /** Insertion order is the display order; the map holds the state. */ + entries: Map + executionTurns: Map + /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ + labelCounts: Map + /** Last body written, so an idempotent replay writes no new revision. */ + lastSerialized: string | null +} + +/** Group identity: the parent turn that spawned the children. `agentPath` is a + * tree rooted at the parent thread, so every child of one turn shares a row + * no matter which thread's stream carried its activity item. */ +export function codexSubagentGroupId(threadId: string, turnId: string | null): string { + return `${threadId}:${turnId ?? 'outside-turn'}` +} + +/** Durable journal identity for the group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 9d3e2c690c4..dcaecb0e3e5 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas' import type { @@ -43,6 +44,7 @@ function createHarness(options: { threadId?: string | null } = {}): { tryPublish: () => ({ accepted: true }) } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId), activeTurn: () => TURN, @@ -138,6 +140,7 @@ function createCoalescingHarness(): { } } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => THREAD, activeTurn: () => TURN, @@ -668,6 +671,7 @@ describe('CodexSubagentRoster', () => { const published: number[] = [] const refusal = { accepted: false, reason: 'backpressure' } as const const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -721,6 +725,7 @@ describe('CodexSubagentRoster', () => { const appended: Appended[] = [] const published: number[] = [] const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -765,6 +770,7 @@ describe('CodexSubagentRoster', () => { it('propagates sink backpressure instead of reporting the row as written', () => { const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 99b37e9c772..db02ac7f454 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -13,12 +13,9 @@ // thread, and a real turn id is assumed freshly minted per turn. Seeding from // the journal is the fix. -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' -import type { - NativeChatSubagentEntry, - NativeChatSubagentState -} from '../../shared/native-chat-types' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -40,6 +37,12 @@ import { readCodexTurnId } from './codex-structured-thread-facts' import { codexSubagentGroupBody } from './codex-subagent-group-body' import { CodexSubagentLinkage } from './codex-subagent-linkage' export { codexSubagentGroupBody } from './codex-subagent-group-body' +export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state' +import { + codexSubagentGroupId, + codexSubagentGroupIdentity, + type RosterGroup +} from './codex-subagent-roster-state' import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, @@ -49,38 +52,12 @@ import { const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } -/** The turn a group belongs to when Codex reports activity outside any turn. - * Mirrors the generic-frame bucket name so the two read alike in the journal. */ -type RosterGroup = { - groupId: string - identity: AgentJournalItemIdentity - /** Insertion order is the display order; the map holds the state. */ - entries: Map - executionTurns: Map - /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ - labelCounts: Map - /** Last body written, so an idempotent replay writes no new revision. */ - lastSerialized: string | null -} - -/** Group identity: the parent turn that spawned the children. `agentPath` is a - * tree rooted at the parent thread, so every child of one turn shares a row - * no matter which thread's stream carried its activity item. */ -export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? 'outside-turn'}` -} - -/** Durable journal identity for the group's row — stable across revisions and - * across a restart, so replay finds the same row instead of appending a new one. */ -export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } -} - export type CodexSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The thread that owns the agent tree; falls back to the event's thread. */ primaryThreadId: () => string | null activeTurn: (threadId: string) => string | null + turnScopeFor: (threadId: string, turnId: string | null) => AgentJournalTurnScope now?: () => number executions?: CodexSubagentExecutions } @@ -280,6 +257,7 @@ export class CodexSubagentRoster { const group: RosterGroup = { groupId, identity: codexSubagentGroupIdentity(groupId), + turnScope: this.deps.turnScopeFor(ownerThreadId, ownerTurnId), entries: new Map(), executionTurns: new Map(), labelCounts: new Map(), @@ -361,7 +339,10 @@ export class CodexSubagentRoster { // The publish must NOT reuse that key: the queue coalesces by key alone, // with no op-kind check, so a publish carrying it would splice out the // still-queued append and the row would never reach the journal. - const options = { coalescingKey: `codex-subagents:${group.groupId}` } + const options = { + coalescingKey: `codex-subagents:${group.groupId}`, + turnScope: group.turnScope + } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(group.identity, body, options) : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts index 978f076d6e9..c44390b738e 100644 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A repair drops what it cannot replay, and says so. // // Two things make a suffix unreplayable: a row this build cannot parse, and a @@ -102,9 +103,18 @@ afterEach(async () => { describe('a malformed row', () => { it('keeps the readable prefix live and drops the rest of the epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('unreadable'), { fence: 1 }) - await journal.appendItem(item(2), body('after the fault'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('unreadable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(2), body('after the fault'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('{"not":"a row"}', 3) @@ -118,8 +128,14 @@ describe('a malformed row', () => { it('discloses the line it could not read', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('later'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('later'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) @@ -141,7 +157,10 @@ describe('a sequence gap', () => { it('drops every row after the hole and reports the epoch corrupt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves @@ -163,7 +182,10 @@ describe('a sequence gap', () => { it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { @@ -177,7 +199,10 @@ describe('a sequence gap', () => { // Same policy the emptied-epoch repair takes: a session that writes into the // epoch owns it, and a later import must not replace rows the user has seen. const writable = await open() - await writable.appendItem(item(9), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(9), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) @@ -187,7 +212,10 @@ describe('a sequence gap', () => { it('is not settled by the repair disclosure it appends for a malformed row', async () => { const journal = await open() for (let ordinal = 0; ordinal < 3; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { @@ -207,7 +235,10 @@ describe('a missing epoch row', () => { // renders a repaired journal as a clean timeline. it('rejects the whole surviving range rather than declaring it contiguous', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -245,7 +276,10 @@ describe('a missing epoch row', () => { // consulted again and the dropped rows never come back. it('keeps asking for provider history until the epoch has content of its own', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) @@ -258,7 +292,10 @@ describe('a missing epoch row', () => { // A session that writes into the epoch owns it: its own rows are not a // repair placeholder, and a later import must not replace them. const writable = await open() - await writable.appendItem(item(1), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(1), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 6cc267a9054..17fb2b1eb49 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The crash boundary: the host wrote a submission row, dispatched, and died // before it learned whether the provider took the message. Replay must reconcile // without duplicating the user's message and without losing it. @@ -138,7 +139,10 @@ describe('crash between provider accept and journal commit', () => { }) // The provider's own copy of the message arrives next, under the identity // reconciliation adopted. It must land in the bubble the user already sees. - await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { fence: 2 }) + await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { + fence: 2, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const items = restarted.snapshot().items expect(items).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts new file mode 100644 index 00000000000..0fbadede3bc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts @@ -0,0 +1,51 @@ +// The turn scope of a row written before rows stated one: the root turn that was open when the +// row was created. Hosts now state a scope on every item they write, so this reads only legacy +// rows, and rows an older host writes into a newer journal after a downgrade. It needs no +// persisted state: the journal is replayed in full on every open, in creation order. +// +// A root turn record created running opens a span, which closes when a revision ends it or +// removes its turn body — a legacy `/compact` row was created as a running turn carrier and then +// overwritten with plain status. A turn record created already settled (rebuilt history) stays +// open until the next root turn record: that is its position within the provider's history. +// Rows created in the window between a crash and the stale-turn sweep land in the dead turn, +// which is where their position puts them too. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' + +export class JournalDerivedTurnScope { + private openTurnItemId: string | null = null + + /** The scope for a row being created now that states none. A turn record belongs to no turn. */ + scopeFor(body: AgentJournalItemBody): AgentJournalTurnScope { + return this.openTurnItemId === null || readAgentJournalTurn(body) + ? AGENT_JOURNAL_THREAD_SCOPE + : { kind: 'turn', turnItemId: this.openTurnItemId } + } + + /** Every change to an item, from any row, after it is applied. `before` is undefined when the + * row created the item, `after` when it removed it. Only the session's own turns open a span. */ + observe( + itemId: string, + root: boolean, + before: AgentJournalItemBody | undefined, + after: AgentJournalItemBody | undefined + ): void { + const turn = readAgentJournalTurn(after) + if (before === undefined && turn && root) { + this.openTurnItemId = itemId + return + } + if ( + itemId === this.openTurnItemId && + readAgentJournalTurn(before)?.state === 'running' && + turn?.state !== 'running' + ) { + this.openTurnItemId = null + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 470ed3dfcb2..2af2623e862 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -7,6 +7,7 @@ import { } from '../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' +import { placeHandedOverMessage } from './journal-submission-fold' import type { JournalRow } from './journal-row-schema' export function applyJournalDispatchRow( @@ -39,6 +40,7 @@ export function applyJournalDispatchRow( submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts + placeHandedOverMessage(state, submission, row) } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 96273366d5f..7d87399c62b 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Republishing an epoch is ONE transaction. import { mkdtemp, rm } from 'node:fs/promises' @@ -86,8 +87,16 @@ describe('journal epoch replacement', () => { it('discards every superseded row in the same transaction', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 }) - await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 }) + await journal.appendItem( + item(1), + { kind: 'status', text: 'old' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + item(2), + { kind: 'status', text: 'older' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const before = journal.epoch await journal.replaceEpochItems('legacy_import', 1, [ diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 9512d5e9fa1..cc9a2adc1bd 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -7,6 +7,8 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' @@ -22,10 +24,13 @@ import { import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' -export type JournalReplacementItem = { +export type JournalReplacementItem = AgentJournalProducerLinkage & { identity: AgentJournalItemIdentity body: AgentJournalItemBody observedAt?: number + /** Absent for history rebuilt from a source that never stated one: derived from position, as + * a legacy row's is, and then written down. */ + turnScope?: AgentJournalTurnScope } export function replaceJournalEpoch(input: { @@ -56,7 +61,9 @@ export function replaceJournalEpoch(input: { body: item.body, seq: state.lastSequence + 1, fence: input.fence, - ts: item.observedAt ?? input.now() + ts: item.observedAt ?? input.now(), + linkage: item, + turnScope: item.turnScope ?? state.derivedTurnScope.scopeFor(item.body) }) assertJournalFence(row.fence, state.highestFence) applyJournalRow(state, row) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index d6424ce7952..34fed89be53 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // An empty chat beside a pre-SQLite journal explains itself. // // The SQLite move shipped no importer, so a session whose history is a @@ -142,7 +143,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() // Deleting the anchor leaves every row unanchored: replay keeps nothing, so diff --git a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts index 592f7471c5c..7a69de557a4 100644 --- a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Every path that can open a SQLite connection releases it. // // Asserting that the happy path closes cleanly proves nothing: these sites are @@ -73,7 +74,11 @@ afterEach(async () => { describe('the standalone probe owns its own connection', () => { it('leaves no handle behind after fifty repeated loads', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'message', role: 'user', blocks: [] }, { fence: 1 }) + await journal.appendItem( + item(1), + { kind: 'message', role: 'user', blocks: [] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await journal.close() for (let attempt = 0; attempt < 50; attempt += 1) { @@ -141,7 +146,10 @@ describe('failure paths inside the open call', () => { // connection exists, which is what lets the factory need no `finally`. it('leaves nothing open when a post-connection step of open() throws', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), runningTool(), { fence: 1 }) + await journal.appendItem(item(1), runningTool(), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() // Replay runs after the connection is open, so a read it cannot serve diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts new file mode 100644 index 00000000000..10aee4a99c4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -0,0 +1,97 @@ +// Folding one item write into the render model: the revision, tombstone and creating-write rules +// the reducer header states. Turn scope is the creating write's too, with one exception, which the +// submission fold owns: a queued message's scope and position are its handover's. + +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalLinkageFields, + isRootAgentJournalItem, + namesAgentJournalProducer +} from '../../../shared/agent-session-journal-producer' +import type { JournalReducerState } from './journal-reducer' + +export function statedOrDerivedTurnScope( + state: JournalReducerState, + write: { body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } +): AgentJournalTurnScope { + return write.turnScope ?? state.derivedTurnScope.scopeFor(write.body) +} + +export function upsertJournalItem( + state: JournalReducerState, + itemId: string, + revision: number, + next: AgentJournalRenderItem, + /** The creating write's fence: the generation a running turn belongs to. */ + fence: number +): void { + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) + if (!existing) { + state.items.set(itemId, next) + state.itemFences.set(itemId, fence) + state.tombstones.delete(itemId) + return + } + // Creation sequence is the ordering key; a revision refreshes content only. + // `observedAt` is pinned with it: clients sort the timeline by that timestamp, + // so letting a revision advance it makes the row jump past everything that + // landed in between — the provider's own echo of a send revises the submission + // row, which relocated the user's bubble below later rows. + const submitted = + existing.body.kind === 'message' && + existing.body.role === 'user' && + parseAgentJournalItemKey(itemId)?.provider === 'orca' + const { sequenceIndex: _revisedAt, ...revised } = next + state.items.set(itemId, { + ...revised, + // Settlements, prompt answers and reopen sweeps revise rows any agent wrote + // without naming one; each would otherwise hand a subagent's row to the session. + ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), + // Provider history may normalize text or omit local attachments from the original send. + body: submitted ? existing.body : next.body, + sequence: existing.sequence, + ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), + observedAt: existing.observedAt, + turnScope: existing.turnScope ?? next.turnScope + }) + state.tombstones.delete(itemId) +} + +export function removeJournalItem( + state: JournalReducerState, + itemId: string, + revision: number +): void { + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + if (existing) { + state.derivedTurnScope.observe( + itemId, + isRootAgentJournalItem(existing), + existing.body, + undefined + ) + } + state.tombstones.set(itemId, revision) + state.items.delete(itemId) + state.itemFences.delete(itemId) +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 92b085e2e0f..7751265b72e 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Legacy import runs the existing per-agent transcript decoders and keys the // results by identity read off the same raw lines. Fixtures are shaped like the // files the providers actually write. @@ -567,7 +568,7 @@ describe('import failures', () => { await journal.appendItem( { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'kept' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const before = journal.epoch const metadataOnly = await writeFixture('metadata-only.jsonl', [ diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts index 07ae011b790..d31d2569c18 100644 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -79,7 +80,8 @@ function seeded() { seq, fence: 1, ts: 1_000 + seq, - ...(stamp ? { linkage: stamp } : {}) + ...(stamp ? { linkage: stamp } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ) } @@ -90,7 +92,12 @@ function seeded() { journalLifecycleBatchRowBuilder( () => state, `settle-${seq}`, - identities.map((identity) => ({ kind: 'item' as const, identity, body: text('settled') })), + identities.map((identity) => ({ + kind: 'item' as const, + identity, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), { fence: 1 } )(seq, 1_000 + seq) ) @@ -180,15 +187,35 @@ describe('producer inheritance across a reopen', () => { it('replays an inherited producer from disk exactly as it was folded live', async () => { const journal = await open() - await journal.appendItem(child, text('working'), { fence: 1, ...linkage }) - await journal.appendItem(own, text('working'), { fence: 1 }) - await journal.appendItem(child, text('still working'), { fence: 1 }) + await journal.appendItem(child, text('working'), { + fence: 1, + ...linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(own, text('working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(child, text('still working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendLifecycleBatch({ settlementId: 'settle-1', fence: 1, mutations: [ - { kind: 'item', identity: child, body: text('settled') }, - { kind: 'item', identity: own, body: text('settled') } + { + kind: 'item', + identity: child, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: own, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] }) const live = journal.snapshot().items diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts new file mode 100644 index 00000000000..6c3640c15cb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' +import { buildJournalItemRow, journalLifecycleBatchRowBuilder } from './journal-row-builders' + +const EPOCH = 'epoch-1' + +function text(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +describe('producer linkage round-trips through the reducer', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'claude-session', + uuid: 'child-1' + } + const linkage = { + agentId: 'task-1', + parentAgentId: 'task-parent', + providerParentRef: 'toolu_1', + producerKind: 'agent' as const, + attempt: 2 + } + + it('copies the whole bundle onto the render item on the plain item path', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], + ...base(1), + ...linkage + }) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('reads each mutation of a mixed batch as its own producer', () => { + // A batch can CREATE rows several agents produced — a settlement landing + // before any checkpoint did. The mutation that names a producer is that + // producer's; the one naming none is the session's own, beside it. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + journalLifecycleBatchRowBuilder( + () => state, + 'settle-mixed', + [ + { + kind: 'item', + identity, + body: text('child'), + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, + body: text('own'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { fence: 1 } + )(1, 1_001) + ) + + const [child, own] = renderJournalState(state).items + expect(child).toMatchObject({ body: text('child'), ...linkage }) + expect(own?.body).toEqual(text('own')) + expect(own && 'agentId' in own).toBe(false) + }) + + it('lets a correction win over the provisional row, without moving the bubble', () => { + // Write-through then correct: the row is written under the spawn call's own + // id, then re-appended under the canonical one. Revision is assigned inside + // the journal's serialized write step, so the later append always outranks + // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does + // not relocate it in the timeline. + const state = createJournalReducerState('session-1', EPOCH) + const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage: provisional, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 9, + fence: 1, + ts: 9_999, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) + }) + + it('does not let a stale checkpoint undo a correction that already landed', () => { + // A text checkpoint carrying the OLD stamp, submitted after the correction, + // would re-root the row. It cannot: revision is read at write time, so the + // last write wins and the lane resolves linkage fresh on every checkpoint. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a'), + seq: 1, + fence: 1, + ts: 1, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a and more'), + seq: 2, + fence: 1, + ts: 2, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + const items = renderJournalState(state).items + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it('keeps linkage when a later revision rewrites the row', () => { + // The resolved-append path lost the marker once before by rebuilding the + // row without it, so the SECOND write is the one that matters here. + const state = createJournalReducerState('session-1', EPOCH) + for (const [seq, body] of [ + [1, text('look')], + [2, text('looking at the lane')] + ] as const) { + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it("renders a row that predates linkage as the session's own", () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'item', + itemId: 'i-legacy', + revision: 1, + body: text('written before linkage existed'), + ...base(1) + }) + const item = renderJournalState(state).items[0] + expect(item && 'agentId' in item).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index eea740e754d..45681f32871 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey, @@ -17,11 +18,7 @@ import { renderJournalState, type JournalReducerState } from './journal-reducer' -import { - buildJournalItemRow, - buildJournalTombstoneRow, - journalLifecycleBatchRowBuilder -} from './journal-row-builders' +import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -657,7 +654,15 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) expect(renderJournalState(state).items).toEqual([]) @@ -666,7 +671,15 @@ describe('re-adding a tombstoned row', () => { // `items` would restart at 1 and lose to the tombstone forever. applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('second')]) }) @@ -682,12 +695,28 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(state.tombstones.get(itemId)).toBeUndefined() @@ -695,170 +724,3 @@ describe('re-adding a tombstoned row', () => { expect(renderJournalState(state).items).toEqual([]) }) }) - -describe('producer linkage round-trips through the reducer', () => { - const identity: AgentJournalItemIdentity = { - provider: 'claude', - sessionId: 'claude-session', - uuid: 'child-1' - } - const linkage = { - agentId: 'task-1', - parentAgentId: 'task-parent', - providerParentRef: 'toolu_1', - producerKind: 'agent' as const, - attempt: 2 - } - - it('copies the whole bundle onto the render item on the plain item path', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage - }) - ) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'lifecycle-batch', - settlementId: 'settle-1', - mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], - ...base(1), - ...linkage - }) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('reads each mutation of a mixed batch as its own producer', () => { - // A batch can CREATE rows several agents produced — a settlement landing - // before any checkpoint did. The mutation that names a producer is that - // producer's; the one naming none is the session's own, beside it. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - journalLifecycleBatchRowBuilder( - () => state, - 'settle-mixed', - [ - { kind: 'item', identity, body: text('child'), linkage }, - { - kind: 'item', - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, - body: text('own') - } - ], - { fence: 1 } - )(1, 1_001) - ) - - const [child, own] = renderJournalState(state).items - expect(child).toMatchObject({ body: text('child'), ...linkage }) - expect(own?.body).toEqual(text('own')) - expect(own && 'agentId' in own).toBe(false) - }) - - it('lets a correction win over the provisional row, without moving the bubble', () => { - // Write-through then correct: the row is written under the spawn call's own - // id, then re-appended under the canonical one. Revision is assigned inside - // the journal's serialized write step, so the later append always outranks - // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does - // not relocate it in the timeline. - const state = createJournalReducerState('session-1', EPOCH) - const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage: provisional - }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 9, - fence: 1, - ts: 9_999, - linkage - }) - ) - - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) - }) - - it('does not let a stale checkpoint undo a correction that already landed', () => { - // A text checkpoint carrying the OLD stamp, submitted after the correction, - // would re-root the row. It cannot: revision is read at write time, so the - // last write wins and the lane resolves linkage fresh on every checkpoint. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body: text('a'), seq: 1, fence: 1, ts: 1, linkage }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('a and more'), - seq: 2, - fence: 1, - ts: 2, - linkage - }) - ) - const items = renderJournalState(state).items - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it('keeps linkage when a later revision rewrites the row', () => { - // The resolved-append path lost the marker once before by rebuilding the - // row without it, so the SECOND write is the one that matters here. - const state = createJournalReducerState('session-1', EPOCH) - for (const [seq, body] of [ - [1, text('look')], - [2, text('looking at the lane')] - ] as const) { - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body, seq, fence: 1, ts: 1_000 + seq, linkage }) - ) - } - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it("renders a row that predates linkage as the session's own", () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'item', - itemId: 'i-legacy', - revision: 1, - body: text('written before linkage existed'), - ...base(1) - }) - const item = renderJournalState(state).items[0] - expect(item && 'agentId' in item).toBe(false) - }) -}) 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 d7e22575702..2b83f7665b5 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -5,9 +5,10 @@ // Rules: highest revision wins, a tombstone removes, a late lower revision is // dropped rather than resurrecting stale content, and ordering is by the // position (sequence, then place in the row) of the write that CREATED an item -// (a later revision updates the body, it does not move the bubble). Producer -// linkage is likewise the creating write's: a revision naming no producer keeps -// it, one naming any replaces it. +// (a later revision updates the body, it does not move the bubble) — except a +// queued message, which sits where its handover put it. Producer linkage is +// likewise the creating write's: a revision naming no producer keeps it, one +// naming any replaces it. import type { AgentJournalAcceptanceReceipt, @@ -21,13 +22,12 @@ import { agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - agentJournalLinkageFields, - namesAgentJournalProducer -} from '../../../shared/agent-session-journal-producer' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { JournalDerivedTurnScope } from './journal-derived-turn-scope' +import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' import 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' @@ -52,6 +52,8 @@ export type JournalReducerState = { * echo from appending a second copy of the user's own message. */ aliases: Map appliedSettlementIds: Set + /** Scope for rows stored without one; rebuilt by replay, never persisted. */ + derivedTurnScope: JournalDerivedTurnScope } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -68,7 +70,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), - appliedSettlementIds: new Set() + appliedSettlementIds: new Set(), + derivedTurnScope: new JournalDerivedTurnScope() } } @@ -85,17 +88,17 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, row.itemId, row.body) acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) - upsertItem( + upsertJournalItem( state, itemId, row.revision, - journalRenderItem(itemId, row.revision, row.body, row), + journalRenderItem(itemId, row.revision, row.body, row, statedOrDerivedTurnScope(state, row)), row.fence ) return } if (row.kind === 'tombstone') { - removeItem(state, resolveItemId(state, row.itemId), row.revision) + removeJournalItem(state, resolveItemId(state, row.itemId), row.revision) return } if (row.kind === 'lifecycle-batch') { @@ -111,17 +114,18 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo const itemId = resolveJournalItemId(state, mutation.itemId, body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) const producer = journalBatchMutationProducer(row, mutation) - const item = journalRenderItem(itemId, revision, body, row, producer, sequenceIndex) - upsertItem(state, itemId, revision, item, row.fence) + const scope = statedOrDerivedTurnScope(state, mutation) + const item = journalRenderItem(itemId, revision, body, row, scope, producer, sequenceIndex) + upsertJournalItem(state, itemId, revision, item, row.fence) } else { - removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) + removeJournalItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } } rememberAppliedSettlementId(state, row.settlementId) return } if (row.kind === 'submission') { - applySubmission(state, row) + applyJournalSubmission(state, row) return } applyJournalDispatchRow(state, row) @@ -192,117 +196,6 @@ function resolveItemId(state: JournalReducerState, itemId: string): string { return state.aliases.get(itemId) ?? itemId } -function upsertItem( - state: JournalReducerState, - itemId: string, - revision: number, - next: AgentJournalRenderItem, - fence: number -): void { - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - if (!existing) { - state.items.set(itemId, next) - state.itemFences.set(itemId, fence) - state.tombstones.delete(itemId) - return - } - // Creation sequence is the ordering key; a revision refreshes content only. - // `observedAt` is pinned with it: clients sort the timeline by that timestamp, - // so letting a revision advance it makes the row jump past everything that - // landed in between — the provider's own echo of a send revises the submission - // row, which relocated the user's bubble below later rows. - const submitted = - existing.body.kind === 'message' && - existing.body.role === 'user' && - parseAgentJournalItemKey(itemId)?.provider === 'orca' - const { sequenceIndex: _revisedAt, ...revised } = next - state.items.set(itemId, { - ...revised, - // Settlements, prompt answers and reopen sweeps revise rows any agent wrote - // without naming one; each would otherwise hand a subagent's row to the session. - ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), - // Provider history may normalize text or omit local attachments from the original send. - body: submitted ? existing.body : next.body, - sequence: existing.sequence, - ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), - observedAt: existing.observedAt - }) - state.tombstones.delete(itemId) -} - -function removeItem(state: JournalReducerState, itemId: string, revision: number): void { - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - state.tombstones.set(itemId, revision) - state.items.delete(itemId) - state.itemFences.delete(itemId) -} - -function applySubmission( - state: JournalReducerState, - row: Extract -): void { - state.submissions.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - fence: row.fence, - payloadFingerprint: row.payloadFingerprint, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: row.ts, - resolvedAt: null, - ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) - }) - const itemId = agentJournalSubmissionKey(row.clientMessageId) - upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row), row.fence) -} - -function acceptSubmissionFromProviderItem( - state: JournalReducerState, - providerItemId: string, - resolvedItemId: string, - row: Pick -): void { - if (providerItemId === resolvedItemId) { - return - } - const submission = [...state.submissions.values()].find( - (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId - ) - if ( - !submission || - submission.dispatchState === 'accepted' || - submission.dispatchState === 'rejected' - ) { - return - } - submission.fence = row.fence - submission.dispatchState = 'accepted' - submission.providerItemId = providerItemId - submission.reason = null - submission.resolvedAt = row.ts - delete submission.recovered - state.receipts.set(submission.clientMessageId, { - clientMessageId: submission.clientMessageId, - providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - /** Project the folded state into the client-facing snapshot. */ export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { // The journal position is the sole ordering key; map insertion order is not, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index 516086eed3d..fd58b2c25e4 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -1,7 +1,8 @@ import type { AgentJournalItemBody, AgentJournalProducerLinkage, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields, @@ -19,6 +20,7 @@ export function journalRenderItem( revision: number, body: AgentJournalItemBody, row: JournalRow, + turnScope: AgentJournalTurnScope, producer: AgentJournalProducerLinkage = row, /** Which of the row's writes this is; only a lifecycle batch has more than one. */ sequenceIndex = 0 @@ -32,6 +34,7 @@ export function journalRenderItem( observedAt: row.ts, ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), + turnScope, ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 4428e13953e..aaac2bc1a20 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Wiring the restart reconciler: what provider history is allowed to decide // about a submission the crash boundary could only doubt. @@ -215,7 +216,8 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const journal = await open() // An identical message, delivered and committed BEFORE the one that crashed. await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { - fence: 1 + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await journal.appendSubmission({ clientMessageId: 'cm_1', diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 7b36fc88dc6..f1484bf5da8 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -35,6 +35,8 @@ function comparableBody(body: AgentJournalMessageItem | undefined): boolean { return ( body?.kind === 'message' && body.role === 'user' && + // A conversation command leaves no user item in provider history to find. + body.command === undefined && body.blocks.length === 1 && body.blocks[0]?.type === 'text' && body.blocks[0].text.trim().length > 0 diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index ee56f28f8a2..9a1869bfe03 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -3,6 +3,8 @@ import type { AgentJournalItemIdentity, AgentJournalMessageItem, AgentJournalProducerLinkage, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' @@ -33,7 +35,7 @@ export function journalItemRowBuilder( state: () => JournalReducerState, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: AgentJournalProducerLinkage & { fence: number; observedAt?: number; recovered?: true } + options: AgentJournalRowAttribution & { fence: number; observedAt?: number; recovered?: true } ): RowBuilder { return (seq, ts) => buildJournalItemRow({ @@ -44,7 +46,8 @@ export function journalItemRowBuilder( fence: options.fence, ts: options.observedAt ?? ts, recovered: options.recovered, - linkage: options + linkage: options, + turnScope: options.turnScope }) } @@ -76,7 +79,9 @@ export function journalDispatchRowBuilder( input: ResolveDispatchInput ): RowBuilder { const providerItemId = - input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null + input.state === 'accepted' && input.providerIdentity + ? agentJournalItemKey(input.providerIdentity) + : null // The only dispatch-row builder: its input type is what makes a rejected row carry its fact. return (seq, ts) => ({ kind: 'dispatch', @@ -86,7 +91,8 @@ export function journalDispatchRowBuilder( reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), - ...(input.recovered ? { recovered: input.recovered } : {}) + ...(input.recovered ? { recovered: input.recovered } : {}), + ...(input.state === 'pending' ? { turnScope: input.turnScope } : {}) }) } @@ -109,6 +115,8 @@ export type JournalLifecycleMutationInput = /** Who wrote the row. Absent ⇒ the session's own agent on a first write, * and the row's existing producer on a revision. */ linkage?: AgentJournalProducerLinkage + /** Which turn the row belongs to. Kept from the write that creates the row. */ + turnScope: AgentJournalTurnScope } | { kind: 'tombstone'; identity: AgentJournalItemIdentity } @@ -117,13 +125,14 @@ export type JournalLifecycleMutationInput = * settled before any checkpoint landed — and one batch can mix producers. * The session's own rows carry no key at all: absence is the claim. */ export function journalLifecycleItemMutation( - producer: AgentJournalProducerLinkage, + attribution: AgentJournalRowAttribution, identity: AgentJournalItemIdentity, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return namesAgentJournalProducer(producer) - ? { kind: 'item', identity, body, linkage: agentJournalLinkageFields(producer) } - : { kind: 'item', identity, body } + const { turnScope } = attribution + return namesAgentJournalProducer(attribution) + ? { kind: 'item', identity, body, turnScope, linkage: agentJournalLinkageFields(attribution) } + : { kind: 'item', identity, body, turnScope } } /** The persisted form of one mutation, shared with the partitioner's size probe @@ -139,6 +148,7 @@ export function journalLifecycleMutationRow( itemId, revision, body: mutation.body, + turnScope: mutation.turnScope, ...agentJournalLinkageFields(mutation.linkage) } : { kind: 'tombstone', itemId, revision } @@ -212,6 +222,7 @@ export function buildJournalItemRow(input: { ts: number recovered?: true linkage?: AgentJournalProducerLinkage + turnScope: AgentJournalTurnScope }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -229,6 +240,7 @@ export function buildJournalItemRow(input: { body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), ...(input.recovered ? { recovered: input.recovered } : {}), + turnScope: input.turnScope, ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index fe23a5ddcb5..6ebc20e5a33 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Context facts ride turn rows. Every kind the writer produces must replay, and // one this build cannot read must cost the fact, never the row or the journal. @@ -91,7 +92,10 @@ describe('context facts on replayed turn rows', () => { it('replays every part and kind the writer produces, unchanged', async () => { const journal = await open() for (const [index, facts] of FACTS.entries()) { - await journal.appendItem(row(index), turn(`turn-${index}`, facts), { fence: 1 }) + await journal.appendItem(row(index), turn(`turn-${index}`, facts), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const written = journal.snapshot().items.map((item) => item.body) await journal.close() @@ -104,11 +108,14 @@ describe('context facts on replayed turn rows', () => { it('keeps a turn row whose facts it cannot read, and everything after it, minus the facts', async () => { const journal = await open() - await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { fence: 1 }) + await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendItem( row(1), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const opened = openJournalDatabase(journalDatabaseFile(root)) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts index b764ebd5ec9..f49dda6dcb6 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -39,12 +40,12 @@ describe('journal row schema versions', () => { await journal.appendItem( identity, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: 'session-1', recordId: 'turn-lifecycle:t1' }, agentJournalTurnBody({ turnId: 't1', state: 'running', startedAt: 1_000 }), - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const opened = openJournalDatabase(journalDatabaseFile(join(root, 'session-1'))) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 870b5259357..1d013255b21 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, parseJournalRow, @@ -277,7 +280,8 @@ describe('producer linkage on the persisted row', () => { seq: 1, fence: 1, ts: 1_700_000_000_000, - ...(withLinkage ? { linkage } : {}) + ...(withLinkage ? { linkage } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) const parsed = parseJournalRow(JSON.stringify(row)) return parsed.ok ? parsed.row : null @@ -348,10 +352,21 @@ describe('producer linkage on the persisted row', () => { it('round-trips a batch mutation that names its own producer, with no version bump', () => { const state = createJournalReducerState('session-1', 'epoch-1') - const own = { kind: 'item' as const, identity: { ...identity, uuid: 'u-own' }, body } + const own = { + kind: 'item' as const, + identity: { ...identity, uuid: 'u-own' }, + body, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } const build = (child: JournalLifecycleMutationInput) => journalLifecycleBatchRowBuilder(() => state, 'settle-1', [child, own], { fence: 1 })(1, 1) - const row = build({ kind: 'item', identity, body, linkage }) + const row = build({ + kind: 'item', + identity, + body, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const parsed = parseJournalRow(JSON.stringify(row)) const mutations = parsed.ok && parsed.row.kind === 'lifecycle-batch' ? parsed.row.mutations : [] @@ -359,7 +374,9 @@ describe('producer linkage on the persisted row', () => { expect(mutations[1] && 'agentId' in mutations[1]).toBe(false) // The same batch without the stamp writes the same version: an older host // ignores the unknown keys rather than latching the journal read-only. - expect(row.v).toBe(build({ kind: 'item', identity, body }).v) + expect(row.v).toBe( + build({ kind: 'item', identity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE }).v + ) }) it('keeps a batch mutation but drops its unusable producer id', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 786d422876c..0a4c6317ebc 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -12,6 +12,7 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { @@ -37,6 +38,10 @@ type JournalRowBase = AgentJournalProducerLinkage & { ts: number /** Set when crash reconciliation appended the row after the fact. */ recovered?: true + /** Which turn the item this row creates belongs to. Rides the base, and is not a `v` bump, + * for the reason linkage does. Absent on rows from hosts that predate it: the reducer + * derives one for them on read. */ + turnScope?: AgentJournalTurnScope } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -90,6 +95,8 @@ export type JournalDispatchRow = JournalRowBase & { /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null + /** On `pending`: the turn the message was handed into, which becomes its row's scope. */ + turnScope?: AgentJournalTurnScope /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact @@ -105,6 +112,7 @@ export type JournalLifecycleMutation = itemId: string revision: number body: AgentJournalItemBody + turnScope?: AgentJournalTurnScope }) | { kind: 'tombstone'; itemId: string; revision: number } @@ -170,10 +178,12 @@ export function parseJournalRow(line: string): JournalRowParse { } const upcast = upcastRow(record, version) dropUnusableProducerLinkage(upcast) + dropUnusableTurnScope(upcast) if (upcast.kind === 'lifecycle-batch' && Array.isArray(upcast.mutations)) { for (const mutation of upcast.mutations) { if (isPlainObject(mutation)) { dropUnusableProducerLinkage(mutation) + dropUnusableTurnScope(mutation) } } } @@ -202,6 +212,24 @@ function dropUnusableProducerLinkage(record: Record): void { } } +/** A scope this build cannot place, removed like unusable linkage: the row then reads as one + * written before scopes existed, and the reducer derives its scope. */ +function dropUnusableTurnScope(record: Record): void { + const scope = record.turnScope + if ( + scope !== undefined && + !( + isPlainObject(scope) && + (scope.kind === 'thread' || + (scope.kind === 'turn' && + typeof scope.turnItemId === 'string' && + scope.turnItemId.length > 0)) + ) + ) { + delete record.turnScope + } +} + /** Context facts this build cannot read, removed from the turn row that carries * them. Same reasoning as linkage: they are an annotation on the turn, and * rejecting the row for them would truncate the journal from that row on. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts index af53cd16d3d..5ab255749c1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // `close()`: enqueue-time admission, and the fulfilled/rejected split. // // The two failures this file exists to prevent: an append enqueued in the same @@ -80,7 +81,10 @@ afterEach(async () => { describe('closed-state admission happens at enqueue', () => { it('completes a write enqueued in the same turn as the close', async () => { const journal = await openJournal() - const append = journal.appendItem(item(1), body('before'), { fence: 1 }) + const append = journal.appendItem(item(1), body('before'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const closed = journal.close() await expect(append).resolves.toBeDefined() @@ -92,7 +96,10 @@ describe('closed-state admission happens at enqueue', () => { it('refuses a write offered while the close is still in flight, without queueing it', async () => { const journal = await openJournal() const closing = journal.close() - const refused = journal.appendItem(item(1), body('during'), { fence: 1 }) + const refused = journal.appendItem(item(1), body('during'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) // The rejection is available before the close step has run: it never joined // the queue, so nothing is ever chained behind a close. @@ -109,7 +116,12 @@ describe('closed-state admission happens at enqueue', () => { (error: unknown) => error ) const refusals = [ - settle(journal.appendItem(item(1), body('after'), { fence: 1 })), + settle( + journal.appendItem(item(1), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ), settle(journal.appendTombstone(item(3), { fence: 1 })), settle( journal.appendSubmission({ @@ -133,7 +145,14 @@ describe('closed-state admission happens at enqueue', () => { journal.appendLifecycleBatch({ settlementId: 'settle', fence: 1, - mutations: [{ kind: 'item', identity: item(4), body: body('x') }] + mutations: [ + { + kind: 'item', + identity: item(4), + body: body('x'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] }) ), settle(journal.rollEpoch('handle_forked', 1)), @@ -148,12 +167,20 @@ describe('closed-state admission happens at enqueue', () => { // caller's own turn, so a refusal never advances the queue. it('rejects without waiting for the queue to advance', async () => { const journal = await openJournal() - const inFlight = journal.appendItem(item(1), body('admitted'), { fence: 1 }) + const inFlight = journal.appendItem(item(1), body('admitted'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const closing = journal.close() // Settles while the admitted append is still running: it reached the gate in // the caller's own turn and never joined the queue behind it. - await expect(journal.appendItem(item(2), body('later'), { fence: 1 })).rejects.toMatchObject({ + await expect( + journal.appendItem(item(2), body('later'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) await expect(inFlight).resolves.toBeDefined() @@ -175,7 +202,10 @@ describe('closed-state admission happens at enqueue', () => { describe('a rejected close is a real retry', () => { it('retries the release, releases the handle, and then goes terminal', async () => { const journal = await openJournal() - await journal.appendItem(item(1), body('durable'), { fence: 1 }) + await journal.appendItem(item(1), body('durable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const injected = injectReleaseFailure(journal) await expect(journal.close()).rejects.toThrow('injected release failure') @@ -183,7 +213,12 @@ describe('a rejected close is a real retry', () => { // Write-closed anyway: retry exists to release the OS handle, never to // resurrect the store. - await expect(journal.appendItem(item(2), body('after'), { fence: 1 })).rejects.toMatchObject({ + await expect( + journal.appendItem(item(2), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c2210efb253..8785be0dc8c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -4,8 +4,9 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, - AgentJournalProducerLinkage, AgentJournalResetReason, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { JournalLoad } from './journal-open' @@ -29,14 +30,18 @@ export type ResolveDispatchInput = { clientMessageId: string fence: number recovered?: true -} & ( - | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } - | { state: 'pending' } - /** `reason` is what released clients print, `rejection` what newer ones read: both from - * `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) - | { state: 'unknown'; reason?: string | null } -) +} & + /** A null identity: the provider took the message without echoing an item of its own, as a + * conversation command it carries out in place. */ + ( + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity | null } + /** The turn the message is handed into — the live root turn, or `thread` when none runs. */ + | { state: 'pending'; turnScope: AgentJournalTurnScope } + /** `reason` is what released clients print, `rejection` what newer ones read: both from + * `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason?: string | null } + ) export type JournalAppendResult = { cursor: AgentJournalCursor @@ -44,7 +49,7 @@ export type JournalAppendResult = { revision: number } -export type JournalItemAppendOptions = AgentJournalProducerLinkage & { +export type JournalItemAppendOptions = AgentJournalRowAttribution & { fence: number observedAt?: number recovered?: true diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index fc69dd339d8..1b6f3a260a3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -6,6 +6,7 @@ // same host the collaborators use, so the store keeps the state and this owns // the sequence. +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' import { replayJournal } from './journal-open' import type { JournalStoreHost } from './journal-store-collaborators' @@ -39,7 +40,9 @@ export function restoreJournalStore( publishRepairEpoch: () => collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence), adopt: host.adopt, - appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }), + // Repair and file-format notices are about the conversation, not any turn in it. + appendItem: (identity, body, fence) => + host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), agent: host.identity.agent, highestFence: () => host.state().highestFence, malformedRows: host.malformedRows, diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index df34ef73e73..daa2e819647 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Two independent version axes, both fail closed. // // `PRAGMA user_version` the DB SHAPE, known before the first read @@ -86,14 +87,19 @@ afterEach(async () => { describe('axis 1: the database shape', () => { it('latches read-only on a newer user_version and writes nothing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) const before = await stat(journalDatabaseFile(root)) const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) // The file this build must not touch is byte-identical afterwards. @@ -120,7 +126,10 @@ describe('axis 1: the database shape', () => { it('migrates an older user_version forward on reopen', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma('user_version = 0')) @@ -137,7 +146,10 @@ describe('axis 1: the database shape', () => { describe('axis 2: the row body shape', () => { it('degrades to read-only on a row from a newer build, without skipping it', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -159,7 +171,9 @@ describe('axis 2: the row body shape', () => { const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) await reopened.close() @@ -174,7 +188,10 @@ describe('axis 2: the row body shape', () => { it('skips a malformed row without giving up the journal, and discloses the skip', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -195,7 +212,10 @@ describe('axis 2: the row body shape', () => { it('keeps one disclosure row across reopens instead of stacking duplicates', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -214,7 +234,10 @@ describe('axis 2: the row body shape', () => { it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 3637f03dab6..67eb19f8cbe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -76,7 +77,10 @@ describe('sequences', () => { const journal = await open() const results = await Promise.all( Array.from({ length: 25 }, (_unused, index) => - journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) ) const sequences = results.map((result) => result.cursor.sequence) @@ -89,9 +93,9 @@ describe('sequences', () => { it('serializes revisions of one item so the last write wins deterministically', async () => { const journal = await open() const results = await Promise.all([ - journal.appendItem(item(0), body('a'), { fence: 1 }), - journal.appendItem(item(0), body('b'), { fence: 1 }), - journal.appendItem(item(0), body('c'), { fence: 1 }) + journal.appendItem(item(0), body('a'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('c'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ]) expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) expect(journal.snapshot().items).toHaveLength(1) @@ -100,9 +104,18 @@ describe('sequences', () => { it('visits reduced items at their creation sequence without promoting an older revision', async () => { const journal = await open() - await journal.appendItem(item(0), body('first'), { fence: 1 }) - const latest = await journal.appendItem(item(1), body('second'), { fence: 1 }) - await journal.appendItem(item(0), body('first revised'), { fence: 1 }) + await journal.appendItem(item(0), body('first'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const latest = await journal.appendItem(item(1), body('second'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('first revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const visited: { itemId: string; sequence: number }[] = [] journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) @@ -131,23 +144,26 @@ describe('sequences', () => { await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - await journal.appendItem(item(0), body('work'), { fence: 1 }) + await journal.appendItem(item(0), body('work'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await bothAgreeOn('turn-1') // The completion is a revision, so it keeps the row's creation sequence rather than moving it. await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'completed' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn(null) await journal.appendItem( turnItem('turn-2'), { kind: 'turn', turnId: 'turn-2', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn('turn-2') }) @@ -165,8 +181,14 @@ describe('sequences', () => { const mimicIdentity = identityFor(digestFormMimic) const journal = await open() - const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { fence: 1 }) - const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { fence: 1 }) + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(oversized.itemId).not.toBe(mimic.itemId) expect([oversized.revision, mimic.revision]).toEqual([1, 1]) @@ -187,17 +209,28 @@ describe('sequences', () => { describe('fences', () => { it('rejects an append from a writer behind the journal', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await expect(journal.appendItem(item(1), body('b'), { fence: 6 })).rejects.toBeInstanceOf( - AgentSessionJournalError - ) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect( + journal.appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toBeInstanceOf(AgentSessionJournalError) }) it('keeps accepting appends after a rejected one', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await journal.appendItem(item(1), body('b'), { fence: 6 }).catch(() => undefined) - await journal.appendItem(item(2), body('c'), { fence: 7 }) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal + .appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + .catch(() => undefined) + await journal.appendItem(item(2), body('c'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) }) }) @@ -205,7 +238,10 @@ describe('fences', () => { describe('replay', () => { it('adopts a caller-provided load without replaying the rows again', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const loaded = await loadJournal(root, IDENTITY.sessionId) expect(loaded).not.toBeNull() await journal.close() @@ -216,9 +252,18 @@ describe('replay', () => { it('reopens to the same render model the live writer held', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) - await journal.appendItem(item(1), body('b'), { fence: 1 }) - await journal.appendItem(item(0), body('a2'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('a2'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendTombstone(item(1), { fence: 1 }) const live = journal.snapshot() @@ -228,9 +273,15 @@ describe('replay', () => { it('serves a resume from a cursor and refuses one from a stale epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const cursor = journal.cursor() - await journal.appendItem(item(1), body('b'), { fence: 1 }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const resumed = journal.readSince(cursor) expect(resumed.ok && resumed.rows).toHaveLength(1) @@ -241,7 +292,10 @@ describe('replay', () => { it('rebuilds from a clean epoch after a rollover', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.rollEpoch('unreconcilable_prefix', 2) expect(journal.snapshot().items).toHaveLength(0) @@ -253,7 +307,10 @@ describe('replay', () => { it('keeps the intact prefix and drops the rejected suffix', async () => { const journal = await open() for (let index = 0; index < 4; index += 1) { - await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + await journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const before = journal.epoch await journal.close() @@ -306,7 +363,14 @@ describe('lifecycle batches', () => { const input = { settlementId: 'concurrent-settlement', fence: 1, - mutations: [{ kind: 'item' as const, identity: item(1), body: body('settled') }] + mutations: [ + { + kind: 'item' as const, + identity: item(1), + body: body('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] } const [first, replay] = await Promise.all([ @@ -325,17 +389,27 @@ describe('lifecycle batches', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const settled = await journal.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('tool settled') }, + { + kind: 'item', + identity: item(1), + body: body('tool settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'item', identity: { provider: 'orca', clientMessageId: 'exit-status' }, - body: { kind: 'status', text: 'Provider exited' } + body: { kind: 'status', text: 'Provider exited' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }, { kind: 'tombstone', identity: turn } ] @@ -356,7 +430,14 @@ describe('lifecycle batches', () => { const replay = await reopened.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, - mutations: [{ kind: 'item', identity: item(9), body: body('must not appear') }] + mutations: [ + { + kind: 'item', + identity: item(9), + body: body('must not appear'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] }) expect(replay).toEqual(beforeReplay) expect( @@ -397,7 +478,10 @@ describe('journal location', () => { describe('on-disk layout', () => { it('keeps the session database and its projection in one directory', async () => { const journal: AgentSessionJournal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(await readdir(root)).toContain('journal.db') await journal.close() await withJournalDatabase(root, (db) => { @@ -436,7 +520,10 @@ describe('what a handle found on disk when it opened', () => { it('names rows an earlier handle wrote, and never a row of a later epoch', async () => { const earlier = await open() - await earlier.appendItem(item(1), body('one'), { fence: 1 }) + await earlier.appendItem(item(1), body('one'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await earlier.appendSubmission(submission('earlier')) await earlier.close() diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3be3aebbc51..99ec29b34ec 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -11,6 +11,7 @@ import type { AgentJournalSubmission, AgentJournalThreadGoal, AgentJournalTurnLifecycle, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -19,6 +20,7 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' import { activeStructuredAgentSessionTurnIdBySequence, + liveStructuredAgentSessionTurnScope, newestStructuredAgentSessionTurnBySequence } from '../../../shared/structured-agent-session-live-turn' import { agentSessionJournalCloseRetries } from './journal-close-retry' @@ -219,6 +221,10 @@ export class AgentSessionJournal { activeTurnId = (): string | null => activeStructuredAgentSessionTurnIdBySequence(this.state.items.values()) + /** Where a row written now belongs: the running turn, or the conversation. */ + liveTurnScope = (): AgentJournalTurnScope => + liveStructuredAgentSessionTurnScope(this.state.items.values()) + /** The newest turn record whatever state it settled in, for readers that need the outcome. */ newestTurn = (): AgentJournalTurnLifecycle | null => newestStructuredAgentSessionTurnBySequence(this.state.items.values()) @@ -273,7 +279,7 @@ export class AgentSessionJournal { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions = { fence: 0 } + options: JournalItemAppendOptions ): Promise { return this.itemAppender.append(identity, body, options) } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index b0dff559b1a..1fea8b9efa5 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -222,7 +223,10 @@ describe('journal reopen after the writing host is gone', () => { { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, { id: 'b', label: 'read_package', state: 'working', startedAt: 10 } ]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) // Still the writing host: it can see the children, so the row says so. const beforeRestart = live.snapshot().items.at(-1)! @@ -245,7 +249,10 @@ describe('journal reopen after the writing host is gone', () => { it('revises the row in place rather than appending a second one', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const before = live.snapshot().items.length await live.close() @@ -257,7 +264,10 @@ describe('journal reopen after the writing host is gone', () => { it('settles a persisted working background task to unverifiable', async () => { const live = await open() const row = backgroundTaskRow() - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const beforeRestart = live.snapshot().items.at(-1)! expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' }) expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"') @@ -273,7 +283,10 @@ describe('journal reopen after the writing host is gone', () => { it('writes nothing on a second reopen once every child is settled', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await live.close() const once = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts new file mode 100644 index 00000000000..fa5283d48e6 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -0,0 +1,97 @@ +// Folding submission rows: the queue entry, its message row, where a handover places it, and the +// provider item an accepted message adopts. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { journalRenderItem } from './journal-render-item' +import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +export function applyJournalSubmission( + state: JournalReducerState, + row: Extract +): void { + state.submissions.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null, + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) + }) + const itemId = agentJournalSubmissionKey(row.clientMessageId) + // A message handed over later belongs to no turn until its handover names one. + const turnScope = row.handoverRecorded + ? AGENT_JOURNAL_THREAD_SCOPE + : statedOrDerivedTurnScope(state, row) + upsertJournalItem( + state, + itemId, + 0, + journalRenderItem(itemId, 0, row.body, row, turnScope), + row.fence + ) +} + +/** A queued message joins the conversation where it was handed over, not where it was accepted: + * what the agent did meanwhile — a command it waited behind, say — happened before it. It joins + * the turn that handover delivered it into — a steer — or none. Rows from hosts that predate the + * stated scope are scoped at the handover, as their creation would have been. */ +export function placeHandedOverMessage( + state: JournalReducerState, + submission: AgentJournalSubmission, + row: Extract +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + if (!submission.handoverRecorded || !item) { + return + } + const { sequenceIndex: _acceptedAt, ...accepted } = item + state.items.set(itemId, { + ...accepted, + sequence: row.seq, + observedAt: row.ts, + turnScope: row.turnScope ?? state.derivedTurnScope.scopeFor(item.body) + }) +} + +export function acceptSubmissionFromProviderItem( + state: JournalReducerState, + providerItemId: string, + resolvedItemId: string, + row: Pick +): void { + if (providerItemId === resolvedItemId) { + return + } + const submission = [...state.submissions.values()].find( + (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId + ) + if ( + !submission || + submission.dispatchState === 'accepted' || + submission.dispatchState === 'rejected' + ) { + return + } + submission.fence = row.fence + submission.dispatchState = 'accepted' + submission.providerItemId = providerItemId + submission.reason = null + submission.resolvedAt = row.ts + delete submission.recovered + state.receipts.set(submission.clientMessageId, { + clientMessageId: submission.clientMessageId, + providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts new file mode 100644 index 00000000000..6842c119f73 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} 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 { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import { + buildJournalItemRow, + buildJournalSubmissionRow, + journalRowBase +} from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' + +const TURN_1: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-1' } +const TURN_2: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-2' } +const COMMAND_TURN: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const inTurn = (identity: AgentJournalItemIdentity): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: agentJournalItemKey(identity) +}) + +function row(id: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: id } +} + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function turn(turnId: string, state: 'running' | 'completed'): AgentJournalItemBody { + return { kind: 'turn', turnId, state, startedAt: 1 } +} + +/** Builds rows against a scratch state, so the same list can be folded more than once. */ +function rows() { + const state = createJournalReducerState('session-1', 'epoch-1') + const built: JournalRow[] = [] + let seq = 0 + const push = (next: JournalRow): void => { + built.push(next) + applyJournalRow(state, next) + } + return { + state, + built, + item( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + scope?: AgentJournalTurnScope + ) { + seq += 1 + const next = buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + turnScope: scope ?? AGENT_JOURNAL_THREAD_SCOPE + }) + if (!scope) { + // A row from a host that predates stated scopes. + delete next.turnScope + } + push(next) + }, + submission(clientMessageId: string) { + seq += 1 + push( + buildJournalSubmissionRow({ + state, + clientMessageId, + payloadFingerprint: `fp-${clientMessageId}`, + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + seq, + fence: 1, + ts: 1_000 + seq, + handoverRecorded: true + }) + ) + }, + handover(clientMessageId: string, scope?: AgentJournalTurnScope) { + seq += 1 + // Built by hand: a row from an older host carries no scope, which the builder never writes. + push({ + kind: 'dispatch', + clientMessageId, + state: 'pending', + providerItemId: null, + reason: null, + ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), + ...(scope ? { turnScope: scope } : {}) + }) + } + } +} + +function scopeOf(state: JournalReducerState, identity: AgentJournalItemIdentity) { + return state.items.get(agentJournalItemKey(identity))?.turnScope +} + +function submissionScope(state: JournalReducerState, clientMessageId: string) { + return state.items.get(agentJournalSubmissionKey(clientMessageId))?.turnScope +} + +function refold(built: readonly JournalRow[]) { + const state = createJournalReducerState('session-1', 'epoch-1') + for (const next of built) { + applyJournalRow(state, next) + } + return renderJournalState(state) +} + +describe('stated turn scope', () => { + it('keeps the scope of the write that created the row', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + item(row('answer'), prose('first'), inTurn(TURN_1)) + // A revision stating another scope only updates the content. + item(row('answer'), prose('second'), AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('answer'))).toEqual(inTurn(TURN_1)) + }) + + it('places a queued message only when its handover states a turn', () => { + const { state, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('steer') + submission('later') + // Accepted while a turn runs, but not yet delivered into it. + expect(submissionScope(state, 'steer')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + handover('steer', inTurn(TURN_1)) + handover('later', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + expect(submissionScope(state, 'later')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a message held behind a command out of the command turn', () => { + const { state, item, submission, handover } = rows() + submission('cmd-1') + handover('cmd-1', AGENT_JOURNAL_THREAD_SCOPE) + item(COMMAND_TURN, turn('compact:cmd-1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('held') + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + item(row('result'), prose('compacted'), inTurn(COMMAND_TURN)) + item(COMMAND_TURN, turn('compact:cmd-1', 'completed'), AGENT_JOURNAL_THREAD_SCOPE) + const drawn = (messages: readonly { id: string }[]) => + messages + .map((message) => message.id) + .filter((id) => id === agentJournalItemKey(row('result')) || id.includes('held')) + const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] + const onPhone = () => { + const { items, submissions } = renderJournalState(state) + return projectStructuredAgentSessionMessages(items, [], submissions) + } + // Still waiting: drawn after everything the agent did, the command's result included. + expect(drawn(onPhone())).toEqual(expected) + handover('held', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + // Delivered: placed where it was handed over, by sequence for paging and the phone, and by + // timestamp for the desktop transcript. + expect(drawn(onPhone())).toEqual(expected) + expect(drawn(projectNativeChatTranscriptMessages(onPhone()))).toEqual(expected) + }) +}) + +describe('scope derived for rows stored without one', () => { + it('places rows inside a live span and closes it at the terminal revision', () => { + const { state, item } = rows() + item(row('before'), prose('before')) + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(scopeOf(state, row('before'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, TURN_1)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('inside'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a turn rebuilt already settled open until the next turn record', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'completed')) + item(row('first'), prose('first')) + item(TURN_2, turn('t2', 'completed')) + item(row('second'), prose('second')) + expect(scopeOf(state, row('first'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('second'))).toEqual(inTurn(TURN_2)) + }) + + it('closes a legacy /compact carrier where its turn body is overwritten, and shows the carrier', () => { + const { state, item } = rows() + const carrier = row('compact:op-1') + item(carrier, { + kind: 'status', + text: 'Compacting conversation…', + turnLifecycle: { turnId: 'compact:op-1', state: 'running' } + }) + item(row('summary'), prose('summary')) + item(carrier, { kind: 'status', text: 'Conversation compacted.' }) + item(row('after'), prose('after')) + expect(scopeOf(state, carrier)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('summary'))).toEqual(inTurn(carrier)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('lands rows written after a crash left a turn running in that turn', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('crash-window'), prose('written before the sweep')) + expect(scopeOf(state, row('crash-window'))).toEqual(inTurn(TURN_1)) + }) + + it('derives the same scopes on replay as live', () => { + const { state, built, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + submission('steer') + handover('steer') + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + expect(refold(built)).toEqual(renderJournalState(state)) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts index 5a859fb2e81..1ab1089d494 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createCodexStructuredItemStreams } from '../../codex/codex-structured-item-streams' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' @@ -9,7 +10,7 @@ describe('empty streamed deltas', () => { sink: { appendItem() {}, appendTombstone() {}, publish() {} }, turnIdFor: () => 'turn', identityFor: () => ({ provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 }), - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: () => () => {} }) const append = (delta: string) => diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index 17bdf710490..f13d41373d7 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -40,7 +41,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number, text: string): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index cda878f5a01..97ce6ecd216 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -12,7 +12,10 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, @@ -61,7 +64,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -216,7 +222,10 @@ describe('history page byte ceiling', () => { async function appendLargeItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -300,7 +309,10 @@ describe('history page byte ceiling', () => { }) it('degrades a single over-budget item to a visible truncation marker instead of overflowing', async () => { - await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { fence: 1 }) + await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const tail = pageOf( readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) @@ -320,14 +332,28 @@ describe('projectJournalBatch', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const cursor = journal.cursor() await journal.appendLifecycleBatch({ settlementId: 'settlement-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('one') }, - { kind: 'item', identity: item(2), body: body('two') }, + { + kind: 'item', + identity: item(1), + body: body('one'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: item(2), + body: body('two'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'tombstone', identity: turn } ] }) @@ -371,7 +397,10 @@ describe('projectJournalBatch', () => { it('publishes touched items at their current reduced state, not as a delta', async () => { await appendItems(1) const cursor = journal.cursor() - await journal.appendItem(item(1), body('revised'), { fence: 1 }) + await journal.appendItem(item(1), body('revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const since = journal.readSince(cursor) if (!since.ok) { throw new Error(`expected rows, got reset ${since.reset}`) @@ -440,7 +469,7 @@ describe('projectJournalBatch', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'root-turn', ordinal: 2 }, message, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const page = readAgentSessionHistory(journal, { @@ -636,7 +665,10 @@ describe('identity bounding at admission', () => { ordinal: 1 } const start = { epoch: journal.epoch, sequence: journal.cursor().sequence } - const appended = await journal.appendItem(oversized, body('bounded'), { fence: 1 }) + const appended = await journal.appendItem(oversized, body('bounded'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(appended.itemId.length).toBeLessThan(2048) expect(appended.itemId).toContain('~orca-oversized~') diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 1c60029c7d3..df2330346ae 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Recovery drives the real journal loader against real on-disk damage: a hole // punched in the row sequence, and a row stamped with a schema this host cannot // read — on both version axes, because only one of them is detectable before a @@ -80,7 +81,7 @@ async function seedJournal(count: number): Promise { await journal.appendItem( item(ordinal), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } const epoch = journal.epoch @@ -290,7 +291,8 @@ describe('openAgentSessionJournalWithRecovery', () => { { kind: 'item', identity: { provider: 'orca', clientMessageId: 'approval-1' }, - body: { kind: 'status', text: 'approved' } + body: { kind: 'status', text: 'approved' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE } ] }) @@ -405,7 +407,7 @@ describe('openAgentSessionJournalWithRecovery', () => { await reopened.journal.appendItem( item(2), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'typed later' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const epoch = reopened.journal.epoch await reopened.journal.close() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index fb4ee607e38..98a6f7e42dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A send is accepted, then delivered: the host answers once the message is recorded, and the // session's delivery loop starts a provider child for it and hands it over. Against the real host, // store and journal; each assertion reads what an open chat or the journal's next reader sees. @@ -545,7 +546,12 @@ describe('what an earlier host process left behind', () => { await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) - await journal.resolveDispatch({ clientMessageId: 'handed', state: 'pending', fence }) + await journal.resolveDispatch({ + clientMessageId: 'handed', + state: 'pending', + fence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) }) await host.flushAllStreamedEvents() await startHost() @@ -819,7 +825,7 @@ describe('a compaction or rewind an earlier child left prepared', () => { await startHost() } - it('settles an interrupted compaction at open, so a send is accepted and delivered (R16)', async () => { + it("ignores an older build's interrupted compaction, so a send is accepted and delivered (R16)", async () => { await leftPrepared((fence) => store.setConversationCommand(SESSION, fence, { command: 'compact', @@ -834,10 +840,8 @@ describe('a compaction or rewind an earlier child left prepared', () => { const id = await accept('after the compaction') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - state: 'unknown' - }) + // Nothing settles the record: it belongs to a child this host no longer runs. + expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ phase: 'prepared' }) }) it('completes a rewind the provider already applied at open, so a send is accepted (R16)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e4ef8eb85f8..0515f7ed669 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -15,6 +15,7 @@ import type { AgentJournalItemBody, AgentJournalMessageItem, AgentJournalDispatchState, + AgentJournalTurnItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' @@ -41,7 +42,6 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { @@ -145,6 +145,23 @@ export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessi return error instanceof Error && error.name === 'AgentSessionPreSpawnError' } +/** A conversation command the host handed to a provider child: the running turn it opened for it, + * which the child's translator ends, and where the command's own result row goes. */ +export type StructuredAgentSessionCommandRun = { + clientMessageId: string + /** What a Stop names. */ + turnId: string + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The turn record as the host wrote it; the translator's end revises it. */ + running: AgentJournalTurnItem +} + +/** A command the provider took answers it in place and echoes no item of its own. */ +export type AgentSessionCommandAdmission = + | AgentSessionDispatchOutcome + | { state: 'accepted'; providerIdentity: null } + export type AgentSessionDispatchOutcome = /** The provider owns the turn now, under this identity. */ | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } @@ -274,12 +291,14 @@ export type StructuredAgentSessionAdapter = { | { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } | { ok: false; reason: AgentSessionRewindReason } > + /** Sends a conversation compaction and answers with the provider's receipt of it, as a dispatch + * does. The command's turn is the child's journal translator's to end, from the provider's own + * frames; a child that ends first leaves it to the host's settlement of that child. */ compact?(input: { - turnId: string sessionId: string fence: number - onLateResult?: (result: StructuredSessionCompactionResult) => Promise - }): Promise + command: StructuredAgentSessionCommandRun + }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill * a turn the client never asked to stop. With no `turnId` the conversation asked to stop * everything it has in flight — a running turn, or a dispatch whose turn has not opened yet. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 4b8e1d87ba3..371b942c2f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Source validation must finish before a new session claims the provider conversation. import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' @@ -162,7 +163,7 @@ describe('adopting a provider conversation on create', () => { await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 55128a23c9c..dbb3a63e7b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // What an open chat receives, asserted at its subscriber rather than in the journal: a fresh // subscribe re-reads the journal and hides a write that never reached the readers already open. @@ -176,7 +177,11 @@ describe('an open chat receives every row its journal commits', () => { const identity = { provider: 'orca' as const, clientMessageId: 'context-usage' } const body = { kind: 'status' as const, text: 'context usage answered after the turn' } - expect(providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }))).toEqual({ + expect( + providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) await host.flushStreamedEvents(SESSION) @@ -194,7 +199,10 @@ describe('an open chat receives every row its journal commits', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'host-note' }, { kind: 'status', text: 'written by a writer that publishes nothing' }, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) expect(pane.received().statuses).toEqual(['written by a writer that publishes nothing']) @@ -213,7 +221,8 @@ describe('an open chat receives each row once', () => { sink.appendItem( { provider: 'orca', clientMessageId: 'streamed' }, - { kind: 'status', text: 'streamed row' } + { kind: 'status', text: 'streamed row' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) sink.publish() await host.flushStreamedEvents(SESSION) @@ -233,7 +242,10 @@ describe('an open chat receives each row once', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'host-row' }, { kind: 'status', text: 'host row' }, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) host['subscribers'].publish(SESSION, journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 40804bf55e2..6fc715022aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,6 +1,5 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' -import { recoverInterruptedCompaction } from './structured-compaction-recovery' // The host's attach, lifted out of the host class. // // Attach is the one operation that touches every collaborator the host owns — the lease @@ -210,7 +209,6 @@ async function runAttach( context.deps.adapter, context.now ) - await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) if (fenceBefore !== null && fence !== fenceBefore) { context.subscribers.snapshot(sessionId, attached.journal, fence) } else { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts new file mode 100644 index 00000000000..61e3f7ef3ad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -0,0 +1,291 @@ +// Stop and `/compact` on the shipping Claude adapter, driven by the frames a stream-json child +// writes: which result ends the command, and what the next message is handed to. + +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 { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { ClaudeControlRequestError } from '../../claude/claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +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' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let claude: ReturnType +/** The adapter's clock, which a real child's exit reads at a different instant than the host's. */ +let adapterNow = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-compact-stop-')) + resetHostTestOperationIds() + adapterNow = NOW + 7 + // No echo of its own: each test writes the frames Claude would. + claude = fakeClaude({ replayUuid: null }) + 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) { + void host.handleAdapterEvent(mapped) + } + }, + // The runtime's own wiring: the provider's answer is what settles a send it took. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => adapterNow + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + // The production router is what declares create support; the bare adapter only knows locations. + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const attached = await host.attach( + CALLER, + 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(attached).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function compact(): Promise { + const params = { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } + await expect(host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + return params.envelope.clientOperationId +} + +function stop(cmid: string) { + const { turnId } = structuredAgentSessionCommandTurn(cmid) + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +/** The uuid of the frame that carried `text` to Claude, and the child it went to. */ +async function sent(text: string): Promise<{ connection: FakeConnection; uuid: string }> { + return vi.waitFor(() => { + for (const connection of claude.connections) { + const frame = connection.sent.find((message) => JSON.stringify(message).includes(text)) + if (frame) { + return { connection, uuid: String(frame.uuid) } + } + } + throw new Error(`nothing sent ${text}`) + }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function result(uuid: string, overrides: Record = {}): Record { + return { + type: 'result', + subtype: 'success', + is_error: false, + uuid: `result-${uuid}`, + user_message_uuid: uuid, + ...overrides + } +} + +const INTERRUPTED = { + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming' +} + +async function commandState(cmid: string) { + await host.flushStreamedEvents(SESSION) + const { itemId } = structuredAgentSessionCommandTurn(cmid) + const snapshot = await host.journalSnapshot(SESSION) + return readAgentJournalTurn(snapshot.items.find((item) => item.itemId === itemId)?.body) +} + +it('ends a hung /compact Claude will not interrupt by stopping it, and answers the next send in its own turn', async () => { + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'Claude did not answer the interrupt.') + } + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + + await expect(stop(cmid)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + + const body = hostTestMessage('what next?') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const { connection, uuid } = await sent('what next?') + expect(compacting.closed).toBe(true) + expect(connection).not.toBe(compacting) + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'what next?' }] } + }) + frame(connection, { + type: 'assistant', + uuid: 'answer', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Here is.' }] } + }) + frame(connection, result(uuid)) + + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + const answer = snapshot.items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(answer?.turnScope).toMatchObject({ kind: 'turn' }) + expect(answer?.turnScope).not.toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) + +it('ends a stopped /compact on its own interrupted result, and a late copy of that result does not end the next one', async () => { + claude.routes.interrupt = () => ({}) + const first = await compact() + const { connection, uuid: firstUuid } = await sent('/compact') + + await expect(stop(first)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + // The interrupt was taken: the command runs until Claude answers it. + expect((await commandState(first))?.state).toBe('running') + frame(connection, result(firstUuid, INTERRUPTED)) + await vi.waitFor(async () => + expect(await commandState(first)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + + const second = await compact() + await vi.waitFor(() => + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('/compact')) + ).toHaveLength(2) + ) + const secondUuid = String(connection.sent.at(-1)?.uuid) + frame(connection, result(firstUuid, INTERRUPTED)) + frame( + connection, + result('an-earlier-send', { subtype: 'error_during_execution', is_error: true }) + ) + expect((await commandState(second))?.state).toBe('running') + + frame(connection, { type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame(connection, result(secondUuid)) + await vi.waitFor(async () => + expect(await commandState(second)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) +}) + +it('settles a /compact whose Claude child exits mid-command through that child, and delivers what waited', async () => { + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + const body = hostTestMessage('after the exit') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + // Claude sends no end frame at all: its child is gone. + compacting.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + const { connection } = await sent('after the exit') + expect(connection).not.toBe(compacting) +}) + +it('tells why a /compact ended when its Claude child exits after taking it', async () => { + const cmid = await compact() + const { connection, uuid } = await sent('/compact') + // Claude echoes the command, so the send is answered; then the child dies with no end frame. + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: '/compact' }] } + }) + await host.flushStreamedEvents(SESSION) + adapterNow = NOW + 7 + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1)')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + // The exit's words are the host's sentence, never Claude's log text. + const exitRow = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerExited' + ) + expect(exitRow?.body).toMatchObject({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + }) + expect(exitRow?.turnScope).toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts index f5410de74f6..74d5355edd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A close that REJECTED did not release the handle. // // `AgentSessionJournal.close()` is retryable by design: the release step is @@ -192,7 +193,8 @@ describe('the attach orchestration', () => { { kind: 'status', text: 'still writable' - } + }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) ).resolves.toBeDefined() expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) @@ -229,7 +231,8 @@ describe('the attach orchestration', () => { { kind: 'status', text: 'still writable' - } + }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) ).resolves.toBeDefined() expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts new file mode 100644 index 00000000000..993ee3fd258 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -0,0 +1,191 @@ +// After `/compact`, every reader that reports on the user's requests reads past the command: the +// sidebar's prompt, preview, verdict and instant, restart resume, and an older client's label. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { latestStructuredAgentSessionRequest } from '../../../shared/structured-agent-session-latest-request' +import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectTurnItemHistory } from '../../runtime/rpc/methods/structured-agent-session-turn-item-capability' +import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' +import { + journal, + NOW, + record, + SESSION, + TEARDOWN_CURRENT +} from './structured-agent-session-restart-resume-test-harness' + +const PROMPT = agentJournalSubmissionKey('prompt-1') +const COMMAND = agentJournalSubmissionKey('command-1') +const REAL_TURN = 'legacy:codex:session:turn-lifecycle%3Aturn-1' +const COMMAND_TURN = 'orca:command-turn%3Acommand-1' + +let sequence = 0 +function item(itemId: string, body: AgentJournalRenderItem['body'], scope?: string) { + sequence += 1 + return { + itemId, + revision: 1, + body, + sequence, + observedAt: NOW, + turnScope: scope ? { kind: 'turn' as const, turnItemId: scope } : { kind: 'thread' as const } + } +} + +function accepted(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: NOW + } +} + +/** "List three fruits", answered, then `/compact` in its own turn. */ +function compactedAfterARealTurn(commandState: 'running' | 'completed' = 'completed') { + sequence = 0 + const items: AgentJournalRenderItem[] = [ + item(PROMPT, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'List three fruits' }] + }), + item(REAL_TURN, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + outcome: 'success', + userItemId: PROMPT, + startedAt: NOW, + completedAt: NOW + 1_000 + }), + item( + 'codex:answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Apple, pear, fig' }] }, + REAL_TURN + ), + item(COMMAND, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(COMMAND_TURN, { + kind: 'turn', + turnId: 'compact:command-1', + state: commandState, + userItemId: COMMAND, + startedAt: NOW + 5_000, + ...(commandState === 'completed' ? { outcome: 'success', completedAt: NOW + 9_000 } : {}) + }), + item( + 'codex:summary', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Summary of it all' }] }, + COMMAND_TURN + ), + item( + 'orca:command-result%3Acommand-1', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + COMMAND_TURN + ) + ] + return { items, submissions: [accepted('prompt-1'), accepted('command-1')] } +} + +describe('the sidebar after /compact (B7)', () => { + it('keeps the last real prompt, its answer, its verdict and its instant', () => { + const { items, submissions } = compactedAfterARealTurn() + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toEqual({ + status: 'idle', + latestPrompt: 'List three fruits', + lastAssistantMessage: 'Apple, pear, fig', + turnOutcome: 'success', + statusStartedAt: NOW + 1_000 + }) + }) + + it('reads working while the command runs', () => { + const { items, submissions } = compactedAfterARealTurn('running') + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toMatchObject({ + status: 'working', + latestPrompt: 'List three fruits' + }) + }) +}) + +describe('the completion feed around /compact (B6)', () => { + // The feed announces on a change of latest request; /compact running or settled leaves it be. + it.each(['running', 'completed'] as const)( + 'keeps the last real turn latest while it is %s', + (state) => { + const { items, submissions } = compactedAfterARealTurn(state) + expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ + kind: 'turn', + id: 'turn-1', + running: false, + outcome: 'success' + }) + } + ) +}) + +describe('restart resume around /compact (B14)', () => { + function markerFor(items: AgentJournalRenderItem[], tasks: boolean) { + return structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { journal: journal(items), child: { fence: 1 } }, + getRecord: () => record(), + backgroundTasks: () => + tasks ? [{ id: 'task-a', kind: 'agent', description: 'Review', state: 'working' }] : [], + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + } + + it('records nothing while the command runs', () => { + expect(markerFor(compactedAfterARealTurn('running').items, false)).toBeNull() + }) + + it('anchors a settled lead with live children on its last real turn', () => { + const marker = markerFor(compactedAfterARealTurn().items, true) + expect(marker?.work).toEqual({ kind: 'turn', id: 'turn-1' }) + // The newest user message is still the last real prompt, as before commands were messages. + expect(marker?.latestUserItemId).toBe(PROMPT) + }) +}) + +describe("an older client's label for a command turn (B17)", () => { + it("names the session's agent, not the one its key suggests", () => { + const { items, submissions } = compactedAfterARealTurn() + const history = { + ok: true as const, + page: { + sessionId: SESSION, + epoch: 'epoch-1', + direction: 'tail' as const, + items, + removedItemIds: [], + submissions, + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } + } + const projected = projectTurnItemHistory(history, { clientKind: 'runtime' }, 'claude') + const commandTurn = projected.page.items.find((entry) => entry.itemId === COMMAND_TURN) + expect(commandTurn?.body).toMatchObject({ + kind: 'status', + text: expect.stringContaining('Claude') + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts new file mode 100644 index 00000000000..cb83dba870a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -0,0 +1,320 @@ +// A conversation command the user sent, such as `/compact`, carried out as a turn of its own. +// +// The command is an ordinary queued message until the delivery loop hands it over. There the loop +// opens the command's turn and sends it; the provider's receipt resolves the message, as it does +// any send. The provider child's journal translator ends the turn from the provider's own frames, +// and a child that ends first is settled with it. The host writes a command's end only when the +// provider never took it. While the turn runs it takes no input, so the loop hands nothing over. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionAdapter, + StructuredAgentSessionProviderChildPhase +} from './structured-agent-session-adapter' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' + +/** What the user sent for `/compact`: the text they typed, and the command it names. */ +export function structuredAgentSessionCompactBody(): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND } + } +} + +type AwaitedSubmission = Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'acceptedSequence' +> + +/** Optional `submissions` as the dead-generation journal reads it. */ +export type StructuredAgentSessionAwaitedCommandJournal = { + submissions?: () => readonly AwaitedSubmission[] + itemBody: AgentSessionJournal['itemBody'] +} + +/** The command the oldest message still waiting on the provider names: a start that fails now + * fails that message first, so its next step is to run the command again. */ +export function structuredAgentSessionAwaitedCommand( + journal: StructuredAgentSessionAwaitedCommandJournal +): AgentSessionConversationCommand | undefined { + let oldest: AwaitedSubmission | undefined + for (const submission of journal.submissions?.() ?? []) { + if ( + submission.dispatchState === 'pending' && + (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) + ) { + oldest = submission + } + } + const body = oldest && journal.itemBody(agentJournalSubmissionKey(oldest.clientMessageId)) + return body?.kind === 'message' && body.command?.name === STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + ? STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + : undefined +} + +/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the + * host's Stop and delivery gate tell a command's turn from any other. */ +export function structuredAgentSessionCommandTurn(clientMessageId: string): { + identity: AgentJournalItemIdentity + itemId: string + turnId: string + /** The command's one result row, inside its turn. */ + resultIdentity: AgentJournalItemIdentity +} { + const identity = { provider: 'orca' as const, clientMessageId: `command-turn:${clientMessageId}` } + return { + identity, + itemId: agentJournalItemKey(identity), + turnId: `compact:${clientMessageId}`, + resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` } + } +} + +/** Whether the journal's running turn is a command's, which takes no input while it runs. */ +export function structuredAgentSessionCommandRunning( + journal: Pick +): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) +} + +export function isStructuredAgentSessionCommandTurnId(turnId: string): boolean { + return turnId.startsWith('compact:') +} + +const STOP_NOTE_PREFIX = 'stop:' + +/** A Stop's note, on the turn it named. The key says what it is, so a later Stop can read it. */ +export function structuredAgentSessionStopNoteIdentity( + clientOperationId: string +): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${clientOperationId}` } +} + +/** Whether an earlier Stop already asked the running command `turnId` names to end. Read from the + * journal, so nothing is held that could outlive the command. */ +export function structuredAgentSessionCommandWasStopped( + journal: Pick, + turnId: string +): boolean { + const { itemId } = structuredAgentSessionCommandTurn(turnId.slice('compact:'.length)) + return journal.snapshot().items.some((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return ( + item.turnScope?.kind === 'turn' && + item.turnScope.turnItemId === itemId && + identity?.provider === 'orca' && + identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) + ) + }) +} + +export type StructuredAgentSessionCommandHandoverContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined + /** Who a failure the handover meets names, as the start's own row does. */ + failureTextContext?: AgentSessionFailureWordsContext + record: () => AgentSessionRecord | null + flushStreamedEvents: () => Promise + now: () => number +} + +/** Refuses the command, or opens its turn and sends it. */ +export async function handOverStructuredAgentSessionCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + submission: AgentJournalSubmission, + body: AgentJournalMessageItem +): Promise { + const { clientMessageId } = submission + // Provider frames already received decide whether a turn is running. + await ctx.flushStreamedEvents() + const blocked = commandBlocked(ctx, body) + if (blocked) { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }), + fence: ctx.fence + }) + return + } + const turn = structuredAgentSessionCommandTurn(clientMessageId) + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + const startedAt = ctx.now() + const running = agentJournalTurnBody({ + turnId: turn.turnId, + state: 'running', + userItemId: agentJournalSubmissionKey(clientMessageId), + requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission), + startedAt + }) + await ctx.journal.appendItem(turn.identity, running, { + fence: ctx.fence, + observedAt: startedAt, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + let admission: AgentSessionCommandAdmission + try { + admission = await ctx.adapter.compact!({ + sessionId: ctx.sessionId, + fence: ctx.fence, + command: { clientMessageId, ...turn, running } + }) + } catch (error) { + // A child that had not proven its start took nothing, so the command provably did not run. Any + // other throw is a lost reply: the command may have run. + const unsent = + ctx.providerChildPhase?.() === 'starting' + ? { + state: 'rejected' as const, + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } + : { + state: 'unknown' as const, + reason: error instanceof Error ? error.message : String(error) + } + await settleUnsentCommand(ctx, clientMessageId, unsent) + return + } + if (admission.state === 'rejected') { + // The provider refused the compaction itself: its row reads as the compaction failing. + await settleUnsentCommand( + ctx, + clientMessageId, + admission, + agentSessionFailureFact('compactionFailed', { detail: admission.rejection.detail }) + ) + } else if (admission.state !== 'admitted') { + // An unknown write leaves the turn to the provider's end or the child's: it may have run. + await ctx.journal.resolveDispatch({ clientMessageId, ...admission, fence: ctx.fence }) + } +} + +/** Where the turn a handed-over submission runs in starts counting: its handover, so time spent + * held behind a command or a start is not counted as the agent's work. */ +export function structuredAgentSessionHandoverOrigin( + journal: AgentSessionJournal, + submission: AgentJournalSubmission +): number { + const handedOver = journal + .submissions() + .find((entry) => entry.clientMessageId === submission.clientMessageId) + return handedOver?.handedOverAt ?? submission.submittedAt +} + +/** The command's end when the provider never took it: refused, or lost with the adapter's throw. + * The message's answer goes first, so a crash before the turn's end leaves a running turn, which + * the stale-turn sweep settles, never an ended turn whose message still reads as in flight. */ +async function settleUnsentCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + clientMessageId: string, + unsent: + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string }, + /** What the result row reports, when it is not the rejection's own fact. */ + rowFailure?: AgentSessionFailureFact +): Promise { + const turn = structuredAgentSessionCommandTurn(clientMessageId) + const running = readAgentJournalTurn(ctx.journal.itemBody(turn.itemId) ?? undefined) + await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) + if (running?.state !== 'running') { + return + } + const refused = unsent.state === 'rejected' + const mutations: JournalLifecycleMutationInput[] = [ + ...(refused + ? [ + { + kind: 'item' as const, + identity: turn.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { + ...ctx.failureTextContext, + surface: 'row' + }), + tone: 'error' as const + }, + turnScope: { kind: 'turn' as const, turnItemId: turn.itemId } + } + ] + : []), + { + kind: 'item', + identity: turn.identity, + body: agentJournalTurnBody({ + ...running, + ...(refused + ? { state: 'completed', outcome: 'failure', completedAt: ctx.now() } + : { state: 'unverifiable' }) + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + await ctx.journal.appendLifecycleBatch({ + settlementId: `command-settled:${clientMessageId}`, + fence: ctx.fence, + mutations + }) +} + +/** Why the command may not run now, as the fact its message is rejected with; null when it may. */ +function commandBlocked( + ctx: StructuredAgentSessionCommandHandoverContext, + body: AgentJournalMessageItem +): SubmissionRejectionFact | null { + if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) { + return agentSessionFailureFact('commandRefused') + } + const record = ctx.record() + if (!record) { + return agentSessionFailureFact('hostFault') + } + const refusal = conversationCommandBlocked(ctx, record, 'handover') + return refusal + ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) + : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index b294540990d..954525c6429 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -45,7 +46,7 @@ function appendStatus(journal: AgentSessionJournal, text: string) { return journal.appendItem( { provider: 'orca', clientMessageId: text }, { kind: 'status', text }, - { fence: 0 } + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 0d83491ee21..f91c8baba15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { completedStructuredAgentTurnSeconds, @@ -132,10 +134,16 @@ async function seedClaudeToolTurn(): Promise { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, fence: 13 }) + const turnIdentity = { + provider: 'claude' as const, + sessionId: PROVIDER_SESSION, + uuid: 'uuid-turn' + } + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(turnIdentity) } await journal.appendItem( - { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' }, + turnIdentity, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: now }, - { fence: 13 } + { fence: 13, turnScope } ) now = TOOL_STARTED_AT await journal.appendItem( @@ -146,7 +154,7 @@ async function seedClaudeToolTurn(): Promise { input: { command: 'for i in $(seq 90); do sleep 1; done' }, state: 'running' }, - { fence: 13 } + { fence: 13, turnScope } ) await journal.close() } @@ -363,7 +371,8 @@ describe('a turn a read reached before the reconcile proved its owner dead', () // The new child is already working when its start lands, ahead of the queued revision. events?.appendItem( { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn-2' }, - { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT } + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { process, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 4a0febb6ceb..5a55bbb26cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -54,7 +55,7 @@ async function seedUnfinishedWork(): Promise { await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, @@ -65,7 +66,7 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 3 }, @@ -75,12 +76,12 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'web', label: 'Web' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 4 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -198,7 +199,8 @@ describe('dead structured-session generation settlement', () => { { kind: 'status', text: "Orca ran into a problem, so this didn't go through. Try again.", - failure: { kind: 'hostFault' } + failure: { kind: 'hostFault' }, + tone: 'error' } ]) }) @@ -349,7 +351,7 @@ describe('dead structured-session generation settlement', () => { await journal.appendItem( childCall, { kind: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'running' }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( childAsk, @@ -360,7 +362,7 @@ describe('dead structured-session generation settlement', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await settleStructuredAgentSessionDeadGeneration({ @@ -420,12 +422,12 @@ describe('whether a dead generation interrupted anything', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, { kind: 'turn', turnId: 'turn-1', state: 'completed', startedAt: 900, completedAt: 950 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } 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 8ee3430c5c9..72caac40844 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 @@ -5,9 +5,10 @@ import { } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { - AgentJournalItemBody, - AgentJournalRenderItem +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' @@ -30,6 +31,10 @@ import { turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + exitedRootTurnScope, + runningRootTurnScope +} from './structured-agent-session-exit-turn-scope' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ @@ -144,6 +149,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) } } else if (showUnexpectedExitOutcome) { + // The turn the exit ended, and an error so no fold ever hides why it stopped. mutations.push({ kind: 'item', identity: { provider: 'orca', clientMessageId: input.settlementId }, @@ -155,15 +161,22 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ...input.failureTextContext, surface: 'row' } - ) - } + ), + tone: 'error' + }, + turnScope: exitedRootTurnScope(items, input.verdict) }) } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) if (identity && body) { - mutations.push({ kind: 'item', identity, body }) + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } } mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) @@ -212,7 +225,12 @@ export async function settleStaleStructuredAgentSessionState(input: { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) if (identity && body) { - mutations.push({ kind: 'item', identity, body }) + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } } const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal) @@ -240,8 +258,10 @@ export async function settleStaleStructuredAgentSessionState(input: { ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { ...input.failureTextContext, surface: 'row' - }) - } + }), + tone: 'error' + }, + turnScope: runningRootTurnScope(items) }) } for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { 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 7dd17306105..373e2ae3c36 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 @@ -2,13 +2,15 @@ // it, and the one thing that settles a queued message because of a start, a child or a leftover. // // A send is accepted on its own serialized step and returns; this loop does the rest. It exists -// for a session exactly while a message is queued there — accepted, not yet handed over — and -// every step re-reads the journal and the conversation's child record to decide, so there is no -// loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop +// for a session exactly while a message is queued there — accepted, not yet handed over — and no +// child is running a conversation command: a command's turn takes no input, and the commit that +// ends it wakes the loop again. Every step re-reads the journal and the conversation's child +// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop // that arrives while a start holds the queue withdraw the queued messages before the handover that // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact, type SubmissionRejectionFact @@ -35,6 +37,7 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -53,6 +56,9 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void + record: (sessionId: string) => AgentSessionRecord | null + flushStreamedEvents: (sessionId: string) => Promise + now: () => number } type Step = 'continue' | 'stop' @@ -144,7 +150,9 @@ export class StructuredAgentSessionDeliveryLoop { (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) const oldest = oldestQueuedSubmission(session) - if (!oldest) { + // A running command takes no input while its child carries it; its end is a commit, which + // wakes the loop again. With no child it is a gone generation's, which the start below settles. + if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } const failedStart = startThatFailedWhileQueued(session, oldest) @@ -208,7 +216,10 @@ export class StructuredAgentSessionDeliveryLoop { fence: awaitedChild.fence, adapter: this.deps.adapter, providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, - failureTextContext: this.deps.failureTextContext(sessionId) + failureTextContext: this.deps.failureTextContext(sessionId), + record: () => this.deps.record(sessionId), + flushStreamedEvents: () => this.deps.flushStreamedEvents(sessionId), + now: this.deps.now }, next ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index b8c1d0b7c0e..46dc8fbf7e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -37,7 +38,7 @@ type Recorded = { * it: a double that omits the third parameter makes every assertion about what * the sink forwards pass against `undefined`, which is how this went unnoticed * before. Kept separate so the call-order assertions stay about call order. */ -const journalAppendOptions: JournalItemAppendOptions[] = [] +const journalAppendOptions: Partial[] = [] function target( fence: number, @@ -92,8 +93,8 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.publish() expect(log).toEqual([]) @@ -112,10 +113,10 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(1, log)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) // The re-attach that raised the fence. deferred.bind(target(2, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([ @@ -130,7 +131,7 @@ describe('deferred structured agent-session event sink', () => { deferred.bind(target(1, log)) deferred.unbind() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) expect(log).toEqual([]) deferred.bind(target(2, log)) await deferred.drained() @@ -143,9 +144,15 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() expect( - deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1)) + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null)).toEqual({ + expect( + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) deferred.bind(target(2, log)) @@ -161,10 +168,10 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.close() deferred.bind(target(3, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([]) @@ -180,7 +187,7 @@ describe('deferred structured agent-session event sink', () => { }) deferred.bind(target(4, log, 0)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendTombstone(identity(1)) const barrier = await deferred.drained() @@ -202,14 +209,14 @@ describe('deferred structured agent-session event sink', () => { const runtime = new StructuredAgentSessionHostRuntimeState({ store: {} } as never) const failed = runtime.eventSinkFor('session-1') failed.bind(target(1, [], 0)) - failed.sink.appendItem(identity(0), BODY) + failed.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(failed.drained()).resolves.toMatchObject({ ok: false }) const recovered = runtime.eventSinkFor('session-1') expect(recovered).not.toBe(failed) const log: Recorded[] = [] recovered.bind(target(2, log)) - recovered.sink.appendItem(identity(1), BODY) + recovered.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(recovered.drained()).resolves.toEqual({ ok: true }) expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 1 }]) }) @@ -229,9 +236,15 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(1), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -257,11 +270,17 @@ describe('deferred structured agent-session event sink', () => { } }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) expect( - deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1)) + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -292,7 +311,9 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 1 }) expect(readingControl.pauseReading).toHaveBeenCalledOnce() @@ -322,7 +343,7 @@ describe('deferred structured agent-session event sink', () => { deferred.sink.appendLifecycleBatch?.( 'settlement-1', - [{ kind: 'item', identity: identity(0), body: BODY }], + [{ kind: 'item', identity: identity(0), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { lifecycle: true } ) expect(deferred.sink.tryPublish?.({ lifecycle: true })).toEqual({ @@ -354,7 +375,9 @@ describe('deferred structured agent-session event sink', () => { }) const releaseFirst = deferred.sink.bindReadingControl?.(firstControl) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(firstControl.pauseReading).toHaveBeenCalledOnce() const releaseSecond = deferred.sink.bindReadingControl?.(secondControl) @@ -373,7 +396,7 @@ describe('deferred structured agent-session event sink', () => { it('replaces a queued same-item checkpoint before it runs', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) deferred.sink.appendItem(identity(1), BODY, options) @@ -390,7 +413,12 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() const batch = (ordinal: number) => [ - { kind: 'item' as const, identity: identity(ordinal), body: BODY } + { + kind: 'item' as const, + identity: identity(ordinal), + body: BODY, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] deferred.sink.appendLifecycleBatch?.('turn-completed:turn-1', batch(0)) @@ -413,10 +441,10 @@ describe('deferred structured agent-session event sink', () => { it('keeps a replacement checkpoint after distinct intervening operations', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendItem(identity(2), BODY, options) deferred.bind(target(6, log)) await deferred.drained() @@ -460,10 +488,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -479,10 +512,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, () => identity(1), { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, () => identity(1), { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -496,7 +534,7 @@ describe('producer linkage reaches the journal through every append path', () => deferred.bind(target(5, log)) deferred.sink.appendLifecycleBatch?.( 'settle-1', - [{ kind: 'item', identity: identity(1), body: BODY }], + [{ kind: 'item', identity: identity(1), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { ...LINKAGE } ) @@ -511,13 +549,13 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() // A control, not a pin. Absence is the claim, so the keys must be missing // rather than present-and-undefined: a reader holding this options object // would read `agentId: undefined` as a key that exists. - expect(journalAppendOptions).toEqual([{ fence: 5 }]) + expect(journalAppendOptions).toEqual([{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE }]) deferred.close() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index ad92e1c24ca..f712c36cc91 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -2,7 +2,8 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -36,6 +37,11 @@ export type StructuredAgentSessionAppendOptions = AgentJournalProducerLinkage & observedAt?: number } +/** An item write states which turn its row belongs to; the write that creates the row decides. */ +export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { + turnScope: AgentJournalTurnScope +} + export type StructuredAgentSessionLifecycleJournal = Pick< AgentSessionJournal, 'epoch' | 'visitItems' @@ -58,7 +64,7 @@ export type StructuredAgentSessionRevisionResolver = ( /** A revision's body is derived from the row it revises, so coalescing one away would lose it. */ export type StructuredAgentSessionRevisionOptions = Omit< - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, 'coalescingKey' > @@ -69,7 +75,7 @@ export type StructuredAgentSessionEventSink = { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): void appendTombstone( identity: AgentJournalItemIdentity, @@ -84,40 +90,40 @@ export type StructuredAgentSessionEventSink = { tryAppendItem?( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues an ordinary append whose identity is resolved after journal bind. */ tryAppendResolvedItem?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues one resolved append and its publication as a single admitted operation. */ tryAppendResolvedItemAndPublish?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues a read-modify-write of one row; `reservedBytes` must bound the resolved write. */ tryReviseResolvedItem?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one revision and its publication as a single admitted operation. */ tryReviseResolvedItemAndPublish?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one journal-derived lifecycle append; a null resolution is a no-op. */ tryAppendLifecycleTransition?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Current durable epoch, when this deferred sink is bound to its journal. */ journalEpoch?(): string | null @@ -231,7 +237,7 @@ export function createDeferredStructuredAgentSessionEventSink( return { sink: { - appendItem: (identity, body, options = {}) => { + appendItem: (identity, body, options) => { queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), @@ -246,7 +252,7 @@ export function createDeferredStructuredAgentSessionEventSink( options ) }, - tryAppendItem: (identity, body, options = {}) => + tryAppendItem: (identity, body, options) => queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts new file mode 100644 index 00000000000..c336a1d3eca --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts @@ -0,0 +1,36 @@ +// Which turn a gone generation's exit row belongs to, so the row reports on the turn it ended. + +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' + +/** The turn the exit ended: still running, or already ended at the exit's instant by the child's + * own translator, which settles its open turn when the child goes. */ +export function exitedRootTurnScope( + items: readonly AgentJournalRenderItem[], + verdict: StructuredAgentSessionTurnVerdict +): AgentJournalTurnScope { + const endedAt = verdict.state === 'interrupted' ? verdict.completedAt : undefined + const ended = items.findLast((item) => { + const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item.body) : null + return ( + turn?.state === 'running' || + (turn?.state === 'interrupted' && endedAt !== undefined && turn.completedAt === endedAt) + ) + }) + return ended ? { kind: 'turn', turnItemId: ended.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} + +export function runningRootTurnScope( + items: readonly AgentJournalRenderItem[] +): AgentJournalTurnScope { + const running = items.findLast( + (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' + ) + return running ? { kind: 'turn', turnItemId: running.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 90e07ddf88c..8ce8d7d1507 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Removing a session from the host's map and removing its status row are ONE operation. // // The store keeps a row until told to drop it, and `structuredHostOwned` bypasses the staleness @@ -110,12 +111,12 @@ async function workingSession(): Promise<{ await journal.appendItem( PROMPT, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index 8181110a5a6..72317e8ed81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -70,30 +71,34 @@ async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number if (!events) { throw new Error('seedGroupedQuestion requires an acquired session') } - events.appendItem(identity, { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) 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 d85d1dfee0f..ed2e48fd562 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 @@ -12,6 +12,7 @@ import { type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostDeps, @@ -19,7 +20,6 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionConversationDelivery = { @@ -29,6 +29,10 @@ export type StructuredAgentSessionConversationDelivery = { sessionId: string, options?: StructuredAgentSessionConversationOpenOptions ) => Promise + /** Every commit a conversation's journal makes: one may have ended the command that held its + * 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 /** 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. */ @@ -50,6 +54,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void publishRestored: (sessionId: string) => void + flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input const loop = new StructuredAgentSessionDeliveryLoop({ @@ -61,8 +66,14 @@ export function createStructuredAgentSessionConversationDelivery(input: { conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), failureTextContext: (sessionId) => - structuredAgentSessionFailureWordsContext(deps.store.getRecord(sessionId)), - onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + structuredAgentSessionFailureWordsContext( + deps.store.getRecord(sessionId), + sessions.get(sessionId)?.journal + ), + onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + record: (sessionId) => deps.store.getRecord(sessionId), + flushStreamedEvents: input.flushStreamedEvents, + now: () => deps.now?.() ?? Date.now() }) const adoptOpened = async ( sessionId: string, @@ -79,6 +90,26 @@ export function createStructuredAgentSessionConversationDelivery(input: { loop.wake(sessionId) } } + const wakesQueued = new Set() + const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + if ( + wakesQueued.has(sessionId) || + structuredAgentSessionCommandRunning(journal) || + !journal.submissions().some(isQueuedAgentJournalSubmission) + ) { + return + } + wakesQueued.add(sessionId) + void input + .serialize(sessionId, async () => { + wakesQueued.delete(sessionId) + loop.wake(sessionId) + }) + .catch((error: unknown) => { + wakesQueued.delete(sessionId) + deps.onEventSinkError?.({ sessionId, error }) + }) + } // A chat open before its owner's death was proven revises what its open settled. Queued, never // awaited: the writer can hold this session's serialize (an attach recovering its lease). const stopResettling = deps.store.onDeathEvidence((sessionId) => { @@ -94,6 +125,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) return { loop, + afterCommit, adoptOpened, dispose: () => { loop.dispose() @@ -105,10 +137,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { } /** - * A compaction or rewind found prepared when the conversation opens was started under a child - * this process no longer has — the open runs only when none is indexed — so nothing will finish - * it, and left alone it refuses every send, so no agent would ever start to. Settled here instead - * of by a start inside acceptance. A Codex rewind only its provider can prove stays for the attach. + * A rewind found prepared when the conversation opens was started under a child this process no + * longer has — the open runs only when none is indexed — so nothing will finish it, and left alone + * it refuses every send until a view attaches. Settled here instead of by a start inside + * acceptance. A Codex rewind only its provider can prove stays for the attach. */ async function settleInterruptedCommands( deps: StructuredAgentSessionHostDeps, @@ -117,7 +149,6 @@ async function settleInterruptedCommands( ): Promise { const fence = structuredAgentSessionConversationFence(deps.store, sessionId) try { - await settleInterruptedCompaction(deps.store, sessionId, session.journal, fence) await recoverStructuredRewind(deps.store, sessionId, session.journal, fence) } catch (error) { deps.onEventSinkError?.({ sessionId, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 1586bd4a1c1..f7f26ead440 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -78,7 +78,8 @@ export type StructuredAgentSessionMutationContext = { now: () => number } -function mutate( +/** Admits the envelope and runs the plan inside the session's serialize. */ +export function mutateStructuredAgentSession( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, @@ -113,7 +114,7 @@ export function sendStructuredAgentSessionTurn( } ): Promise> { const plan = sendPlan(params) - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -146,28 +147,21 @@ export function cancelStructuredAgentSessionTurn( prompt?: { itemId: string; expectedRevision: number } } ): Promise> { - const command = context.deps.store.getRecord(params.envelope.sessionId)?.conversationCommand - // Interrupts must reach a provider while the command awaits its terminal frame. - const cancellationContext = - command?.command === 'compact' && command.phase === 'prepared' - ? { - ...context, - serialize: (sessionId: string, task: () => Promise) => - context.serialize(`compact-cancel:${sessionId}`, task) - } - : context - const plan = cancelPlan(params) if (params.scope || params.prompt) { - return mutate( - cancellationContext, + return mutateStructuredAgentSession( + context, caller, params.envelope, - plan, + cancelPlan(params), openForWrite(context, params.envelope) ) } - return mutate( - cancellationContext, + const plan = cancelPlan({ + ...params, + stopChild: () => context.stopAgent(params.envelope.sessionId) + }) + return mutateStructuredAgentSession( + context, caller, params.envelope, { @@ -212,7 +206,7 @@ export function respondToStructuredAgentSessionPrompt( caller: StructuredAgentSessionCaller, params: AgentSessionPromptRequest & { envelope: AgentSessionMutationEnvelope } ): Promise> { - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -230,7 +224,7 @@ export async function setStructuredAgentSessionOption( await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) const plan = setOptionPlan(params) const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -255,7 +249,7 @@ export function changeStructuredAgentSessionThreadGoal( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 1e0d77b0d52..d354ad911f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -98,13 +99,17 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis if (!events) { throw new Error('seedApproval requires an acquired session') } - events.appendItem(identity, { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 58a84cecd5f..5db9e5d0d74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' @@ -186,7 +187,8 @@ describe('attach', () => { }) events?.appendItem( { provider: 'orca', clientMessageId: 'old-journal-write' }, - { kind: 'status', text: 'old journal write' } + { kind: 'status', text: 'old journal write' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) const released = await store.evictProvenDeadOwner({ @@ -389,7 +391,7 @@ describe('respondToPrompt', () => { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - child + { ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) 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 26697d3f681..9faabccfb1e 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 @@ -63,7 +63,10 @@ export class StructuredAgentSessionHost { this ) private readonly sessions = new StructuredAgentSessionConversations({ - deliver: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + deliver: (sessionId, journal) => { + this.subscribers.publish(sessionId, journal) + this.conversationDelivery.afterCommit(sessionId, journal) + }, onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), now: () => this.now() }) @@ -123,7 +126,8 @@ export class StructuredAgentSessionHost { reset, structuredAgentSessionConversationFence(deps.store, sessionId) ), - publishRestored: this.clientDelivery.publishRestored + publishRestored: this.clientDelivery.publishRestored, + flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, @@ -170,6 +174,7 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) + sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index ebc2009fa7d..25288dc6490 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The idle sweep on a real host: what puts an agent to rest, what keeps it running, and what a // reader and the session lists see when it does. @@ -185,7 +186,8 @@ describe('the idle sweep', () => { await foundRestTestChat(rig) providerEvents().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += IDLE_MS + 1 @@ -210,7 +212,8 @@ describe('the idle sweep', () => { // Activity at 29 minutes: a provider row reaching the journal. providerEvents().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 60 }, - { kind: 'status', text: 'still thinking' } + { kind: 'status', text: 'still thinking' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += 60_001 @@ -247,7 +250,8 @@ describe('the idle sweep', () => { detail: null, options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += IDLE_MS + 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index b35690c4e7d..e7ce667ed2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -6,15 +6,16 @@ import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts' -import type { StructuredAgentSessionAppendOptions } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from './structured-agent-session-event-sink' export function structuredAgentSessionJournalAppendOptions( fence: number, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): JournalItemAppendOptions { return { fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + turnScope: options.turnScope, ...agentJournalLinkageFields(options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index 8dabd942705..f6ca0bc4a4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Journal handle ownership across the wire layer. // // Every one of these sites is reached only when something has already gone @@ -91,7 +92,7 @@ describe('site 6: recovery rehydration', () => { await seeded.appendItem( { provider: 'codex', threadId: SESSION, turnId: 'turn-1', ordinal }, { kind: 'status', text: `seed-${ordinal}` }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } await seeded.close() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 2194b992fb0..8a43b1a8eff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -271,7 +272,10 @@ describe('settling a send the provider proves it received after the ack window', await journal().appendItem( { provider: 'claude', sessionId: THREAD, uuid: 'echo-row' }, params.body, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) expect(await submissions()).toMatchObject([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 35c02972829..95861c81f29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -9,7 +9,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-background-task-wire' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' @@ -138,7 +141,7 @@ async function send(text: string): Promise { } async function provider(ordinal: number, body: AgentJournalItemBody): Promise { - events!.appendItem({ ...PROVIDER_ROW, ordinal }, body) + events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) events!.publish() await host.flushStreamedEvents(SESSION) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 2007f9bc174..a88359e121d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -14,7 +14,12 @@ import type { AgentSessionPromptResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, + structuredAgentSessionCompactBody +} from './structured-agent-session-command-turn' import { performCancel, performPrompt, @@ -96,12 +101,52 @@ export function sendPlan(params: { } } +export type ConversationCommandAcceptance = + | { clientMessageId: string } + /** What an older build's run of this operation recorded. */ + | { recorded: AgentSessionConversationCommandResult } + +/** `/compact` accepted like a send: one submission, keyed by the operation id, that the delivery + * loop carries out as the command's own turn. */ +export function conversationCommandPlan(params: { + envelope: AgentSessionMutationEnvelope + priorRecord: () => AgentSessionConversationCommandResult | null +}): MutationPlan { + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.conversationCommand', + conversationWrite: true, + markUnknownBeforeRun: true, + fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, + recoverUnknownFromDurableState: true, + run: async (ctx) => { + const sent = await performSend(ctx, { + clientMessageId, + payloadFingerprint: params.envelope.payloadFingerprint, + body: structuredAgentSessionCompactBody() + }) + return sent.ok ? { ok: true, value: { clientMessageId } } : sent + }, + replay: (ctx, outcome) => { + if (outcome.status === 'succeeded' && outcome.conversationCommand) { + return { recorded: outcome.conversationCommand } + } + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return { clientMessageId } + } + const prior = params.priorRecord() + return prior ? { recorded: prior } : null + } + } +} + export function cancelPlan(params: { envelope: AgentSessionMutationEnvelope turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } + stopChild?: () => Promise }): MutationPlan { return { method: 'agentSession.cancel', @@ -119,7 +164,8 @@ export function cancelPlan(params: { ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), - ...(params.prompt ? { prompt: params.prompt } : {}) + ...(params.prompt ? { prompt: params.prompt } : {}), + ...(params.stopChild ? { stopChild: params.stopChild } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index a9e32b026e6..e0ae5da9d81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -50,7 +51,7 @@ async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { journal, itemId: item.itemId } } @@ -117,7 +118,7 @@ describe('performCancel for a pending prompt', () => { resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index fb66a1e1a8f..30e81ab2d1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The provider child is its own record on the conversation: stopping it, losing it or failing to // start it ends the child, never the conversation. Against the real host, store and journal, with a // live subscriber opened before each action. @@ -339,7 +340,7 @@ describe('settling an earlier child before the next one takes its message', () = await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, - { fence: releasedFence } + { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const id = await accept('for the next child') 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 a2be1610327..7a90850dfc6 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 @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A turn that was running when its host went away ends when recovery settles it. That settlement is // the edge the user needs to see — their work stopped — so the session reads as newly done then, // and nothing along the way may call it a success. Every hop is the real one: durable journal, @@ -59,12 +60,12 @@ async function sessionWithRunningTurn() { await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: TURN_STARTED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const server = new AgentHookServer() const sessions = new Map([[SESSION, indexedStatusFeedSession({ journal })]]) @@ -196,7 +197,7 @@ describe('a turn recovery settled after its host went away', () => { startedAt: TURN_STARTED, completedAt: EXIT_OBSERVED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) session.publish() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index 005986adb44..d3c15de933c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { @@ -63,7 +64,11 @@ describe('resolved revisions', () => { const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('abc')) for (const suffix of ['a', 'b', 'c']) { - expect(deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix))).toEqual({ + expect( + deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) } @@ -77,7 +82,9 @@ describe('resolved revisions', () => { it('skips a revision that resolves to nothing', async () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.tryReviseResolvedItem?.(1_000, () => null) + deferred.sink.tryReviseResolvedItem?.(1_000, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) deferred.bind(target) await expect(deferred.drained()).resolves.toEqual({ ok: true }) @@ -88,8 +95,12 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) const published: string[] = [] vi.mocked(target.publish).mockImplementation(() => @@ -105,10 +116,14 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItem?.(bytes, () => ({ - identity: ROW, - body: text('a'.repeat(64)) - })) + deferred.sink.tryReviseResolvedItem?.( + bytes, + () => ({ + identity: ROW, + body: text('a'.repeat(64)) + }), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) deferred.bind(journalTarget(rows)) await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) expect(rows.size).toBe(0) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts index de1190aefc1..eb2fbea97fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts @@ -4,7 +4,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal } from './structured-agent-session-event-sink' @@ -30,7 +30,7 @@ export function createStructuredAgentSessionResolvedAppend( const submit = ( reservedBytes: number, resolve: (journal: StructuredAgentSessionRevisionJournal) => ResolvedItem | null, - options: StructuredAgentSessionAppendOptions, + options: Omit, publish: boolean ) => queue.submit( @@ -58,7 +58,7 @@ export function createStructuredAgentSessionResolvedAppend( options ) const identityOnly = (publish: boolean) => - ((identitySizeBound, body, resolveIdentity, options = {}) => + ((identitySizeBound, body, resolveIdentity, options) => submit( estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + (publish ? 1 : 0), (journal) => { @@ -71,11 +71,11 @@ export function createStructuredAgentSessionResolvedAppend( return { tryAppendResolvedItem: identityOnly(false), tryAppendResolvedItemAndPublish: identityOnly(true), - tryReviseResolvedItem: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItem: (reservedBytes, resolve, options) => submit(reservedBytes, resolve, options, false), - tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options) => submit(reservedBytes + 1, resolve, options, true), - tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options = {}) => { + tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options) => { const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) return queue.submit( { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index 50008ce79c4..b16f76c1633 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -6,7 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts index cfbdd9ce35e..4dff44ff2ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -13,7 +13,8 @@ const MESSAGE = 'continuation-1' let submission: AgentJournalSubmission const journal = { submissions: (): AgentJournalSubmission[] => [submission], - cursor: () => ({ epoch: 'epoch-1', sequence: 1 }) + cursor: () => ({ epoch: 'epoch-1', sequence: 1 }), + activeTurnId: () => null } beforeEach(() => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 9dd04929385..823b5926897 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -6,7 +6,10 @@ // message asks the agent to verify its last action before repeating it, and the launch toast // reports what happened. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { readAgentSessionFailureFact, type UnreadAgentSessionFailureFact @@ -113,7 +116,8 @@ function restartNoteWriter( await session.journal.appendItem( { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, { kind: 'status', text, ...(tone ? { tone } : {}) }, - { fence } + // About the conversation, not any turn in it. + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d88a6330e78..6566105ee87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -60,7 +61,8 @@ export async function interruptedRestart( // An earlier exchange had finished; the user's next send had not opened a turn yet. events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'completed' } + { kind: 'turn', turnId: 'earlier-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) } @@ -73,7 +75,8 @@ export async function interruptedRestart( } else if (work === 'children') { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'settled-turn', state: 'completed' } + { kind: 'turn', turnId: 'settled-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const group = { provider: 'codex', @@ -92,20 +95,21 @@ export async function interruptedRestart( } ] }) - events.appendItem(group, roster('working')) + events.appendItem(group, roster('working'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) previous.host.deps.adapter.backgroundTaskState = () => ({ state: 'monitoring', tasks: [{ id: 'child-1', kind: 'agent', description: 'Review loop 4', state: 'working' }] }) // As the real adapters do: the child's own close settles the children it can no longer hear. previous.host.deps.adapter.closeSession = async () => { - events.appendItem(group, roster('unverifiable')) + events.appendItem(group, roster('unverifiable'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return true } } else { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'interrupted-turn', state: 'running' } + { kind: 'turn', turnId: 'interrupted-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } await previous.host.flushStreamedEvents(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index b45e08314a0..59ff6958d53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdir, rm, writeFile } from 'node:fs/promises' import { AgentSessionRecoveryCapsule, @@ -279,7 +280,8 @@ it('serializes teardown publication behind an explicit dismissal', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.restartResume.beginTeardown('quit') @@ -437,7 +439,8 @@ it('logs teardown capsule publication failure and still releases the provider', } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) const capsulePath = join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index bf740ac405b..2ea9e6f57b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { attach, hostTestState } from './structured-agent-session-host-test-harness' @@ -49,12 +50,14 @@ it.each(['approval', 'question', 'completed'])( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 2 }, - { kind: 'status', text: 'Provider is requesting approval' } + { kind: 'status', text: 'Provider is requesting approval' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 3 }, @@ -65,7 +68,7 @@ it.each(['approval', 'question', 'completed'])( kind: event === 'approval' ? 'approval' : 'question' } : { kind: 'turn', turnId: 'working', state: 'completed' }, - { lifecycle: true } + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushAllStreamedEvents() const offered = await new AgentSessionRecoveryCapsule(root).list(NOW) @@ -90,7 +93,8 @@ it.each(['approval', 'question', 'completed'] as const)( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => { @@ -103,7 +107,8 @@ it.each(['approval', 'question', 'completed'] as const)( }, event === 'completed' ? { kind: 'turn', turnId: 'working', state: 'completed' } - : { ...pendingApproval().body, question: 'Which action?', kind: event } + : { ...pendingApproval().body, question: 'Which action?', kind: event }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return true } @@ -123,7 +128,8 @@ it('marks a settled chat whose subagent was still running', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled', ordinal: 1 }, - { kind: 'turn', turnId: 'settled', state: 'completed' } + { kind: 'turn', turnId: 'settled', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.backgroundTaskState = () => ({ @@ -153,7 +159,8 @@ it('offers nothing for a chat whose child was not proven stopped', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index a596cc5c9ed..a9caf642523 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A Codex rewind whose outcome only the provider can prove, found on a chat at rest: nothing on // screen will start the agent that settles it, so the next send does, and its message is kept. @@ -158,8 +159,10 @@ function sendParams(text: string) { async function interruptedRewindAtRest(): Promise { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const drop = { ...KEPT, turnId: 'drop' } - sink.appendItem(KEPT, hostTestMessage('verified history')) - sink.appendItem(drop, hostTestMessage('to be rewound')) + sink.appendItem(KEPT, hostTestMessage('verified history'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(drop, hostTestMessage('to be rewound'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) rewind.mockImplementation(async (input) => { await input.onReverted?.() @@ -205,7 +208,7 @@ describe('an interrupted Codex rewind on a chat at rest (R16)', () => { describe('a rewind asked of a chat at rest (P2-23)', () => { it('starts the agent first and answers with what the provider says', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) - sink.appendItem(KEPT, hostTestMessage('kept')) + sink.appendItem(KEPT, hostTestMessage('kept'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch await host.flushAllStreamedEvents() 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 a8ded60e6ff..fcba5479423 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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,6 +9,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { @@ -151,7 +153,7 @@ async function seed(acceptedSubmissions = false) { selectedItemId = agentJournalSubmissionKey(clientOperationId) } } else { - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } } await host.flushStreamedEvents(HOST_TEST_SESSION) @@ -268,7 +270,8 @@ describe('host rewind', () => { const target = await seed() sink.appendItem( { provider: 'orca', clientMessageId: 'active' }, - { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } } + { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: false, @@ -377,12 +380,24 @@ describe('host rewind', () => { completedAt: HOST_TEST_NOW - 4_000, durationMs: 5_000 } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(turnRow('kept'), keptTurn) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(turnRow('drop'), { ...keptTurn, turnId: 'drop', durationMs: 1_000 }) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(turnRow('kept'), keptTurn, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + turnRow('drop'), + { ...keptTurn, turnId: 'drop', durationMs: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) // The provider preflight knows only its own items, never the host's turn rows. const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }] @@ -411,6 +426,98 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) + it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string, ordinal = 0) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal + }) + const turnRow = (turnId: string) => ({ + provider: 'legacy' as const, + agent: 'codex', + sessionId: HOST_TEST_SESSION, + recordId: `turn-lifecycle:${turnId}` + }) + for (const turnId of ['kept', 'drop']) { + const body = hostTestMessage(turnId) + const clientOperationId = hostTestOperationId() + vi.mocked(adapter.dispatch).mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: message(turnId) + }) + expect( + await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId, + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.dispatchState + ).toBe('accepted') + ) + // As the translator writes it: the turn names the submission that opened it. + sink.appendItem( + turnRow(turnId), + { + kind: 'turn', + turnId, + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey(clientOperationId), + startedAt: HOST_TEST_NOW - 2_000, + completedAt: HOST_TEST_NOW - 1_000 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message(turnId, 1), + { ...hostTestMessage(`answer ${turnId}`), role: 'assistant' }, + { turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turnRow(turnId)) } } + ) + await host.flushStreamedEvents(HOST_TEST_SESSION) + } + const items = [ + { identity: message('kept'), body: hostTestMessage('kept') }, + { + identity: message('kept', 1), + body: { ...hostTestMessage('answer kept'), role: 'assistant' as const } + } + ] + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + await input.onReverted?.() + return { ok: true, items } + }) + const target = (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.at(-1)! + expect( + await host.rewind(caller, await params(agentJournalSubmissionKey(target.clientMessageId))) + ).toMatchObject({ ok: true }) + + const rebuilt = await host.journalSnapshot(HOST_TEST_SESSION) + const membership = nativeChatTurnMembership( + rebuilt.items.flatMap(({ itemId, body }) => + body.kind === 'message' ? [{ id: itemId, role: body.role }] : [] + ), + rebuilt + ) + const opener = agentJournalItemKey(message('kept')) + expect(membership.turnKeys).toEqual([opener, opener]) + }) + it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string) => ({ @@ -432,10 +539,18 @@ describe('host rewind', () => { payload: { head: '{}', byteLength: 2, digest: '4'.repeat(64), truncated: false } } } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) rewind.mockImplementationOnce(async (input) => { await input.onReverted?.() 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 45dd7244896..f1870dc124d 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 @@ -1,4 +1,5 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { agentJournalItemKey, agentJournalSubmissionKey, @@ -11,6 +12,7 @@ import type { AgentSessionRewindResult } from '../../../shared/agent-session-rewind' import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { openWithAgent } from './structured-agent-session-send-preparation' @@ -120,10 +122,12 @@ export async function rewindStructuredAgentSession( } const retained = snapshot.items .slice(0, boundary) - .map(({ itemId, body, observedAt }) => ({ + .map(({ itemId, body, observedAt, turnScope, ...linkage }) => ({ itemId: providerKey(itemId), - body, - observedAt + body: withRenamedTurnOpener(body, providerKey), + observedAt, + ...(turnScope ? { turnScope } : {}), + ...agentJournalLinkageFields(linkage) })) if ( retained.length > 10_000 || @@ -229,3 +233,18 @@ export async function rewindStructuredAgentSession( : result }) } + +/** The new epoch keeps no submissions, so a sent message survives only under its provider key; the + * turn it opened must name it by that key too, or the turn anchors on nothing. */ +function withRenamedTurnOpener( + body: AgentJournalItemBody, + rename: (itemId: string) => string +): AgentJournalItemBody { + if (body.kind === 'turn' && body.userItemId !== undefined) { + return { ...body, userItemId: rename(body.userItemId) } + } + const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined + return body.kind === 'status' && lifecycle?.userItemId !== undefined + ? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } } + : body +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index 79882025889..d4862e3e544 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -48,14 +48,12 @@ describe('a send refused by the conversation command it follows', () => { }) }) - it('keeps an unconfirmed /compact as an unconfirmed command', () => { - const blocked = structuredAgentSessionSendBlock( - withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) - ) - - expect(blocked?.refusal).toMatchObject({ - details: { reason: 'conversationCommandUnconfirmed' } - }) + it("lets a send follow an older build's unconfirmed /compact, whose child this host no longer runs", () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) + ) + ).toBeNull() }) it('lets a send follow a /clear whose new conversation failed to start', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 0a14f436119..630e3fa386e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A send can be what opens a conversation this process has not read yet: a chat nobody has on // screen after the app died, sent to from a phone or the CLI. Whatever that journal shows running // belongs to a generation that is gone, so it is settled when the journal opens, not only when a @@ -51,7 +52,8 @@ async function relaunchAfterCrashMidTurn( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'crashed-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'crashed-turn', state: 'running' } + { kind: 'turn', turnId: 'crashed-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await dying.host.flushStreamedEvents(SESSION) // An empty renewal queues behind every record write, so they are on disk. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index a7a0e971055..57b7137481e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -17,6 +17,10 @@ import { classifyJournalOpenFailure, type JournalOpenFailure } from '../agent-session-journal/journal-open-failure' +import { + structuredAgentSessionAwaitedCommand, + type StructuredAgentSessionAwaitedCommandJournal +} from './structured-agent-session-command-turn' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -34,10 +38,11 @@ export function structuredAgentSessionSendBlock( return rewindRefusal('outcome-unknown') } const command = record?.conversationCommand + // Only a clear's record gates sends; an older build's compaction record belongs to a child this + // host no longer runs. if ( - command && - ((command.state === 'unknown' && command.phase === 'prepared') || - (command.command === 'clear' && command.replacementSessionId)) + command?.command === 'clear' && + ((command.state === 'unknown' && command.phase === 'prepared') || command.replacementSessionId) ) { return { ok: false, @@ -49,17 +54,11 @@ export function structuredAgentSessionSendBlock( 'This conversation has been cleared. Use the current conversation.' ) : // A prepared /clear names its replacement before that conversation exists. - command.command === 'clear' - ? refuse( - 'agent_session_operation_invalid', - { reason: 'clearUnconfirmed' }, - "The last /clear didn't finish. Start a new chat to continue." - ) - : refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCommandUnconfirmed' }, - 'The conversation operation is unconfirmed.' - ) + refuse( + 'agent_session_operation_invalid', + { reason: 'clearUnconfirmed' }, + "The last /clear didn't finish. Start a new chat to continue." + ) } } return null @@ -125,9 +124,15 @@ export function sendPreparation( } } -/** Who a failure sentence names: the chat's agent, when the record says. */ +/** Who a failure sentence names: the chat's agent, when the record says; and, given the journal, + * the command a failed start leaves to run again. */ export function structuredAgentSessionFailureWordsContext( - record: AgentSessionRecord | null + record: AgentSessionRecord | null, + journal?: StructuredAgentSessionAwaitedCommandJournal ): AgentSessionFailureWordsContext { - return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} + const command = journal && structuredAgentSessionAwaitedCommand(journal) + return { + ...(record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {}), + ...(command ? { command } : {}) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts index ff9765d1cee..8155da7a2bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -27,6 +27,71 @@ function emptyJournal(): AgentSessionJournal { } as unknown as AgentSessionJournal } +/** A message accepted and not yet handed over, while `activeTurnId` names the running turn. */ +function queuedJournal( + activeTurnId: string | null, + handedOver = false +): Pick { + return { + cursor: () => ({ epoch: 'epoch-1', sequence: handedOver ? 3 : 2 }), + activeTurnId: () => activeTurnId, + submissions: () => [ + { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true, + ...(handedOver ? { handedOverAt: 3 } : {}) + } + ] + } +} + +describe('a wait that also ends behind a running command', () => { + const until = 'handed-over-or-behind-command' as const + + it('ends once the message waits behind a running command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal('turn-1')) + const pending = settlements.wait('session-1', 'client-1', { until }) + + settlements.publish('session-1', queuedJournal('compact:cmd-1')) + + const settled = await pending + expect(settled?.value.submission.dispatchState).toBe('pending') + expect(settled?.value.submission).not.toHaveProperty('handedOverAt') + }) + + it('keeps waiting for the handover behind anything that is not a command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal(null)) + let settled = false + const pending = settlements.wait('session-1', 'client-1', { until }).then((result) => { + settled = true + return result + }) + + settlements.publish('session-1', queuedJournal('turn-1')) + await Promise.resolve() + expect(settled).toBe(false) + settlements.publish('session-1', queuedJournal('turn-1', true)) + + await expect(pending).resolves.toMatchObject({ value: { submission: { handedOverAt: 3 } } }) + }) + + it('leaves the plain handover wait to the handover', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => + queuedJournal('compact:cmd-1') + ) + const pending = settlements.wait('session-1', 'client-1', { until: 'handed-over', budgetMs: 1 }) + + await expect(pending).resolves.toBeUndefined() + }) +}) + describe('structured send settlement compatibility wait', () => { afterEach(() => vi.useRealTimers()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index bc265833c42..a9815b953f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -5,9 +5,10 @@ import type { import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isStructuredAgentSessionCommandTurnId } from './structured-agent-session-command-turn' /** What a settlement read needs of a journal. */ -type SendSettlementJournal = Pick +type SendSettlementJournal = Pick type SettledSend = { cursor: AgentJournalCursor @@ -16,8 +17,9 @@ type SettledSend = { type SendSettlement = SettledSend | 'pending' | 'missing' -/** What ends a wait: the provider's answer, or only the host handing the message over. */ -export type SendSettlementPoint = 'answered' | 'handed-over' +/** What ends a wait: the provider's answer, the host handing the message over, or either that or + * the message waiting behind a running command, which hands nothing over until it ends. */ +export type SendSettlementPoint = 'answered' | 'handed-over' | 'handed-over-or-behind-command' export type SendSettlementWaitOptions = { signal?: AbortSignal @@ -55,12 +57,18 @@ function settledSend( return 'missing' } const waiting = - until === 'handed-over' - ? isQueuedAgentJournalSubmission(submission) - : submission.dispatchState === 'pending' + until === 'answered' + ? submission.dispatchState === 'pending' + : isQueuedAgentJournalSubmission(submission) && + !(until === 'handed-over-or-behind-command' && runningCommand(journal)) return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } } +function runningCommand(journal: SendSettlementJournal): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) +} + function abortError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 83959233f6f..204da81a2e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -3,6 +3,7 @@ // reading it before the restart restore reaches it. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { @@ -38,7 +39,8 @@ async function exitWithUnwrittenSettlement(refusedWrites = 2): Promise { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) const append = vi.spyOn(open.journal, 'appendLifecycleBatch') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 2c7a91e7637..54acad4832b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -1,9 +1,14 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,6 +28,10 @@ const RUNNING_IDENTITY = { ordinal: 0 } +function turnScopeOf(identity: AgentJournalItemIdentity): AgentJournalTurnScope { + return { kind: 'turn', turnItemId: agentJournalItemKey(identity) } +} + function lifecycleItem( turnId: string, state: 'running' | 'completed', @@ -169,7 +178,8 @@ describe('running turn lifecycle revisions', () => { state: 'interrupted', startedAt: 30, completedAt: 40 - } + }, + turnScope: { kind: 'thread' } } ] ) @@ -238,7 +248,8 @@ describe('running turn lifecycle revisions', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ]) }) @@ -295,7 +306,8 @@ describe('stale session state on a cold acquire', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ] }) @@ -337,7 +349,8 @@ describe('stale session state on a cold acquire', () => { resolvedBy: null, resolvedAt: null } - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -367,8 +380,15 @@ describe('stale session state on a cold acquire', () => { turnId: 'turn-1', ordinal: 1 }) - await journal.appendItem(prompt('thread-child'), body, { fence: 1, ...child }) - await journal.appendItem(prompt(THREAD), body, { fence: 1 }) + await journal.appendItem(prompt('thread-child'), body, { + fence: 1, + ...child, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(prompt(THREAD), body, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await settleStaleStructuredAgentSessionState({ journal, @@ -408,19 +428,20 @@ describe('stale session state on a cold acquire', () => { }) const command = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } const shell = { kind: 'tool-call' as const, name: 'shell', input: { command: 'pnpm test' } } + const turnScope = turnScopeOf({ ...command, ordinal: 0 }) await journal.appendItem( { ...command, ordinal: 0 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope } ) now = 200 - await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1 }) + await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1, turnScope }) // Rows can outlast the last renewal; only the renewal is proof of life. now = 700 await journal.appendItem( command, { ...shell, input: { command: 'pnpm test', streamed: 'ok' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope } ) now = 9_000 @@ -474,13 +495,13 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem( RUNNING_IDENTITY, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) now = 200 await journal.appendItem( { ...RUNNING_IDENTITY, ordinal: 1 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) const settle = (deathEvidence: AgentSessionDeathEvidence | null) => settleStaleStructuredAgentSessionState({ @@ -503,7 +524,12 @@ describe('stale session state on a cold acquire', () => { fence: 1, handoverRecorded: true }) - await journal.resolveDispatch({ clientMessageId: 'send-1', state: 'pending', fence: 1 }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'pending', + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + fence: 1 + }) now = 3_606_000 await settle({ @@ -540,7 +566,7 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem( RUNNING_IDENTITY, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) now = 400 // The open, before anything proved the fence-1 owner gone. @@ -603,7 +629,7 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem( { ...RUNNING_IDENTITY, turnId }, { kind: 'turn', turnId, state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf({ ...RUNNING_IDENTITY, turnId }) } ) } now = 9_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index b23018aeba8..97c951b4517 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -7,9 +7,10 @@ // with no end at all, until a proof naming its owner is written and revises it. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import { agentJournalTurnBody, @@ -95,7 +96,14 @@ function turnLifecycleRevision( ): JournalLifecycleMutationInput[] { const identity = parseAgentJournalItemKey(item.itemId) return identity - ? [{ kind: 'item', identity, body: agentJournalTurnBody(settledLifecycle(turn, verdict)) }] + ? [ + { + kind: 'item', + identity, + body: agentJournalTurnBody(settledLifecycle(turn, verdict)), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] : [] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 77ba7fe5f58..12fa05710f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,4 +1,5 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' @@ -24,7 +25,9 @@ export function structuredAgentSessionStartFailureRow( kind: 'item', identity: structuredAgentSessionStartFailureRowIdentity(startKey), // The row repeats the sentence the start's rejected messages carry. - body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection } + body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }, + // A start that failed opened no turn. + turnScope: AGENT_JOURNAL_THREAD_SCOPE } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index dae8635cd69..3c4096be138 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -4,6 +4,7 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, @@ -22,6 +23,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), @@ -140,4 +142,30 @@ describe('a provider that ends before it finished starting', () => { }) ) }) + + it('names /compact as the next step when the start that failed was carrying it', async () => { + const base = startedSession() + const session = { + ...base, + child: { generation: GENERATION, fence: 7, phase: 'starting' as const }, + journal: { + ...base.journal, + submissions: () => [{ clientMessageId: 'compact-1', dispatchState: 'pending' as const }], + itemBody: () => structuredAgentSessionCompactBody() + } + } + + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + + const text = 'Claude stopped before it finished starting. Run /compact again.' + expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( + 7, + expect.objectContaining({ reason: text }) + ) + expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( + expect.objectContaining({ + mutations: [expect.objectContaining({ body: expect.objectContaining({ text }) })] + }) + ) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index dcd06c0c7ef..8576df8a95c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Which journal changes republish a session's status now that the summary carries its own state // clock: a moved clock always does, and row activity alone does not once the clock dates the state. @@ -73,7 +74,7 @@ async function openFeed() { } }) const write = async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - await journal.appendItem(identity, body, { fence: 1 }) + await journal.appendItem(identity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) feed.publish(SESSION, journal) } await write( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index eda619694e3..8ff9de818a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -105,7 +106,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -215,12 +216,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -251,12 +252,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 @@ -282,20 +283,20 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const assistant = { ...USER_IDENTITY, ordinal: 2 } await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'finished' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ @@ -319,12 +320,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) for (let revision = 1; revision <= 20; revision += 1) { @@ -332,7 +333,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) } @@ -356,12 +357,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the tests' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -372,7 +373,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( { ...USER_IDENTITY, ordinal: 2 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -390,7 +391,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, @@ -401,7 +402,7 @@ describe('StructuredAgentSessionStatusFeed', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -418,7 +419,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -449,7 +450,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const late: AgentSessionStatusEvent[] = [] @@ -481,7 +482,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -500,12 +501,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix the auth bug' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) @@ -533,7 +534,7 @@ describe('StructuredAgentSessionStatusFeed', () => { role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const seen: (string | null)[] = [] const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) => @@ -613,7 +614,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(() => feed.publish(SESSION, journal)).not.toThrow() @@ -628,12 +629,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const snapshot = vi.spyOn(journal, 'snapshot') let taskState: 'working' | 'waiting' = 'working' @@ -662,7 +663,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const record = { options: { model: 'first-model' }, providerHandleChain: [] } const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) @@ -692,7 +693,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -731,7 +732,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) const before = events.length @@ -792,7 +793,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) // A second identical publication is deduped for the sink exactly as for subscribers, so the @@ -854,7 +855,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(() => feed.forget(SESSION)).not.toThrow() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 57218d0f4bd..ad081c6774f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -39,7 +40,7 @@ async function openJournal(): Promise { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index cb8b3d7d7c5..a0912e86dbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A subagent's work must not re-date the session that spawned it. // // The status row takes its completion stamp and acknowledgement clock from the summary's @@ -92,7 +93,7 @@ async function openSession() { journal.appendItem( { provider: 'orca', clientMessageId }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const latestStatus = () => { const event = events.findLast((candidate) => candidate.type === 'status') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 82f8c551642..c39cfe44e1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -2,7 +2,10 @@ 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_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent @@ -116,7 +119,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'clocked' }, { kind: 'status', text: 'Clocked' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) subscribers.backgroundTasks(SESSION, null, 1) @@ -253,12 +256,12 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { ...turn, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( turn, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) @@ -318,7 +321,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'after-background-fence' }, { kind: 'status', text: 'After background state' }, - { fence: 2 } + { fence: 2, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index b04a9b3d820..4594c8c38bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The lifetime of a provider child, against the real host rather than a double. // // Two leaks meet here: a chat that closes without stopping its app-server, and a launch that @@ -121,7 +122,8 @@ function envelope(method: string, fields: Record): AgentSession function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } } + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -157,7 +159,8 @@ async function failJournalSinkUntilReleased(): Promise { vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => { expect(closeSession).toHaveBeenCalledWith(SESSION) @@ -469,7 +472,8 @@ describe('a session closed and started again', () => { }) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) sink?.publish() await host.flushStreamedEvents(SESSION) @@ -797,7 +801,8 @@ describe('an unexpected provider exit', () => { expect(statuses).toContainEqual({ kind: 'status', text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' }) expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index f490eb2e9cd..9fddf6c7b34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,7 +58,7 @@ function appendGoalRow( return journal.appendItem( { provider: 'orca', clientMessageId: `goal-row:${journal.snapshot().items.length}` }, { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: { ...GOAL, ...overrides } } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 639daf6ab30..a779ba51abe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -70,7 +70,8 @@ export async function performThreadGoalChange( blocks: [{ type: 'text', text: change.objective }], sentAs: 'goal' }, - { fence: ctx.fence } + // Accepting a goal is delivering it, so it joins whatever turn runs now. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } const withdrawObjective = async (): Promise => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index 93d4a54cdc9..1c2ff9b9e53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -344,6 +344,39 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.feed.observe('session-unknown') expect(emit).not.toHaveBeenCalled() }) + + it('announces no /compact turn and keeps its mark on the last real turn (B6)', () => { + const h = harness() + h.listen() + const real = [userEntry('m1', 1), turnItem(turn('t1', 'completed', 'success'), 2)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(real, [accepted]) + h.observe() + const command: AgentJournalRenderItem = { + ...userEntry('c1', 3), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + } + const commandTurn = (lifecycle: AgentJournalTurnLifecycle): AgentJournalRenderItem => ({ + ...turnItem({ ...lifecycle, userItemId: command.itemId }, 4), + itemId: 'orca:command-turn:c1' + }) + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'running'))], + [accepted, pending('c1')] + ) + h.observe() + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'completed', 'success'))], + [accepted, sent('c1', { dispatchState: 'accepted' })] + ) + h.observe() + expect(h.events).toEqual([]) + }) }) describe('a request the agent or its start refused', () => { 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 new file mode 100644 index 00000000000..4bd5b412300 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -0,0 +1,125 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' +import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import { + isStructuredAgentSessionCommandTurnId, + structuredAgentSessionCommandWasStopped, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on. */ + stopChild?: () => Promise + } +): Promise> { + if (input.prompt) { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + } + let cancelled = false + let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } + // The turn the Stop names, read before the cancel settles it: the note reports on that turn. + const turnScope = ctx.journal.liveTurnScope() + // Only the provider's end or the child's ends a command. A command the provider has not opened a + // turn for, would not interrupt, or was already asked to stop, ends with its child; that child's + // dead-generation settlement writes the command's verdict. + const liveTurnId = ctx.journal.activeTurnId() + const runningCommand = + input.stopChild !== undefined && + liveTurnId !== null && + isStructuredAgentSessionCommandTurnId(liveTurnId) && + (input.turnId === undefined || input.turnId === liveTurnId) + const stoppedBefore = + runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) + try { + const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) + const outcome: AgentSessionCancelOutcome = stoppedBefore + ? { cancelled: false } + : input.scope + ? { + cancelled: + ( + await ctx.adapter.stopBackgroundTasks?.({ + sessionId: ctx.sessionId, + fence: ctx.fence, + ...(input.taskId ? { taskId: input.taskId } : {}) + }) + )?.cancelled === true + } + : await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), + fence: ctx.fence, + // The journal is what the client read to name a turn, so it is what judges the request. + resolveLiveTurnId: () => ctx.journal.activeTurnId(), + ...(dispatchStatus ? { dispatchStatus } : {}), + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) + }) + cancelled = outcome.cancelled + if (!cancelled && input.turnId !== undefined) { + note = { kind: 'status', text: 'The provider had already finished this turn.' } + } else if (!cancelled && input.prompt) { + note = null + } else if (!cancelled && outcome.unconfirmed) { + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + } else if (!cancelled) { + // Sent only while the chat reads working, so a Stop that ended nothing must say why. + const detail = outcome.refusal?.detail + note = { + kind: 'status', + ...agentSessionFailureWords( + agentSessionFailureFact('stopRefused', detail ? { detail } : {}), + { ...ctx.failureTextContext, surface: 'row' } + ) + } + } + } catch (error) { + if (input.prompt) { + throw error + } + // The adapter's error is Orca's; the row says only that the stop is unconfirmed. + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + } + if (runningCommand && !cancelled) { + await input.stopChild?.() + cancelled = true + note = { kind: 'status', text: 'Cancellation requested.' } + } + if (cancelled && input.prompt) { + await ctx.flushStreamedEvents() + } + const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } + if (input.scope || note === null) { + return { ok: true, value } + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await ctx.journal.appendItem( + structuredAgentSessionStopNoteIdentity(input.clientOperationId), + note, + { fence: ctx.fence, turnScope } + ) + return { ok: true, value } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index f10b64be1db..370dd12500d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -113,9 +113,8 @@ export async function performPrompt( committed.item = await ctx.journal.appendItem( identity, { ...prompt, resolution }, - { - fence: ctx.fence - } + // A revision: the prompt keeps the turn it was raised in. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } }) @@ -138,7 +137,7 @@ export async function performPrompt( surface: 'row' }) }, - { fence: ctx.fence } + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } const appended = committed.item diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 576743ebac4..302f6dd812f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -43,7 +44,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -91,7 +92,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) let resolveLiveTurnId: (() => string | null) | undefined const cancelTurn = vi.fn( @@ -123,7 +124,7 @@ describe('performCancel', () => { text: 'Done.', turnLifecycle: { turnId: 'turn-1', state: 'completed' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(resolveLiveTurnId?.()).toBeNull() }) @@ -143,7 +144,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const ctx: AgentSessionTurnContext = { sessionId: 'session-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 46c4638dc4e..61d47b4f408 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -13,30 +13,31 @@ import { } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, - AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { refuse, - type AgentSessionCancelResult, type AgentSessionRefusalReason, type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { - AgentSessionCancelOutcome, AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' -import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + handOverStructuredAgentSessionCommand, + structuredAgentSessionHandoverOrigin, + type StructuredAgentSessionCommandHandoverContext +} from './structured-agent-session-command-turn' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' +export { performCancel } from './structured-agent-session-turns-cancel' export type AgentSessionTurnContext = { sessionId: string @@ -99,14 +100,6 @@ async function dispatchSafely( } } -async function appendStatus( - ctx: AgentSessionTurnContext, - clientMessageId: string, - body: AgentJournalStatusItem -): Promise { - await ctx.journal.appendItem({ provider: 'orca', clientMessageId }, body, { fence: ctx.fence }) -} - /** * One id, one delivery. A submission that already exists replays its recorded * outcome and NEVER goes back on the wire, whatever state it is in and whatever @@ -154,13 +147,7 @@ export async function performSend( } } -export type AgentSessionHandoverContext = Pick< - AgentSessionTurnContext, - 'sessionId' | 'journal' | 'fence' | 'adapter' | 'providerChildPhase' -> & { - /** Who a start failure met at dispatch names, as the start's own row does. */ - failureTextContext?: AgentSessionFailureWordsContext -} +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash @@ -181,10 +168,25 @@ export async function handOverSubmission( }) return } - await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence }) - // The row written at acceptance is the send's instant on the host clock; the turn this - // dispatch opens records it so the live counter never re-anchors at turn-open. - const outcome = await dispatchSafely(ctx, clientMessageId, body, submission.submittedAt) + if (body.command) { + await handOverStructuredAgentSessionCommand(ctx, submission, body) + return + } + // The message joins the turn running at handover, a steer, or opens its own. + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + // The handover row's instant on the host clock; the turn this dispatch opens records it so the + // live counter never re-anchors at turn-open. + const outcome = await dispatchSafely( + ctx, + clientMessageId, + body, + structuredAgentSessionHandoverOrigin(ctx.journal, submission) + ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { return @@ -237,89 +239,3 @@ function requireSubmission( } return submission } - -export async function performCancel( - ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ - turnId?: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - } -): Promise> { - if (input.prompt) { - const validated = validatePendingPrompt(ctx, input.prompt) - if (!validated.ok) { - return validated - } - } - let cancelled = false - let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } - try { - const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) - const outcome: AgentSessionCancelOutcome = input.scope - ? { - cancelled: - ( - await ctx.adapter.stopBackgroundTasks?.({ - sessionId: ctx.sessionId, - fence: ctx.fence, - ...(input.taskId ? { taskId: input.taskId } : {}) - }) - )?.cancelled === true - } - : await ctx.adapter.cancelTurn({ - sessionId: ctx.sessionId, - ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), - fence: ctx.fence, - // The journal is what the client read to name a turn, so it is what judges the request. - resolveLiveTurnId: () => ctx.journal.activeTurnId(), - ...(dispatchStatus ? { dispatchStatus } : {}), - ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) - }) - cancelled = outcome.cancelled - if (!cancelled && input.turnId !== undefined) { - note = { kind: 'status', text: 'The provider had already finished this turn.' } - } else if (!cancelled && input.prompt) { - note = null - } else if (!cancelled && outcome.unconfirmed) { - note = { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { - surface: 'row' - }) - } - } else if (!cancelled) { - // Sent only while the chat reads working, so a Stop that ended nothing must say why. - const detail = outcome.refusal?.detail - note = { - kind: 'status', - ...agentSessionFailureWords( - agentSessionFailureFact('stopRefused', detail ? { detail } : {}), - { ...ctx.failureTextContext, surface: 'row' } - ) - } - } - } catch (error) { - if (input.prompt) { - throw error - } - // The adapter's error is Orca's; the row says only that the stop is unconfirmed. - note = { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) - } - } - if (cancelled && input.prompt) { - await ctx.flushStreamedEvents() - } - const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } - if (input.scope || note === null) { - return { ok: true, value } - } - // Keyed by the operation id so a replayed cancel upserts one item, not two. - await appendStatus(ctx, input.clientOperationId, note) - return { ok: true, value } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 451629f7b45..2cb38a46634 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -93,6 +93,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), @@ -165,6 +166,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -223,7 +225,18 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('The agent'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' + }, + // The exit belongs to the turn it ended. + turnScope: { + kind: 'turn', + turnItemId: agentJournalItemKey({ + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-2', + ordinal: 0 + }) } }, { @@ -235,7 +248,8 @@ describe('provider-exit settlement', () => { state: 'interrupted', startedAt: 30, completedAt: 1_234 - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -271,6 +285,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -313,7 +328,8 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('Claude'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' } }) ]) @@ -327,6 +343,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -364,7 +381,8 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('Claude'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' } }) ] @@ -377,6 +395,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 5025c5a6f82..3f758f81607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -25,7 +25,7 @@ type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { export type StructuredAgentSessionUnexpectedExitSession = Pick< StructuredAgentSessionHostSession, 'child' | 'lastEndedChild' -> & { journal: DeadGenerationJournal & Pick } +> & { journal: DeadGenerationJournal & Pick } export type StructuredAgentSessionUnexpectedExitContext< TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession @@ -106,7 +106,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, - failureTextContext: structuredAgentSessionFailureWordsContext(record), + failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index a492b0b65ce..05c57a5941c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The profiles already shipped into a dead end. // // Every record here is a shape taken from a real wedged store: a lease that no acquisition, no @@ -213,7 +214,7 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< ? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } : { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: SEEDED_TURN_STARTED_AT }, - { fence: 13 } + { fence: 13, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() } @@ -582,7 +583,7 @@ describe('already-wedged profiles become usable on load', () => { await restoredJournal().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 }, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW }, - { fence } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // A reconnecting client replays its attach; the same operation admits the live owner. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 585cbd4fbbd..cb9f4285d73 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -9,7 +9,10 @@ import type { import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { openJournalDatabase } from '../agent-session-journal/journal-database' import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { insertJournalRow } from '../agent-session-journal/journal-row-table' @@ -39,7 +42,10 @@ beforeEach(async () => { journalDir: root }) for (let ordinal = 1; ordinal <= 20; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 366ce1cf948..c3186d00daf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -16,7 +16,7 @@ import { agentSessionProviderHandleChainHead, agentSessionProviderHandleRoot } from '../../../shared/agent-session-provider-handle' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-latest-request' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -38,6 +38,12 @@ import { type AgentSessionRestartTask } from '../../../shared/agent-session-restart-activity' import { isLiveChildWork } from '../../../shared/agent-status-child-work-liveness' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { + isStructuredAgentSessionCommandEntry, + isStructuredAgentSessionCommandTurn +} from '../../../shared/structured-agent-session-command-entry' import { activeStructuredAgentSessionTurnId, newestStructuredAgentSessionTurn @@ -84,12 +90,31 @@ function structuredAgentSessionResumeWork( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] ): AgentSessionResumeWork | null { + const bodies = new Map(items.map((item) => [item.itemId, item.body])) + const bodyOf = (itemId: string) => bodies.get(itemId) + // A conversation command in flight is nothing to resume: the user ran it, not the agent. + const newest = newestStructuredAgentSessionTurn(items) + const inFlightSubmission = pendingSubmissionInFlight(submissions) + if ( + (newest?.state === 'running' && isStructuredAgentSessionCommandTurn(newest, bodyOf)) || + (inFlightSubmission && + isStructuredAgentSessionCommandEntry( + bodyOf(agentJournalSubmissionKey(inFlightSubmission.clientMessageId)) + )) + ) { + return null + } const inFlight = structuredAgentSessionWorkInFlight(items, submissions) if (inFlight) { return inFlight } - const newest = newestStructuredAgentSessionTurn(items) - return newest ? { kind: 'turn', id: newest.turnId } : null + // A settled lead whose children were the work anchors on its last real turn. + const lastRequest = items.findLast((item) => { + const turn = readAgentJournalTurn(item.body) + return turn !== null && !isStructuredAgentSessionCommandTurn(turn, bodyOf) + }) + const turn = readAgentJournalTurn(lastRequest?.body) + return turn ? { kind: 'turn', id: turn.turnId } : null } function boundedLabel(text: string | undefined): string { diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts deleted file mode 100644 index 4c565bcf4e2..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { settleInterruptedCompaction } from './structured-compaction-recovery' - -describe('settleInterruptedCompaction', () => { - it("records the unconfirmed compaction in its fact's own words, on the row and the command", async () => { - const command = { - command: 'compact' as const, - state: 'unknown' as const, - phase: 'prepared' as const, - operationId: 'op-1', - callerKey: 'client-1', - runtimeFence: 1 - } - const setConversationCommand = vi.fn(async () => undefined) - const store = { - getRecord: () => ({ conversationCommand: command }), - setConversationCommand, - recordOperationOutcome: vi.fn(async () => undefined) - } - const appendItem = vi.fn(async () => ({ cursor: { epoch: 'e', sequence: 1 } })) - - await settleInterruptedCompaction( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function reads only the members stubbed above. - store as unknown as AgentSessionRecordStore, - 'session-1', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function calls only `appendItem`. - { appendItem } as unknown as AgentSessionJournal, - 2 - ) - - const words = { - text: 'Compaction completion is unconfirmed.', - failure: { kind: 'compactionUnconfirmed' } - } - expect(appendItem).toHaveBeenCalledWith( - { provider: 'orca', clientMessageId: 'compact:op-1' }, - { kind: 'status', ...words }, - { fence: 2 } - ) - expect(setConversationCommand).toHaveBeenCalledWith( - 'session-1', - 2, - expect.objectContaining({ error: words.text, failure: words.failure, state: 'unknown' }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts deleted file mode 100644 index b581d746f62..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' - -/** A newly acquired owner cannot still be executing the previous owner's command. */ -export async function recoverInterruptedCompaction( - store: AgentSessionRecordStore, - sessionId: string, - journal: AgentSessionJournal, - fence: number -): Promise { - const command = store.getRecord(sessionId)?.conversationCommand - if (command?.runtimeFence === undefined || command.runtimeFence === fence) { - return - } - await settleInterruptedCompaction(store, sessionId, journal, fence) -} - -/** A compaction still prepared whose child can no longer finish it: its outcome is unknown. */ -export async function settleInterruptedCompaction( - store: AgentSessionRecordStore, - sessionId: string, - journal: AgentSessionJournal, - fence: number -): Promise { - const command = store.getRecord(sessionId)?.conversationCommand - if (command?.command !== 'compact' || command.phase !== 'prepared') { - return - } - const words = agentSessionFailureWords(agentSessionFailureFact('compactionUnconfirmed'), { - surface: 'row' - }) - await journal.appendItem( - { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', ...words }, - { fence } - ) - const recovered = { - ...command, - phase: 'committed' as const, - state: 'unknown' as const, - error: words.text, - failure: words.failure - } - await store.setConversationCommand(sessionId, fence, recovered) - await store.recordOperationOutcome({ - callerKey: command.callerKey, - operationId: command.operationId, - outcome: { status: 'succeeded', sessionId, conversationCommand: recovered } - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index fe3e443c495..be1002016b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -68,3 +68,40 @@ describe('conversationCommandBlocked background tasks', () => { ) }) }) + +describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => { + const staleTurn = { + items: [{ itemId: 'turn-1', body: { kind: 'turn', turnId: 'turn-1', state: 'running' } }] + } + + it("does not refuse over a dead generation's running turn, which the start sweeps", () => { + const ctx = contextWith(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body. + ctx.journal.snapshot = () => staleTurn as never + expect(conversationCommandBlocked(ctx, RECORD, 'at-rest')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD)).toMatchObject({ + details: { reason: 'turnActive' }, + message: 'Wait for the current turn to finish before using this command.' + }) + }) + + it("ignores an older build's compaction record", () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { command: 'compact', phase: 'prepared', state: 'unknown' } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + }) + + it('at handover, lets the command itself and messages queued behind it wait', () => { + const ctx = contextWith(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the dispatch fields. + const queued = [ + { clientMessageId: 'command', dispatchState: 'pending', handoverRecorded: true }, + { clientMessageId: 'behind', dispatchState: 'pending', handoverRecorded: true } + ] as never + ctx.journal.submissions = () => queued + expect(conversationCommandBlocked(ctx, RECORD, 'handover')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index cecdbbf0b13..06e6cadc537 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { @@ -14,9 +15,17 @@ function blocked( return refuse('agent_session_operation_invalid', { reason }, message) } +/** + * Why a conversation command may not run now; null when it may. + * + * `at-rest`: a command accepted with no child running. A running turn on record then belongs to a + * dead generation, which the start before handover sweeps, so it refuses nothing yet. + * `handover`: the command is the oldest queued message, and those queued behind it wait for it. + */ export function conversationCommandBlocked( - ctx: AgentSessionTurnContext, - record: AgentSessionRecord + ctx: Pick, + record: AgentSessionRecord, + admission?: 'at-rest' | 'handover' ): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { @@ -32,8 +41,10 @@ export function conversationCommandBlocked( 'This conversation has been cleared. Open the current conversation to continue.' ) } + // An older build's compaction record belongs to a child this host no longer runs. if ( - record.conversationCommand?.state === 'unknown' && + record.conversationCommand?.command === 'clear' && + record.conversationCommand.state === 'unknown' && record.conversationCommand.phase === 'prepared' ) { return blocked( @@ -44,7 +55,7 @@ export function conversationCommandBlocked( if (record.lease.handoffStage || record.lease.handoffOperationId) { return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } - if (activeStructuredAgentSessionTurnId(items)) { + if (admission !== 'at-rest' && activeStructuredAgentSessionTurnId(items)) { return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( @@ -74,7 +85,8 @@ export function conversationCommandBlocked( if ( ctx.journal.submissions().some( (entry) => - entry.dispatchState === 'pending' || + (entry.dispatchState === 'pending' && + !(admission === 'handover' && isQueuedAgentJournalSubmission(entry))) || // Doubt left by an earlier child is not this one's work in flight. (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 662ec70e8dd..b0c4dd0f92e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -4,15 +4,21 @@ import { runStructuredConversationCommand, type ConversationCommandParams } from './structured-conversation-command' +import { runStructuredCompaction } from './structured-conversation-compaction' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' export class StructuredConversationCommandController { + /** Held only by a clear, which replaces the conversation a send would land in. A compaction is + * a queued message, and sends accepted behind it wait for it in the queue. */ readonly pending = new Map() constructor( private readonly context: () => StructuredAgentSessionMutationContext, - private readonly host: Pick + private readonly host: Pick< + StructuredAgentSessionHost, + 'attach' | 'flushStreamedEvents' | 'waitForSendSettlement' + > ) {} send = ( caller: StructuredAgentSessionCaller, @@ -30,6 +36,9 @@ export class StructuredConversationCommandController { : sendStructuredAgentSessionTurn(this.context(), caller, params) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { + if (params.command === 'compact') { + return runStructuredCompaction(this.context(), this.host, caller, params) + } const key = JSON.stringify([caller.callerKey, params.envelope.clientOperationId]) const pending = this.pending.get(params.envelope.sessionId) if (pending && pending.key !== key) { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts new file mode 100644 index 00000000000..a2c0e9aef6b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { structuredCompactionOutcome } from './structured-conversation-command-outcome' + +describe('structuredCompactionOutcome', () => { + it('is a success only when the provider reported the compaction', () => { + expect(structuredCompactionOutcome({ compacted: true, interruptRequested: true })).toEqual({ + outcome: 'success' + }) + }) + + it("reads no compaction after Orca's interrupt as the user's cancellation", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: true, + failed: { detail: { text: 'API Error: Request was aborted.', audience: 'person' } } + }) + ).toEqual({ outcome: 'cancellation' }) + }) + + it("reads a failure the provider reported as a failed compaction, keeping the provider's words", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: false, + failed: { detail: { text: 'Not enough messages to compact.', audience: 'person' } } + }) + ).toEqual({ + outcome: 'failure', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + } + }) + expect( + structuredCompactionOutcome({ compacted: false, interruptRequested: false, failed: {} }) + ).toEqual({ outcome: 'failure', failure: { kind: 'compactionFailed' } }) + }) + + it('reads a compaction the provider never reported as a failure it did not confirm', () => { + expect(structuredCompactionOutcome({ compacted: false, interruptRequested: false })).toEqual({ + outcome: 'failure', + failure: { kind: 'compactionUnconfirmed' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts new file mode 100644 index 00000000000..cc9513a6ad1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts @@ -0,0 +1,44 @@ +// How a conversation command the provider ran ended, read the same way for every provider. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' + +export type StructuredConversationCommandOutcome = { + outcome: 'success' | 'failure' | 'cancellation' + /** On a failure: what its row reports. */ + failure?: AgentSessionFailureFact +} + +/** What the provider showed of one compaction while it ran. */ +export type StructuredCompactionEvidence = { + /** The provider reported the conversation compacted: Claude's boundary, Codex's item. */ + compacted: boolean + /** Orca asked the provider to stop the command. */ + interruptRequested: boolean + /** The provider said the compaction failed, with its words for a person when it gave any. */ + failed?: { detail?: ProviderDiagnostic } | null +} + +/** Only a compaction the provider reported doing is a success: Claude answers a stopped `/compact` + * with the same success result as a finished one, so the result's own verdict cannot decide. With + * none, one Orca asked to stop is the user's cancellation; anything else failed — reported as the + * provider's failure when it said so, and otherwise as a compaction it never confirmed. */ +export function structuredCompactionOutcome( + evidence: StructuredCompactionEvidence +): StructuredConversationCommandOutcome { + if (evidence.compacted) { + return { outcome: 'success' } + } + if (evidence.interruptRequested) { + return { outcome: 'cancellation' } + } + return { + outcome: 'failure', + failure: evidence.failed + ? agentSessionFailureFact('compactionFailed', { detail: evidence.failed.detail }) + : agentSessionFailureFact('compactionUnconfirmed') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 445326f60b0..12e3c706208 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -10,7 +10,6 @@ import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' import { HOST_TEST_NOW, HOST_TEST_SESSION, @@ -47,7 +46,7 @@ function commandParams(command: AgentSessionConversationCommand) { beforeEach(async () => { resetHostTestOperationIds() acquisitions = 0 - compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) + compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) store = await AgentSessionRecordStore.open({ directory: join(directory, 'store'), @@ -109,84 +108,6 @@ afterEach(async () => { }) describe('host conversation commands', () => { - it('compacts once without an ordinary message submission and replays its receipt', async () => { - const params = commandParams('compact') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - value: { state: 'completed' } - }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true - }) - expect(compact).toHaveBeenCalledTimes(1) - expect(adapter.dispatch).not.toHaveBeenCalled() - const history = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(history.page.submissions).toEqual([]) - expect( - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.turnLifecycle?.state === 'running' - ) - ).toBe(false) - }) - - it('reports provider compaction failure without a stuck lifecycle', async () => { - const detail = { text: 'Not enough messages to compact.', audience: 'person' as const } - compact.mockResolvedValue({ outcome: 'failed', detail }) - const failure = { kind: 'compactionFailed', detail } - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { - state: 'completed', - error: 'Compaction failed: Not enough messages to compact.', - failure - } - }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.state).toBe('completed') - const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ - kind: 'status', - text: 'Compaction failed: Not enough messages to compact.', - failure - }) - }) - - it('says only that compaction failed when the provider gave no words', async () => { - compact.mockResolvedValue({ outcome: 'failed' }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { error: 'Compaction failed.', failure: { kind: 'compactionFailed' } } - }) - }) - - it('records a compaction the provider never confirmed as unconfirmed, not failed', async () => { - compact.mockResolvedValue({ outcome: 'unconfirmed' }) - const failure = { kind: 'compactionUnconfirmed' } - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { error: 'Compaction completion is unconfirmed.', failure } - }) - const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ - kind: 'status', - text: 'Compaction completion is unconfirmed.', - failure - }) - }) - - it('keeps an unknown compaction from being executed again', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow('connection lost') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - - /** The replacement seeds from what the provider reports now, not from what the - * retired record happened to store — the same rule acquire and handoff apply. */ it('adopts the reported Fast preference into the replacement record', async () => { adapter.readOptions = async () => ({ models: [], @@ -319,41 +240,8 @@ describe('host conversation commands', () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) - expect(compact).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) }) - it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { - let finish!: (value: StructuredSessionCompactionResult) => void - compact.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const params = commandParams('compact') - const running = host.conversationCommand(caller, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalled()) - expect( - await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) - ).toMatchObject({ ok: false }) - const turnId = `compact:${params.envelope.clientOperationId}` - const cancel = await host.cancel(caller, { - turnId, - envelope: { - ...params.envelope, - clientOperationId: hostTestOperationId(), - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: HOST_TEST_SESSION, - fields: { turnId } - }) - } - }) - expect(cancel).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(adapter.cancelTurn).toHaveBeenCalled() - finish({ outcome: 'compacted' }) - await running - }) - it('reconstructs a committed replacement after the ledger settlement is lost', async () => { const persist = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { @@ -372,19 +260,6 @@ describe('host conversation commands', () => { expect(acquisitions).toBe(2) }) - it('repairs an unknown receipt when the provider completes late', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await compact.mock.calls[0]![0].onLateResult?.({ outcome: 'compacted' }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { state: 'completed' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - it('keeps explicitly revealed history and closed replacement tabs out of automatic restoration', async () => { const result = await host.conversationCommand(caller, commandParams('clear')) if (!result.ok) { @@ -397,26 +272,4 @@ describe('host conversation commands', () => { await host.setSessionTabVisibility(result.value.replacementSessionId!, false) expect(host.conversationReplacements()).toEqual([]) }) - it('keeps the old compact outcome unknown but restores usability after verified reacquisition', async () => { - compact.mockRejectedValue(new Error('lost response')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await host.close(HOST_TEST_SESSION) - const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - state: 'unknown' - }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { state: 'unknown' } - }) - compact.mockResolvedValue({ outcome: 'compacted' }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { state: 'completed' } - }) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 2075c24d7bb..af115cfff9e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -23,39 +23,15 @@ import { import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { conversationCommandBlocked } from './structured-conversation-command-admission' -import { - agentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../shared/agent-session-failure' +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' - -/** A compaction that did not succeed, keeping only what the provider wrote for a person. */ -function compactionFailure( - result: StructuredSessionCompactionResult -): AgentSessionFailureFact | undefined { - switch (result.outcome) { - case 'compacted': - return undefined - case 'unconfirmed': - return agentSessionFailureFact('compactionUnconfirmed') - case 'failed': - return agentSessionFailureFact('compactionFailed', { detail: result.detail }) - } -} - -function compactionStatusBody(failure: AgentSessionFailureFact | undefined) { - return failure - ? { kind: 'status' as const, ...agentSessionFailureWords(failure, { surface: 'row' }) } - : { kind: 'status' as const, text: 'Conversation compacted.' } -} /** A command's `error` is the sentence its row shows. */ -function conversationCommandFailure( +export function conversationCommandFailure( failure: AgentSessionFailureFact | undefined, context: AgentSessionFailureWordsContext = {} ) { @@ -114,19 +90,7 @@ export function runStructuredConversationCommand( return outcome.conversationCommand } const prior = matching() - if (prior?.phase === 'committed') { - return prior - } - if (command === 'compact' && prior && outcome.status !== 'unknown') { - return { - command, - state: 'unknown', - error: 'Compaction completion is unconfirmed; it was not run again.' - } - } - return outcome.status === 'succeeded' && command === 'compact' - ? { command, state: 'completed' } - : null + return prior?.phase === 'committed' ? prior : null }, rerunWhenReplayMissing: () => command === 'clear' && matching()?.phase === 'prepared', run: async (ctx) => { @@ -158,7 +122,6 @@ export function runStructuredConversationCommand( ...(replacementSessionId ? { replacementSessionId } : {}) } await store.setConversationCommand(sessionId, ctx.fence, prepared) - let failure: AgentSessionFailureFact | undefined if (command === 'clear' && replacementSessionId) { const attach: AgentSessionAttachParams = { envelope: { @@ -211,78 +174,11 @@ export function runStructuredConversationCommand( await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } } - } else { - if (!ctx.adapter.compact) { - throw new Error('Compaction is unavailable for this provider.') - } - const identity = { - provider: 'orca' as const, - clientMessageId: `compact:${clientOperationId}` - } - await ctx.journal.appendItem( - identity, - { - kind: 'status', - text: 'Compacting conversation…', - turnLifecycle: { turnId: `compact:${clientOperationId}`, state: 'running' } - }, - { fence: ctx.fence } - ) - try { - failure = compactionFailure( - await ctx.adapter.compact({ - turnId: `compact:${clientOperationId}`, - sessionId, - fence: ctx.fence, - onLateResult: (result) => - context.serialize(sessionId, async () => { - if ( - matching()?.phase !== 'prepared' || - context.sessions.get(sessionId)?.journal !== ctx.journal - ) { - return - } - await host.flushStreamedEvents(sessionId) - const late = compactionFailure(result) - await ctx.journal.appendItem(identity, compactionStatusBody(late), { - fence: ctx.fence - }) - await store.setConversationCommand(sessionId, ctx.fence, { - ...prepared, - phase: 'committed', - state: 'completed', - ...conversationCommandFailure(late) - }) - await store.recordOperationOutcome({ - callerKey: caller.callerKey, - operationId: clientOperationId, - outcome: { - status: 'succeeded', - sessionId, - conversationCommand: matching()! - } - }) - }) - }) - ) - await host.flushStreamedEvents(sessionId) - } catch (cause) { - await ctx.journal.appendItem( - identity, - compactionStatusBody(agentSessionFailureFact('compactionUnconfirmed')), - { fence: ctx.fence } - ) - throw cause - } - await ctx.journal.appendItem(identity, compactionStatusBody(failure), { - fence: ctx.fence - }) } const completed = { ...prepared, phase: 'committed' as const, - state: 'completed' as const, - ...conversationCommandFailure(failure) + state: 'completed' as const } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } 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 new file mode 100644 index 00000000000..28eae2dff68 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -0,0 +1,751 @@ +// `/compact` travels the send path: accepted as the user's message, carried out by the delivery loop +// as a turn of its own, settled by re-reading the journal. Each case reads what a subscriber that +// was open before the command saw, or the journal a client would load. + +import { beforeEach, expect, it, vi, type Mock } from 'vitest' +import { + AgentJournalSubmissionSchema, + isAdmissibleAgentJournalItemBody +} from '../../../shared/agent-session-journal-schemas' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import type { 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' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { StructuredConversationCommandOutcome } from './structured-conversation-command-outcome' + +let state: ReturnType +let compact: Mock> +let closeSession: Mock> + +beforeEach(() => { + state = hostTestState() + // Codex's ack: the provider took the command, which its translator ends later. + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + closeSession = vi.fn(async () => true) + Object.assign(state.host.deps.adapter, { compact, closeSession }) +}) + +/** What the child's journal translator writes when the provider ends the command: the command's + * one result row and its turn's end, in one batch. */ +function finish(result: StructuredConversationCommandOutcome): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + const row: AgentJournalItemBody | null = + result.outcome === 'success' + ? { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + : result.outcome === 'failure' + ? { + kind: 'status', + ...agentSessionFailureWords( + result.failure ?? agentSessionFailureFact('compactionUnconfirmed'), + { surface: 'row' } + ), + tone: 'error' + } + : null + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + ...(row + ? [{ kind: 'item' as const, identity: command.resultIdentity, body: row, turnScope }] + : []), + { + kind: 'item', + identity: command.identity, + body: { + ...command.running, + state: result.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: result.outcome, + completedAt: HOST_TEST_NOW + }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +/** Claude's words for a compaction it refused, as its frames carry them. */ +const NOT_ENOUGH = { text: 'Not enough messages to compact.', audience: 'person' as const } + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function subscribe(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await state.host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + return events +} + +/** One line per journal fact a subscriber received, in delivery order. */ +function frames(events: AgentSessionSubscribeEvent[]): string[] { + return events.flatMap((event) => + event.type === 'batch' + ? [ + ...event.batch.items.map(describeItem), + ...event.batch.submissions.map( + (entry) => `submission:${entry.dispatchState}${entry.handedOverAt ? ':handed' : ''}` + ) + ] + : [] + ) +} + +function describeItem(item: AgentJournalRenderItem): string { + const turn = readAgentJournalTurn(item.body) + if (turn) { + return `turn:${turn.state}${turn.outcome ? `:${turn.outcome}` : ''}` + } + return item.body.kind === 'status' ? `status:${item.body.text}` : item.body.kind +} + +async function journal() { + return state.host.journalSnapshot(SESSION) +} + +async function commandTurn(clientMessageId: string) { + const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) + return (await journal()).items.find((item) => item.itemId === itemId) +} + +it('answers at handover, then journals its own entry, turn and result (B1, B16)', async () => { + await attach() + const events = await subscribe() + const params = compactParams() + const cmid = params.envelope.clientOperationId + + // The reply means "started"; the provider has not finished. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { command: 'compact', state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('completed') + ) + // A client saw the command working before it saw the command's end. + const facts = frames(events) + const running = facts.indexOf('turn:running') + const ended = facts.indexOf('turn:completed:success') + expect(running, facts.join('\n')).toBeGreaterThanOrEqual(0) + expect(ended).toBeGreaterThan(running) + expect(facts.indexOf('status:Context compacted')).toBeGreaterThan(running) + // No turn row was ever overwritten by another body kind. + const turnKey = structuredAgentSessionCommandTurn(cmid).itemId + for (const event of events) { + for (const item of event.type === 'batch' ? event.batch.items : []) { + expect(item.itemId !== turnKey || item.body.kind === 'turn').toBe(true) + } + } + const snapshot = await journal() + const entry = snapshot.items.find((item) => item.body.kind === 'message') + expect(entry?.body).toMatchObject({ command: { name: 'compact' } }) + expect(entry?.turnScope).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + const result = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.presentation === 'compaction' + ) + expect(result?.turnScope).toEqual({ kind: 'turn', turnItemId: turnKey }) + const submission = snapshot.submissions.find((item) => item.clientMessageId === cmid) + expect(submission).toMatchObject({ dispatchState: 'accepted', providerItemId: null }) + // An older client's schemas take the accepted submission with no provider item. + expect(AgentJournalSubmissionSchema.safeParse(submission).success).toBe(true) + expect(isAdmissibleAgentJournalItemBody(entry!.body)).toBe(true) + expect(state.dispatch).not.toHaveBeenCalled() +}) + +it('replays the reply for the same operation without running it again (B16)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('holds messages sent during the command and delivers them after it, in order (B2)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('first'))).resolves.toMatchObject({ ok: true }) + await expect(state.host.send(CALLER, sendParams('second'))).resolves.toMatchObject({ ok: true }) + // Nothing is handed over while the command's turn runs. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(state.dispatch).not.toHaveBeenCalled() + + finish({ + outcome: 'failure', + failure: agentSessionFailureFact('compactionFailed', { detail: NOT_ENOUGH }) + }) + + // Delivered even though the command failed. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledTimes(2)) + expect(state.dispatch.mock.calls.map(([input]) => input.body.blocks)).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'second' }] + ]) + const turn = await commandTurn(params.envelope.clientOperationId) + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'completed', outcome: 'failure' }) + const error = (await journal()).items.find( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + expect(error?.body).toMatchObject({ + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH } + }) +}) + +it('hands over a message held behind the command when the command ends just as the loop stops for it', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const activeTurnId = live.activeTurnId + let ended = false + vi.spyOn(live, 'activeTurnId').mockImplementation(() => { + const read = activeTurnId() + // The provider's end lands the moment the loop's own step reads the command as running and + // stops; no other reader's view of it matters here. + if ( + !ended && + read?.startsWith('compact:') && + new Error('who reads').stack?.includes('StructuredAgentSessionDeliveryLoop.prepare') + ) { + ended = true + finish({ outcome: 'success' }) + } + return read + }) + + await expect(state.host.send(CALLER, sendParams('held'))).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(ended).toBe(true) +}) + +it('settles a command the provider refused as a failure with its reason, and moves on (B3)', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await state.host.send(CALLER, sendParams('after the refusal')) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + const snapshot = await journal() + // The message was not sent, in the provider's words; the command's row says the compaction failed. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message: Not enough messages to compact.', + rejection: { kind: 'providerRejected', detail: NOT_ENOUGH } + }) + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH }, + tone: 'error' + }, + turnScope: { kind: 'turn', turnItemId: structuredAgentSessionCommandTurn(cmid).itemId } + }) + ]) +}) + +it('says only that the compaction failed when the provider refused it without words', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }) + }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + + await vi.waitFor(async () => + expect( + (await journal()).items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + ) +}) + +it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { + await attach() + const events = state.acquire.mock.calls.at(-1)?.[0].events + // The provider starts a turn of its own after acceptance, before the command is handed over. + Object.assign(state.host.deps.adapter, { + awaitStarted: vi.fn(async () => { + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + }) + }) + const params = compactParams() + + const refused = { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } + } + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } + }) + expect(compact).not.toHaveBeenCalled() + expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() + expect( + (await journal()).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "This command didn't run. Try it again.", + rejection: refused + }) +}) + +it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { + await attach() + await state.host.close(SESSION) + state.acquire.mockRejectedValue(new Error('not signed in')) + const params = compactParams() + + // The next step is the command again, not a message. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { + state: 'completed', + error: "Codex couldn't restart. Run /compact again.", + failure: { kind: 'restartFailed' } + } + }) + const snapshot = await journal() + expect(snapshot.items.filter((item) => readAgentJournalTurn(item.body))).toEqual([]) + // The start's own row, in the words the command's message was refused with. + expect( + snapshot.items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: "Codex couldn't restart. Run /compact again.", + failure: expect.objectContaining({ kind: 'restartFailed' }), + tone: 'error' + } + ]) + expect( + snapshot.submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Run /compact again." + }) + expect(compact).not.toHaveBeenCalled() +}) + +function stop(turnId: string) { + return state.host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId + }) +} + +it('leaves the command to the provider when it takes the Stop, and ends it as cancelled (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + // The interrupt was taken, not answered: the command runs until the provider ends it. + expect(state.cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId })) + expect(closeSession).not.toHaveBeenCalled() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + finish({ outcome: 'cancellation' }) + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + // A cancellation writes no result row. + expect((await journal()).items.some((item) => item.itemId.includes('command-result'))).toBe(false) +}) + +it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + // 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() + 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') +}) + +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. + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + + await expect(stop(structuredAgentSessionCommandTurn(cmid).turnId)).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) + + expect(closeSession).toHaveBeenCalledOnce() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + const notes = (await journal()).items.filter((item) => item.body.kind === 'status') + expect(notes.map((item) => item.body.kind === 'status' && item.body.text)).toEqual([ + 'Cancellation requested.' + ]) + // The next message starts a child of its own. + await state.host.send(CALLER, sendParams('after the stop')) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) +}) + +it('does not stop the child for a Stop naming a command that already ended (B4)', async () => { + await attach() + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const { itemId, turnId } = structuredAgentSessionCommandTurn(params.envelope.clientOperationId) + await vi.waitFor(async () => + expect( + readAgentJournalTurn((await journal()).items.find((item) => item.itemId === itemId)?.body) + ?.state + ).toBe('completed') + ) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + expect(closeSession).not.toHaveBeenCalled() +}) + +it("answers a refused command's message before ending its turn, so a crash between them leaves a turn the sweep settles (B4)", async () => { + await attach() + // The one command end the host still writes: the provider refused it. A provider's own end is + // one batch its translator writes, with nothing to split. + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + const crash = vi + .spyOn(live, 'appendLifecycleBatch') + .mockImplementation((input) => + input.settlementId.startsWith('command-settled:') + ? Promise.reject(new Error('host crashed')) + : appendLifecycleBatch(input) + ) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(crash).toHaveBeenCalled()) + crash.mockRestore() + + // Never an ended turn whose message still reads as in flight. + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState + ).toBe('rejected') + await settleStaleStructuredAgentSessionState({ + journal: live, + sessionId: SESSION, + fence: state.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: null, + deathEvidence: null + }) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') +}) + +it('counts a message held behind the command from its handover, not its send', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const sent = await state.host.send(CALLER, sendParams('held')) + expect(sent.ok).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 20)) + finish({ outcome: 'success' }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + const [dispatched] = state.dispatch.mock.calls[0]! + const submission = (await journal()).submissions.find( + (entry) => entry.clientMessageId === dispatched.clientMessageId + ) + expect(submission?.handedOverAt).toBeGreaterThan(submission!.submittedAt) + expect(dispatched.requestedAt).toBe(submission?.handedOverAt) +}) + +it('settles a command whose adapter call threw after the start as unknown (B4)', async () => { + await attach() + compact.mockImplementation(() => { + throw new Error('codex app-server session is not live') + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') + ) + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid) + ).toMatchObject({ dispatchState: 'unknown', reason: 'codex app-server session is not live' }) +}) + +it('writes one exit row when the child dies mid-command, and the loop writes nothing but moves on (B4)', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // The next child resumes the thread, as a real one does. + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('queued behind it'))).resolves.toMatchObject({ + ok: true + }) + + // The adapter never answers the command: the host's own record of the child's end is enough. + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + // Released from the command, the loop starts a child and delivers what waited behind it. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + const snapshot = await journal() + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) + expect(snapshot.items.some((item) => item.itemId.includes('command-result'))).toBe(false) + // Codex acknowledged the command, so the message was delivered; only its turn was cut short. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState).toBe( + 'accepted' + ) +}) + +it('delivers the next message after a command whose child died and whose settlement could not be written', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + vi.spyOn(live, 'appendLifecycleBatch').mockImplementation((input) => + input.settlementId.includes('provider-exit:') + ? Promise.reject(new Error('disk full')) + : appendLifecycleBatch(input) + ) + + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + await vi.waitFor(() => expect(state.host['sessions'].get(SESSION)?.child).toBeNull()) + // The exit it recorded settles what the failed write left running, so the command holds nothing. + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + + await expect(state.host.send(CALLER, sendParams('after it'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') +}) + +it("ignores an older build's unconfirmed compaction record, and answers its operation without rerunning it (B15)", async () => { + await attach() + const older = compactParams() + // An older build admitted this operation and died before recording its outcome. + await state.store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: older.envelope, + hostFingerprint: older.envelope.payloadFingerprint, + now: HOST_TEST_NOW + }) + await state.store.setConversationCommand( + SESSION, + state.store.getRecord(SESSION)!.lease.runtimeFence, + { + command: 'compact', + runtimeFence: state.store.getRecord(SESSION)!.lease.runtimeFence, + operationId: older.envelope.clientOperationId, + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + } + ) + + await expect(state.host.send(CALLER, sendParams('still works'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await expect(state.host.conversationCommand(CALLER, compactParams())).resolves.toMatchObject({ + ok: true, + value: { state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await vi.waitFor(async () => + expect((await journal()).submissions.every((entry) => entry.dispatchState === 'accepted')).toBe( + true + ) + ) + + await expect(state.host.conversationCommand(CALLER, older)).resolves.toMatchObject({ + ok: true, + value: { + state: 'unknown', + error: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' } + } + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('never lets a provider echo alias the command entry', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const events = state.acquire.mock.calls.at(-1)?.[0].events + const echo = { provider: 'codex' as const, threadId: THREAD, turnId: 'later', ordinal: 0 } + events?.appendItem( + echo, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await state.host.flushStreamedEvents(SESSION) + expect((await journal()).items.some((item) => item.itemId === agentJournalItemKey(echo))).toBe( + true + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts new file mode 100644 index 00000000000..352497fe482 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -0,0 +1,156 @@ +// `/compact` sent through the command RPC: accepted into the conversation as the user's message, +// and answered once the delivery loop hands it over. + +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' +import { + agentSessionFailureFact, + readAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { + conversationCommandPlan, + type ConversationCommandAcceptance +} from './structured-agent-session-mutation-plans' +import { + sendPreparation, + structuredAgentSessionFailureWordsContext, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' +import { + conversationCommandFailure, + type ConversationCommandParams +} from './structured-conversation-command' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +/** + * `/compact` from a client that asks through the command RPC: accepted into the conversation like + * any message, and answered once it is handed over — the command has started, not finished. Its + * end reaches the chat as its own turn and result row. + */ +export async function runStructuredCompaction( + context: StructuredAgentSessionMutationContext, + host: Pick, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams +): Promise> { + const { sessionId, clientOperationId } = params.envelope + // An older build ran this operation id and recorded it on the session: answered, never rerun. + const priorRecord = (): AgentSessionConversationCommandResult | null => { + const prior = context.deps.store.getRecord(sessionId)?.conversationCommand + if (prior?.operationId !== clientOperationId || prior.callerKey !== caller.callerKey) { + return null + } + return prior.phase === 'committed' + ? prior + : { + command: 'compact', + state: 'unknown', + ...conversationCommandFailure(agentSessionFailureFact('compactionUnconfirmed')) + } + } + const accepted = await acceptStructuredConversationCommand(context, caller, params, priorRecord) + if (!accepted.ok) { + return accepted + } + if ('recorded' in accepted.value) { + return { ...accepted, value: accepted.value.recorded } + } + const settled = await host.waitForSendSettlement(sessionId, accepted.value.clientMessageId, { + until: 'handed-over', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS + }) + return { + ...accepted, + ...(settled ? { cursor: settled.cursor } : {}), + value: compactionReply(settled?.value.submission, { + ...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)), + command: 'compact' + }) + } +} + +/** The reply shape clients already read: `completed` is now "started". */ +function compactionReply( + submission: AgentJournalSubmission | undefined, + context: AgentSessionFailureWordsContext +): AgentSessionConversationCommandResult { + const error = (reason: string | null, fallback: string) => (reason ?? fallback).slice(0, 4096) + if (!submission || isQueuedAgentJournalSubmission(submission)) { + return { command: 'compact', state: 'unknown', error: 'The command has not started yet.' } + } + if (submission.dispatchState === 'rejected') { + // The message's own fact, in the words its row shows; a row from an older host keeps its reason. + const rejection = readAgentSessionFailureFact(submission.rejection) + return rejection + ? { + command: 'compact', + state: 'completed', + ...conversationCommandFailure(rejection, context) + } + : { + command: 'compact', + state: 'completed', + error: error(submission.reason, 'The command was not run.') + } + } + return submission.dispatchState === 'unknown' + ? { + command: 'compact', + state: 'unknown', + error: error(submission.reason, 'The command may not have run.') + } + : { command: 'compact', state: 'completed' } +} + +/** A conversation command, accepted into the queue as the user's message. */ +function acceptStructuredConversationCommand( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope }, + priorRecord: () => AgentSessionConversationCommandResult | null +): Promise> { + const plan = conversationCommandPlan({ envelope: params.envelope, priorRecord }) + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + run: async (ctx) => { + const record = context.deps.store.getRecord(ctx.sessionId) + const refusal = + record && + conversationCommandBlocked( + ctx, + record, + context.sessions.get(ctx.sessionId)?.child ? undefined : 'at-rest' + ) + const blocked = + structuredAgentSessionSendBlock(record) ?? + (refusal ? { ok: false as const, refusal } : null) + if (blocked) { + return blocked + } + const accepted = await plan.run(ctx) + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted + } + }, + sendPreparation(context, params.envelope) + ) +} 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 42b6e8dd91d..a1d7e41100d 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 @@ -1,5 +1,7 @@ -import { restoreRewindJournalBody } from './structured-rewind-journal-body' -import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, @@ -141,13 +143,7 @@ export async function recoverStructuredRewind( if (rewind.phase !== 'provider-succeeded') { return } - const replacement = rewind.retained.map((item) => { - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - throw new Error('agent_session_rewind:invalid-retained-identity') - } - return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt } - }) + const replacement = rewind.retained.map(retainedRowReplacement) // A crash after the journal transaction must settle its existing epoch, not replace it twice. const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch if ( diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts new file mode 100644 index 00000000000..5b47be7f9f8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + AgentSessionRewindRecordSchema, + type AgentSessionRewindRecord +} from '../../../shared/agent-session-rewind' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' +import type * as RewindJournalBody from './structured-rewind-journal-body' + +vi.mock('./structured-rewind-journal-body', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, restoreRewindJournalBody: vi.fn(actual.restoreRewindJournalBody) } +}) + +type Retained = AgentSessionRewindRecord['retained'][number] + +const codexKey = (turnId: string, ordinal: number) => + agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal }) +const orcaKey = (clientMessageId: string) => + agentJournalItemKey({ provider: 'orca', clientMessageId }) + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function retained(itemId: string, body: AgentJournalItemBody, extra: Partial = {}) { + return { itemId, body, observedAt: 1, ...extra } +} + +/** Provider history carries no scope or producer of its own. */ +function providerItem(itemId: string, body: AgentJournalItemBody): Retained { + return { itemId, body, observedAt: 2 } +} + +const TURN_RECORD = orcaKey('turn-a') +const inTurnA = { kind: 'turn', turnItemId: TURN_RECORD } + +describe('rewind keeps each retained row attributed', () => { + it("keeps a held row's scope and producer, a subagent's row staying the subagent's", () => { + const own = codexKey('a', 1) + const child = codexKey('a', 2) + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(own, prose('own'), { turnScope: inTurnA }), + retained(child, prose('child'), { + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(own, prose('own')), + providerItem(child, prose('child')) + ]) + expect(merged.find((item) => item.itemId === own)).toMatchObject({ turnScope: inTurnA }) + expect(merged.find((item) => item.itemId === own)?.agentId).toBeUndefined() + expect(merged.find((item) => item.itemId === child)).toMatchObject({ + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent', + observedAt: 2 + }) + }) + + it("splices a command's turn, result and entry back, since provider history holds none", () => { + const before = codexKey('a', 1) + const commandTurn = orcaKey('command-turn:cmd-1') + const commandResult = orcaKey('command-result:cmd-1') + const entry = orcaKey('cmd-1') + const reference = [ + retained(before, prose('before')), + retained(entry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + retained(commandTurn, { kind: 'turn', turnId: 'compact:cmd-1', state: 'completed' }), + retained( + commandResult, + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + { turnScope: { kind: 'turn', turnItemId: commandTurn } } + ) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(before, prose('before')) + ]) + expect(merged.map((item) => item.itemId)).toEqual([before, entry, commandTurn, commandResult]) + expect(merged.at(-1)?.turnScope).toEqual({ kind: 'turn', turnItemId: commandTurn }) + }) + + it('places a provider item the old epoch never held in the turn record for its provider turn', () => { + const commandTurn = orcaKey('command-turn:cmd-1') + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'completed', + providerTurnId: 'b' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(codexKey('a', 1), prose('in a')), + providerItem(codexKey('b', 1), prose('claimed by the command')), + providerItem(codexKey('c', 1), prose('no record')) + ]) + const scopeOf = (itemId: string) => merged.find((item) => item.itemId === itemId)?.turnScope + expect(scopeOf(codexKey('a', 1))).toEqual(inTurnA) + expect(scopeOf(codexKey('b', 1))).toEqual({ kind: 'turn', turnItemId: commandTurn }) + // No record to join: the rebuilt epoch places it by position. + expect(scopeOf(codexKey('c', 1))).toBeUndefined() + }) +}) + +describe('the rebuilt epoch item for a retained row', () => { + it('passes a placeable scope and a known producer through', () => { + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: inTurnA, + agentId: 'child', + producerKind: 'agent', + attempt: 2 + }) + ) + ).toMatchObject({ turnScope: inTurnA, agentId: 'child', producerKind: 'agent', attempt: 2 }) + }) + + it('drops a scope and a producer kind this build cannot place', () => { + const replacement = retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: { kind: 'future-kind' }, + producerKind: 'future-producer' + }) + ) + expect(replacement.turnScope).toBeUndefined() + expect(replacement.producerKind).toBeUndefined() + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { turnScope: { kind: 'turn' } }) + ).turnScope + ).toBeUndefined() + }) + + it('leaves a row from an older record unscoped, for the rebuild to derive', () => { + const replacement = retainedRowReplacement(retained(codexKey('a', 1), prose('x'))) + expect(replacement).not.toHaveProperty('turnScope') + expect(replacement).not.toHaveProperty('agentId') + }) + + it('still reads a persisted record written before rows carried scope or producer', () => { + const older = { + operationId: 'op-1', + callerKey: 'desktop', + itemId: codexKey('b', 0), + expectedEpoch: 'epoch-1', + phase: 'prepared', + retained: [{ itemId: codexKey('a', 1), body: prose('x'), observedAt: 1 }] + } + expect(AgentSessionRewindRecordSchema.safeParse(older).success).toBe(true) + }) +}) + +describe('rewind merge cost', () => { + it('reads each merged row once to place the provider items it never held', () => { + const turns = 400 + const providerItems = Array.from({ length: turns }, (_, index) => + providerItem(codexKey(`t${index}`, 1), prose(`answer ${index}`)) + ) + const reference = Array.from({ length: turns }, (_, index) => + retained(orcaKey(`turn-${index}`), { + kind: 'turn', + turnId: `t${index}`, + state: 'completed' + }) + ) + vi.mocked(restoreRewindJournalBody).mockClear() + + const merged = mergeRetainedHostLifecycleRows(reference, providerItems) + + expect(merged.find((item) => item.itemId === codexKey(`t${turns - 1}`, 1))).toMatchObject({ + turnScope: { kind: 'turn', turnItemId: orcaKey(`turn-${turns - 1}`) } + }) + // A scan per provider item reads every merged row for each one: turns² reads. + expect(vi.mocked(restoreRewindJournalBody).mock.calls.length).toBeLessThanOrEqual(merged.length) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index 35307566599..6496518b50b 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,18 +1,30 @@ // Provider preflight returns provider items only. The host's lifecycle rows are its own record, so // a rewind that takes the provider list as the new epoch must splice those rows back beside the -// provider item each one followed. +// provider item each one followed. Provider items carry neither turn scope nor producer, so each +// keeps the ones its retained row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' type RetainedRow = AgentSessionRewindRecord['retained'][number] export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { + const identity = parseAgentJournalItemKey(item.itemId) return ( readAgentJournalTurn(item.body as AgentJournalItemBody) !== null || - parseCodexGoalJournalItemId(item.itemId) !== null + parseCodexGoalJournalItemId(item.itemId) !== null || + // A conversation command's entry and result: the provider's history holds neither. + (item.body.kind === 'message' && item.body.command !== undefined) || + (identity?.provider === 'orca' && identity.clientMessageId.startsWith('command-')) ) } @@ -22,6 +34,7 @@ export function mergeRetainedHostLifecycleRows( providerItems: readonly RetainedRow[] ): RetainedRow[] { const spineIndex = new Map(providerItems.map((item, index) => [item.itemId, index])) + const held = new Map(reference.map((item) => [item.itemId, item])) const rowsAfter = new Map() let anchor = -1 for (const item of reference) { @@ -32,6 +45,77 @@ export function mergeRetainedHostLifecycleRows( } } const merged = [...(rowsAfter.get(-1) ?? [])] - providerItems.forEach((item, index) => merged.push(item, ...(rowsAfter.get(index) ?? []))) - return merged + providerItems.forEach((item, index) => + merged.push(withHeldAttribution(item, held.get(item.itemId)), ...(rowsAfter.get(index) ?? [])) + ) + const turnRecords = turnRecordsByProviderTurn(merged) + return merged.map((item) => + held.has(item.itemId) || item.turnScope ? item : withProviderTurnScope(item, turnRecords) + ) +} + +/** The rebuilt epoch's item for one retained row, with the scope and producer it was written with. */ +export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLinkage & { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + observedAt: number + turnScope?: AgentJournalTurnScope +} { + const identity = parseAgentJournalItemKey(row.itemId) + if (!identity) { + throw new Error('agent_session_rewind:invalid-retained-identity') + } + const { agentId, parentAgentId, providerParentRef, producerKind, attempt, turnScope } = row + const scope = + turnScope?.kind === 'turn' && turnScope.turnItemId + ? { kind: 'turn' as const, turnItemId: turnScope.turnItemId } + : turnScope?.kind === 'thread' + ? { kind: 'thread' as const } + : undefined + return { + identity, + body: restoreRewindJournalBody(row.body), + observedAt: row.observedAt, + ...(scope ? { turnScope: scope } : {}), + ...(agentId === undefined ? {} : { agentId }), + ...(parentAgentId === undefined ? {} : { parentAgentId }), + ...(providerParentRef === undefined ? {} : { providerParentRef }), + ...(producerKind === 'agent' || producerKind === 'background' ? { producerKind } : {}), + ...(attempt === undefined ? {} : { attempt }) + } +} + +function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): RetainedRow { + if (!held) { + return item + } + const { itemId: _itemId, body: _body, observedAt: _observedAt, ...attribution } = held + return { ...item, ...attribution } +} + +/** Provider turn id → the item id of its turn record: the turn's own, or the command turn that + * claimed it. The first record wins, as a scan from the top would find it. */ +function turnRecordsByProviderTurn(merged: readonly RetainedRow[]): ReadonlyMap { + const records = new Map() + for (const candidate of merged) { + const turn = readAgentJournalTurn(restoreRewindJournalBody(candidate.body)) + for (const turnId of [turn?.turnId, turn?.providerTurnId]) { + if (turnId !== undefined && !records.has(turnId)) { + records.set(turnId, candidate.itemId) + } + } + } + return records +} + +/** A provider item the old epoch never held joins the turn record for its provider turn. + * Otherwise the rebuild places it. */ +function withProviderTurnScope( + item: RetainedRow, + turnRecords: ReadonlyMap +): RetainedRow { + const identity = parseAgentJournalItemKey(item.itemId) + const turnItemId = + identity?.provider === 'codex' && identity.turnId ? turnRecords.get(identity.turnId) : undefined + return turnItemId ? { ...item, turnScope: { kind: 'turn', turnItemId } } : item } diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts deleted file mode 100644 index 8936a4a28d0..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from './structured-session-compaction' - -describe('structured compaction lifecycle', () => { - it('waits beyond the Codex acknowledgment and ignores other threads', async () => { - const tracker = new StructuredSessionCompaction() - const finished = vi.fn() - const result = tracker - .run('session', 'thread', async () => undefined) - .then((value) => { - finished() - return value - }) - await Promise.resolve() - tracker.codex('session', 'turn/started', { threadId: 'other', turn: { id: 'foreign' } }) - tracker.codex('session', 'turn/completed', { - threadId: 'other', - turn: { id: 'foreign', status: 'completed' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/started', { threadId: 'thread', turn: { id: 'compact-turn' } }) - tracker.codex('session', 'item/completed', { - threadId: 'thread', - item: { type: 'contextCompaction' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/completed', { - threadId: 'thread', - turn: { id: 'compact-turn', status: 'completed' } - }) - await expect(result).resolves.toEqual({ outcome: 'compacted' }) - }) - - it('observes notifications arriving before the request acknowledgment', async () => { - const tracker = new StructuredSessionCompaction() - await expect( - tracker.run('s', 't', async () => { - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { - threadId: 't', - turn: { id: 'c', status: 'failed', error: { message: 'Unavailable' } } - }) - }) - ).resolves.toEqual({ - outcome: 'failed', - detail: { text: 'Unavailable', audience: 'person' } - }) - }) - - it.each(['success', 'failed'])( - 'uses Claude compact_result %s rather than result subtype', - async (state) => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'provider', async () => {}) - tracker.claude('s', { - type: 'system', - subtype: 'status', - session_id: 'provider', - compact_result: state, - compact_error: 'Not enough messages to compact.' - }) - tracker.claude('s', { - type: 'result', - subtype: 'success', - session_id: 'provider', - result: '' - }) - await expect(result).resolves.toEqual( - state === 'success' - ? { outcome: 'compacted' } - : { - outcome: 'failed', - detail: { text: 'Not enough messages to compact.', audience: 'person' } - } - ) - } - ) - - it('cleans up on provider exit and permits another operation', async () => { - const tracker = new StructuredSessionCompaction() - const pending = tracker.run('s', 'p', async () => {}) - tracker.ended('s') - await expect(pending).resolves.toEqual({ outcome: 'unconfirmed' }) - const next = tracker.run('s', 'p', async () => { - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - }) - await expect(next).resolves.toEqual({ outcome: 'compacted' }) - }) - it('reconciles a terminal frame after timeout without repeating the provider request', async () => { - vi.useFakeTimers() - try { - const tracker = new StructuredSessionCompaction(10) - const late = vi.fn(async () => {}) - const invoke = vi.fn(async () => undefined) - const result = tracker.run('s', 'p', invoke, late) - const rejected = expect(result).rejects.toThrow('unconfirmed') - await vi.advanceTimersByTimeAsync(11) - await rejected - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - expect(late).toHaveBeenCalledWith({ outcome: 'compacted' }) - expect(invoke).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('does not mistake an unrelated completed turn for compaction', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => undefined) - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'completed' } }) - await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) - }) - - it('fails, not merely unconfirmed, a Codex compaction turn that did not complete', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => undefined) - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { - threadId: 't', - turn: { id: 'c', status: 'interrupted' } - }) - await expect(result).resolves.toEqual({ outcome: 'failed' }) - }) - - it('leaves a Claude turn that ended without confirming compaction unconfirmed', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'p', async () => {}) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) - }) - - it('fails a Claude compaction whose turn ended in error', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'p', async () => {}) - tracker.claude('s', { type: 'result', subtype: 'error_during_execution', session_id: 'p' }) - await expect(result).resolves.toEqual({ outcome: 'failed' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts deleted file mode 100644 index 5f5c88d0b07..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { providerDiagnostic, type ProviderDiagnostic } from '../../../shared/agent-session-failure' - -/** How a compaction ended. `unconfirmed`: the provider never said whether it compacted, so it may - * have. `detail` is the provider's own words for a failure, when it gave any. */ -export type StructuredSessionCompactionResult = - | { outcome: 'compacted' } - | { outcome: 'failed'; detail?: ProviderDiagnostic } - | { outcome: 'unconfirmed' } - -/** A compaction that never began: refused before it was sent, or by the provider at the request. */ -export type StructuredSessionCompactionRefusal = Extract< - StructuredSessionCompactionResult, - { outcome: 'failed' } -> - -function compactionFailed( - detail: ProviderDiagnostic | undefined -): StructuredSessionCompactionRefusal { - return { outcome: 'failed', ...(detail ? { detail } : {}) } -} - -type PendingCompaction = { - identity: string - commandTurnId?: string - turnId?: string - /** The provider reported the compaction failed, with its words when it gave any. */ - failed?: { detail?: ProviderDiagnostic } - compacted: boolean - finish: (result: StructuredSessionCompactionResult) => void -} - -function record(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {} -} - -export function isCodexCompactionComplete(method: string, params: unknown): boolean { - return ( - method === 'thread/compacted' || - (method === 'item/completed' && record(record(params).item).type === 'contextCompaction') - ) -} - -/** A receipt is not completion; keep listening through the provider's terminal frame. */ -export class StructuredSessionCompaction { - private readonly pending = new Map() - constructor(private readonly timeoutMs = 180_000) {} - - async run( - sessionId: string, - identity: string, - invoke: () => Promise, - onLateResult?: (result: StructuredSessionCompactionResult) => Promise, - commandTurnId?: string - ): Promise { - if (this.pending.has(sessionId)) { - throw new Error('Compaction is already running.') - } - let timer: ReturnType - let expired = false - const completion = new Promise((resolve, reject) => { - const finish = (result: StructuredSessionCompactionResult) => { - this.pending.delete(sessionId) - if (expired && onLateResult) { - void onLateResult(result).catch((error) => - console.warn('Could not persist late compaction completion', error) - ) - } - resolve(result) - } - this.pending.set(sessionId, { - identity, - commandTurnId, - compacted: false, - finish - }) - timer = setTimeout(() => { - expired = true - reject(new Error('Compaction completion is unconfirmed.')) - }, this.timeoutMs) - timer.unref?.() - }) - // Observe rejection even while invoke is waiting for its own receipt. - void completion.catch(() => {}) - try { - const refusal = await invoke() - if (refusal) { - this.pending.get(sessionId)?.finish(compactionFailed(refusal.detail)) - } - return await completion - } catch (error) { - expired = this.pending.has(sessionId) - throw error - } finally { - clearTimeout(timer!) - if (!expired) { - this.pending.delete(sessionId) - } - } - } - - hasPending(sessionId: string): boolean { - return this.pending.has(sessionId) - } - - ownsTurn(sessionId: string, turnId: string): boolean { - return this.pending.get(sessionId)?.commandTurnId === turnId - } - - providerTurnId(sessionId: string, turnId: string): string | undefined { - return this.ownsTurn(sessionId, turnId) ? this.pending.get(sessionId)?.turnId : turnId - } - - ended(sessionId: string): void { - // It may have compacted before it exited. - this.pending.get(sessionId)?.finish({ outcome: 'unconfirmed' }) - } - - codex(sessionId: string, method: string, value: unknown): void { - const pending = this.pending.get(sessionId) - const params = record(value) - if (!pending || params.threadId !== pending.identity) { - return - } - const turn = record(params.turn) - if (method === 'turn/started' && typeof turn.id === 'string') { - pending.turnId = turn.id - } - if (isCodexCompactionComplete(method, params)) { - pending.compacted = true - } - if (method === 'turn/completed' && turn.id === pending.turnId) { - const error = record(turn.error).message - pending.finish( - turn.status !== 'completed' - ? compactionFailed( - typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined - ) - : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } - ) - } - } - - claude(sessionId: string, message: Record): void { - const pending = this.pending.get(sessionId) - if (!pending || message.session_id !== pending.identity) { - return - } - if (message.compact_result === 'failed') { - pending.failed = { - detail: - typeof message.compact_error === 'string' - ? providerDiagnostic(message.compact_error, 'person') - : undefined - } - } - if (message.compact_result === 'success' || message.subtype === 'compact_boundary') { - pending.compacted = true - } - if (message.type === 'result') { - if ( - message.is_error === true || - (typeof message.subtype === 'string' && message.subtype.startsWith('error')) - ) { - pending.failed ??= {} - } - pending.finish( - pending.failed - ? compactionFailed(pending.failed.detail) - : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } - ) - } - } -} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 1cc5050d803..63d24030c70 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session-journal-types' // A chat at rest, through the RPC surface a client actually calls: opening it starts nothing, what // it can answer without an agent it answers, and the first send is what starts one. @@ -158,7 +159,8 @@ describe('the accessor', () => { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, - hostTestMessage('from the provider') + hostTestMessage('from the provider'), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) await rig.restart() @@ -421,7 +423,8 @@ describe('an agent exit', () => { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) // The exit's own write and the retry recording the exit queues are both refused. 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 9a0f3902496..85d3e2a5b4b 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 @@ -191,6 +191,7 @@ export function hostStub(): StructuredAgentSessionHost { current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), + sessionAgent: vi.fn(() => null), journalSnapshot: vi.fn((sessionId: string) => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index 9ae846df6fc..4b3bd989c87 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -34,7 +34,7 @@ describe('agentSession.send reply timing', () => { await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(SESSION, 'client-1', { - until: 'handed-over', + until: 'handed-over-or-behind-command', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 5fde5e0f6e4..c7c8d2cc4ce 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -9,8 +9,10 @@ import { requireStructuredHost, structuredCallerFor } from './structured-agent-s /** * A send answers once the host accepts it. A client that predates that answer cannot show a - * message rejected after it, so its reply is held until the message is handed over or rejected; - * one that predates pending replies at all waits, as before, for the provider's answer. + * message rejected after it, so its reply is held until the message is handed over or rejected — + * or queued behind a running command such as `/compact`, which could outlast the client's own + * request timeout; one that predates pending replies at all waits, as before, for the provider's + * answer. */ export async function sendStructuredAgentSessionForClient( params: Parameters[1], @@ -33,7 +35,7 @@ export async function sendStructuredAgentSessionForClient( result.value.clientMessageId, { until: capabilities.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) - ? 'handed-over' + ? 'handed-over-or-behind-command' : 'answered', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, ...(context.signal ? { signal: context.signal } : {}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts index f0694811a9a..c6984552399 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts @@ -24,7 +24,10 @@ function readsTurnItems(ctx: TurnItemReader): boolean { ) } -function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] { +function projectItems( + items: AgentJournalRenderItem[], + sessionAgent: string | null +): AgentJournalRenderItem[] { if (!items.some((item) => item.body.kind === 'turn')) { return items } @@ -33,39 +36,45 @@ function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] return item } const { kind: _kind, ...turn } = item.body - return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId) } + return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId, sessionAgent) } }) } -function projectPage(page: AgentSessionHistoryPage): AgentSessionHistoryPage { - const items = projectItems(page.items) +function projectPage( + page: AgentSessionHistoryPage, + sessionAgent: string | null +): AgentSessionHistoryPage { + const items = projectItems(page.items, sessionAgent) return items === page.items ? page : { ...page, items } } +/** `sessionAgent` names the agent on a turn whose key does not, such as a command's. */ export function projectTurnItemHistory( result: AgentSessionHistoryResult, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionHistoryResult { if (readsTurnItems(ctx)) { return result } - const page = projectPage(result.page) + const page = projectPage(result.page, sessionAgent) return page === result.page ? result : { ...result, page } } export function projectTurnItemEvent( event: AgentSessionSubscribeEvent, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionSubscribeEvent { if (readsTurnItems(ctx)) { return event } if (event.type === 'batch') { - const items = projectItems(event.batch.items) + const items = projectItems(event.batch.items, sessionAgent) return items === event.batch.items ? event : { ...event, batch: { ...event.batch, items } } } if (event.type === 'snapshot' || event.type === 'reset') { - const page = projectPage(event.page) + const page = projectPage(event.page, sessionAgent) return page === event.page ? event : { ...event, page } } return event diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index dc01a2fee8f..dd7c9ee7115 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -254,11 +254,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.history', params: HistoryParams, - handler: async (params, ctx) => - projectTurnItemHistory( - projectBackgroundTaskHistory(await (await requireInstalledHost(ctx)).history(params), ctx), - ctx + handler: async (params, ctx) => { + const host = await requireInstalledHost(ctx) + return projectTurnItemHistory( + projectBackgroundTaskHistory(await host.history(params), ctx), + ctx, + host.sessionAgent(params.sessionId) ) + } }), defineStreamingMethod({ name: 'agentSession.subscribe', @@ -277,7 +280,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ dispose = await host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), + emit: (event) => + emit( + projectTurnItemEvent( + projectBackgroundTaskEvent(event, ctx), + ctx, + host.sessionAgent(params.sessionId) + ) + ), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 7b22a45d09d..f4baa90da20 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -164,7 +165,7 @@ describe('a request that failed reads as failed through the feed, the ingest and outcome: 'cancellation', completedAt: 5 }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { status, ps } = ingest(publishedSummary(journal)) @@ -180,7 +181,7 @@ describe('a request that failed reads as failed through the feed, the ingest and await journal.appendItem( TURN_IDENTITY, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const summary: AgentSessionStatusSummary = { ...publishedSummary(journal), diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index a1373f43c71..5b081d1e5df 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { mkdtemp, rm } from 'node:fs/promises' @@ -71,7 +72,7 @@ async function awaitingApproval() { await journal.appendItem( { ...IDENTITY, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { ...IDENTITY, ordinal: 2 }, @@ -82,7 +83,7 @@ async function awaitingApproval() { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ diff --git a/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts new file mode 100644 index 00000000000..ee3876301df --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts @@ -0,0 +1,154 @@ +// A Codex child that exits, or that Stop ends, while `/compact` runs, observed the way the shipping +// adapter observes it: the app-server connection's own exit, not a hand-fed compaction result. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The desktop's own client: a send answers at acceptance, so one queued behind the command + // returns while the command still runs. + harness = await openStructuredCodexRpcHarness(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES) +}) + +afterEach(async () => { + await harness.dispose() +}) + +async function snapshot() { + return (await ensureStructuredAgentSessionHost(harness.hostConfig())).journalSnapshot(SESSION) +} + +/** The connections that received `method`, once per call. */ +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +/** The child serving the thread; a model-list probe opens connections of its own. */ +function threadChild(): FakeCodexConnection { + const child = harness.codex.connections.findLast((connection) => + connection.calls.some( + (entry) => entry.method === 'thread/start' || entry.method === 'thread/resume' + ) + ) + if (!child) { + throw new Error('no codex child opened the thread') + } + return child +} + +async function startCompact(fence: number): Promise { + const params = { + command: 'compact', + envelope: harness.envelope('agentSession.conversationCommand', { command: 'compact' }, fence) + } + await harness.ok('agentSession.conversationCommand', params) + return params.envelope.clientOperationId +} + +async function send(text: string): Promise { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + await harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function exitChild(): FakeCodexConnection { + const child = threadChild() + child.handlers.onExit?.(new Error('codex app-server exited')) + return child +} + +it('delivers what waited behind the command once its child dies mid-command, with one exit row', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + await send('after the exit') + + const dead = exitChild() + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(dead) + const settled = await snapshot() + const turn = settled.items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + expect( + settled.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) +}) + +it('runs a later command after Stop named the one whose child died', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const dead = exitChild() + await vi.waitFor(async () => { + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + }) + const { turnId } = structuredAgentSessionCommandTurn(command) + await harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + + // Nothing the dead command left behind holds the queue or refuses the next one. + const later = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(2)) + expect(calls('thread/compact/start')[1]).not.toBe(dead) + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(later).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('running') +}) + +it('ends a command Codex has not opened a turn for by stopping its child, and the next send gets a fresh one', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + const first = threadChild() + const { itemId, turnId } = structuredAgentSessionCommandTurn(command) + + // No `turn/started` yet, so there is no provider turn to interrupt. + await expect( + harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + ).resolves.toMatchObject({ cancelled: true }) + + expect(first.closed).toBe(true) + expect(first.calls.some((entry) => entry.method === 'turn/interrupt')).toBe(false) + const turn = (await snapshot()).items.find((item) => item.itemId === itemId) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + await send('after the stop') + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(first) +}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index a4e2500ffc4..29dbdf59302 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -6,234 +6,27 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection -} from '../codex/codex-app-server-connection' import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' -import type { OrcaRuntimeService } from './orca-runtime' -import type { RpcRequest, RpcResponse } from './rpc/core' -import { RpcDispatcher } from './rpc/dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + TURN, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' const journals = createTrackedJournalOpener() - -const SESSION = 'session-integration-1' -const THREAD = 'thread-integration' -const TURN = 'turn-1' const WORKSPACE = 'workspace-1' -// The capability set the desktop renderer advertises. Without the pending-send -// one the host holds the reply until the send settles, which is a shim for -// clients too old to render a pending bubble — not what this suite models. -const CLIENT = { - clientId: 'device-a', - clientKind: 'runtime' as const, - clientCapabilities: [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] -} -// ─── the fake `codex app-server` ──────────────────────────────────────────── - -type CodexScript = { - connections: FakeConnection[] - openConnection: typeof openCodexAppServerConnection - live: () => FakeConnection - notify: (method: string, params: unknown) => void - ask: (id: number, method: string, params: unknown) => void -} - -// `closed` is readonly on the real connection; the fake flips it so the test can -// see the takeover reap the previous child. -type FakeConnection = Omit & { - closed: boolean - handlers: CodexAppServerConnectionHandlers - calls: { method: string; params?: Record }[] - replies: { id: number | string; result?: unknown; code?: number }[] - resumedThreadId: string | null - launch: Parameters[0] -} - -function fakeCodex(): CodexScript { - const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { - const connection: FakeConnection = { - launch, - handlers, - calls: [], - replies: [], - resumedThreadId: null, - pid: 4321, - closed: false, - request: async (method, params) => { - connection.calls.push({ method, params }) - if (method === 'thread/start') { - return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } - } - if (method === 'thread/resume') { - connection.resumedThreadId = (params as { threadId: string }).threadId - return { thread: { id: connection.resumedThreadId } } - } - if (method === 'turn/start') { - return { turn: { id: TURN } } - } - if (method === 'model/list') { - return { - data: [ - { - model: 'gpt-live', - displayName: 'GPT Live', - hidden: false, - supportedReasoningEfforts: [ - { reasoningEffort: 'medium', description: 'Balanced' }, - { reasoningEffort: 'high', description: 'Deep reasoning' } - ], - defaultReasoningEffort: 'medium', - isDefault: true - } - ], - nextCursor: null - } - } - return {} - }, - notify: () => {}, - respond: (id, result) => connection.replies.push({ id, result }), - respondWithError: (id, code) => connection.replies.push({ id, code }), - close: async () => { - connection.closed = true - return true - } - } - connections.push(connection) - return connection - }) as typeof openCodexAppServerConnection - const live = (): FakeConnection => { - const connection = connections.at(-1) - if (!connection) { - throw new Error('no codex app-server has been opened') - } - return connection - } - return { - connections, - openConnection, - live, - notify: (method, params) => live().handlers.onNotification?.(method, params), - ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) - } -} - -// ─── the RPC client ───────────────────────────────────────────────────────── - -let operations = 0 - -/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real - * time, not a frozen constant: the runtime under test stamps the ledger with - * its own clock and refuses a future-dated id. */ -function operationId(): string { - operations += 1 - return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` -} - -function envelope(method: string, fields: Record, fence: number | null) { - return { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function attachParams(fence: number | null) { - const params = { - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE, - workspaceKind: 'git-worktree' as const - }, - provider: 'codex' as const, - agent: 'codex', - accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } - } - const envelope = { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: '' - } - return { - ...params, - envelope: { - ...envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields({ ...params, envelope } as never) - }) - } - } -} - -function createIntentParams() { - const worktree = `id:${WORKSPACE}` - const fields = { worktree, agent: 'codex' } - return { envelope: envelope('agentSession.create', fields, null), ...fields } -} - -let codex: CodexScript -let root: string -let dispatcher: RpcDispatcher -let bootEnvironmentReads: number -let codexOverrideReads: number -let configuredCodexProfile: string - -/** Runs a one-shot method and returns its decoded reply. */ -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } - await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) - const first = replies[0] - if (!first) { - throw new Error(`no reply for ${method}`) - } - return first -} - -/** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ -async function ok(method: string, params: unknown): Promise { - const response = await call(method, params) - expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) - const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result - expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ ok: true }) - return result.value as T -} +let harness: StructuredCodexRpcHarness function textOf(item: AgentJournalRenderItem): string { const body = item.body @@ -243,69 +36,20 @@ function textOf(item: AgentJournalRenderItem): string { } beforeEach(async () => { - operations = 0 - root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) - codex = fakeCodex() - bootEnvironmentReads = 0 - codexOverrideReads = 0 - configuredCodexProfile = 'configured' - const runtime = { - getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), - getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), - resolveStructuredAgentSessionCreateIntent: async () => { - const { - envelope: _envelope, - providerHandle: _providerHandle, - ...resolved - } = attachParams(null) - return resolved - }, - publishStructuredAgentSessionTab: () => {}, - ensureStructuredAgentSessionHost: () => - ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => { - bootEnvironmentReads += 1 - return { - PATH: '/shell/bin:/usr/bin', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported', - CODEX_HOME: '/shell/home' - } - }, - resolveCodexOverrides: () => { - codexOverrideReads += 1 - return { CODEX_PROFILE: configuredCodexProfile } - }, - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }).then(() => undefined), - registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { - return { - releaseIfCurrent: dispose - } - }) - } - dispatcher = new RpcDispatcher({ - runtime: runtime as unknown as OrcaRuntimeService, - methods: STRUCTURED_AGENT_SESSION_METHODS - }) + harness = await openStructuredCodexRpcHarness() }) afterEach(async () => { await journals.closeAll() - await stopStructuredAgentSessionRuntime() - await rm(root, { recursive: true, force: true }) + await harness.dispose() }) describe('a structured codex session over agentSession.*', () => { it('replays a durable image send without dispatching it twice', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) const path = '/tmp/orca-paste-image.png' const body = { kind: 'message' as const, @@ -313,31 +57,23 @@ describe('a structured codex session over agentSession.*', () => { blocks: [{ type: 'image-ref' as const, path }] } const params = { - envelope: envelope('agentSession.send', { body }, created.fence), + envelope: harness.envelope('agentSession.send', { body }, created.fence), body } - const turnStarts = () => codex.live().calls.filter((entry) => entry.method === 'turn/start') - await ok('agentSession.send', params) + const turnStarts = () => + harness.codex.live().calls.filter((entry) => entry.method === 'turn/start') + await harness.ok('agentSession.send', params) // Accepted first; the delivery loop hands it over once. await vi.waitFor(() => expect(turnStarts()).toHaveLength(1)) - const replay = await call('agentSession.send', params) + const replay = await harness.call('agentSession.send', params) expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) expect(turnStarts()).toHaveLength(1) }) it('joins an acquired attach through journal bind before draining final rows', async () => { - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }) + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps .adapter const historyEntered = Promise.withResolvers() @@ -349,15 +85,18 @@ describe('a structured codex session over agentSession.*', () => { return originalHistoryFilePath(input) }) - const creating = ok<{ fence: number }>('agentSession.create', createIntentParams()) + const creating = harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) await historyEntered.promise - codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) - codex.notify('item/started', { + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + harness.codex.notify('item/started', { threadId: THREAD, turnId: TURN, item: { type: 'agentMessage', id: 'item-bind-window', text: '' } }) - codex.notify('item/agentMessage/delta', { + harness.codex.notify('item/agentMessage/delta', { threadId: THREAD, turnId: TURN, itemId: 'item-bind-window', @@ -384,7 +123,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + journalDir: journalDirectoryFor(harness.root, identity) }) expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') expect( diff --git a/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts new file mode 100644 index 00000000000..fb820891ba5 --- /dev/null +++ b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts @@ -0,0 +1,106 @@ +// A client that predates accepted-send replies (every phone build) has its send reply held until +// handover. A message queued behind `/compact` is handed over only when the compaction ends, so +// that reply answers once the message waits behind the running command instead. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +const COMPACTION_TURN = 'compaction-turn' +// Far under the phone's 15 s send timeout, and far over a handover that is already due. +const PROMPT_REPLY_MS = 2_000 + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The harness's default client is the phone's: pending replies, but not accepted-send. + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +function send(text: string): Promise<{ submission: AgentJournalSubmission }> { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + return harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function promptly(reply: Promise): Promise { + return Promise.race([ + reply, + new Promise((_, reject) => + setTimeout(() => reject(new Error('the send reply waited out the command')), PROMPT_REPLY_MS) + ) + ]) +} + +it('answers a send queued behind /compact at once, and delivers it once after the compaction', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await harness.ok('agentSession.conversationCommand', { + command: 'compact', + envelope: harness.envelope( + 'agentSession.conversationCommand', + { command: 'compact' }, + created.fence + ) + }) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const { submission } = await promptly(send('after the compaction')) + + expect(submission).toMatchObject({ dispatchState: 'pending' }) + expect(submission.handedOverAt).toBeUndefined() + expect(calls('turn/start')).toHaveLength(0) + + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: COMPACTION_TURN } }) + harness.codex.notify('item/completed', { + threadId: THREAD, + turnId: COMPACTION_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + harness.codex.notify('turn/completed', { + threadId: THREAD, + turn: { id: COMPACTION_TURN, status: 'completed' } + }) + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + const journal = await ( + await ensureStructuredAgentSessionHost(harness.hostConfig()) + ).journalSnapshot(SESSION) + expect( + journal.submissions.filter((entry) => entry.clientMessageId === submission.clientMessageId) + ).toEqual([expect.objectContaining({ handedOverAt: expect.any(Number) })]) + // Nothing re-sent it: the host delivered the one message once. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(calls('turn/start')).toHaveLength(1) +}) + +it('still holds the reply of a send that no command is holding until it is handed over', async () => { + await harness.ok('agentSession.create', harness.createIntentParams()) + + // The first send starts the child; the reply waits out that start and the handover. + const { submission } = await promptly(send('first message')) + + expect(submission.handedOverAt).toEqual(expect.any(Number)) + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) +}) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4160b81c4de..9e4c9aca2c7 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -64,6 +64,8 @@ export function structuredClaudeLifecycleEvent( cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, + // The instant the translator ended the open turn at; the host reads the exit's turn by it. + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) } } diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts new file mode 100644 index 00000000000..6b9024ba029 --- /dev/null +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -0,0 +1,278 @@ +// One structured Codex session driven over `agentSession.*`, with nothing stubbed but the Codex +// child: the RPC dispatcher, schemas, record store, journal, lease, Codex adapter and translation +// are the ones that ship. The fake app-server answers the JSON-RPC calls the real one does, and a +// test pushes notifications, blocking requests and the child's exit back through its handlers. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +export const SESSION = 'session-integration-1' +export const THREAD = 'thread-integration' +export const TURN = 'turn-1' +const WORKSPACE = 'workspace-1' +// Without the pending-send capability the host holds the reply until the send settles, which is a +// shim for clients too old to render a pending bubble — not what these suites model. +const DEFAULT_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] + +// `closed` is readonly on the real connection; the fake flips it so a test can +// see a takeover reap the previous child. +export type FakeCodexConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number }[] + resumedThreadId: string | null + launch: Parameters[0] +} + +export type FakeCodex = { + connections: FakeCodexConnection[] + openConnection: typeof openCodexAppServerConnection + live: () => FakeCodexConnection + notify: (method: string, params: unknown) => void + ask: (id: number, method: string, params: unknown) => void +} + +function fakeCodex(): FakeCodex { + const connections: FakeCodexConnection[] = [] + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { + const connection: FakeCodexConnection = { + launch, + handlers, + calls: [], + replies: [], + resumedThreadId: null, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + if (method === 'thread/start') { + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + } + if (method === 'thread/resume') { + connection.resumedThreadId = (params as { threadId: string }).threadId + return { thread: { id: connection.resumedThreadId } } + } + if (method === 'turn/start') { + return { turn: { id: TURN } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code) => connection.replies.push({ id, code }), + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + } + const live = (): FakeCodexConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no codex app-server has been opened') + } + return connection + } + return { + connections, + openConnection, + live, + notify: (method, params) => live().handlers.onNotification?.(method, params), + ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) + } +} + +function attachParams(operationId: () => string, fence: number | null) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex', + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const envelope = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope } as never) + }) + } + } +} + +export type StructuredCodexRpcHarness = { + codex: FakeCodex + root: string + hostConfig: () => Parameters[0] + envelope: ( + method: string, + fields: Record, + fence: number | null + ) => { sessionId: string; clientOperationId: string; expectedRuntimeFence: number | null } + createIntentParams: () => Record + /** Runs a one-shot method and returns its decoded reply. */ + call: (method: string, params: unknown) => Promise + /** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ + ok: (method: string, params: unknown) => Promise + dispose: () => Promise +} + +export async function openStructuredCodexRpcHarness( + clientCapabilities: readonly RuntimeCapability[] = DEFAULT_CLIENT_CAPABILITIES +): Promise { + const client = { clientId: 'device-a', clientKind: 'runtime' as const, clientCapabilities } + let operations = 0 + const root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) + const codex = fakeCodex() + // `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real time, not a frozen + // constant: the runtime under test stamps the ledger with its own clock and refuses a future id. + const operationId = (): string => { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` + } + const envelope: StructuredCodexRpcHarness['envelope'] = (method, fields, fence) => ({ + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + const hostConfig = (): Parameters[0] => ({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ + PATH: '/shell/bin:/usr/bin', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home' + }), + resolveCodexOverrides: () => ({ CODEX_PROFILE: 'configured' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const runtime = { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(operationId, null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost(hostConfig()).then(() => undefined), + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ + releaseIfCurrent: dispose + })) + } + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) + const call: StructuredCodexRpcHarness['call'] = async (method, params) => { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), client) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first + } + return { + codex, + root, + hostConfig, + envelope, + createIntentParams: () => { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } + }, + call, + ok: async (method: string, params: unknown): Promise => { + const response = await call(method, params) + expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result + expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ + ok: true + }) + return result.value as T + }, + dispose: async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + } + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx new file mode 100644 index 00000000000..d1da02b713c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx @@ -0,0 +1,180 @@ +// @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 { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const text = ( + id: string, + role: NativeChatMessage['role'], + body: string, + timestamp: number +): NativeChatMessage => ({ + id, + role, + blocks: [{ type: 'text', text: body }], + timestamp, + source: 'transcript' +}) +const toolRun = (id: string, timestamp: number): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'sleep 5' }, state: 'completed' }], + timestamp, + source: 'transcript' +}) + +// Claude runs A; B is sent mid-turn and Claude answers it after A. The journal writes B when it is +// sent, so A's remaining tool run and its answer follow B there, and B's turn opens after them. +const messages = [ + text('user-a', 'user', 'Run the first job', 1), + toolRun('a-tool-1', 2), + text('user-b', 'user', 'Then run the second job', 3), + toolRun('a-tool-2', 4), + text('a-answer', 'assistant', 'FIRST DONE', 5), + text('b-answer', 'assistant', 'SECOND DONE', 6) +] + +/** With `statesScope` false the rows carry no turn scope, as a host older than turn scopes writes. */ +function journal(bState: 'running' | 'completed', statesScope: boolean): AgentJournalRenderItem[] { + const thread: AgentJournalTurnScope = { kind: 'thread' } + const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + const row = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope, + sequence: number + ): AgentJournalRenderItem => ({ + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(statesScope ? { turnScope } : {}) + }) + const said = (role: 'user' | 'assistant'): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [] + }) + return [ + row('user-a', said('user'), thread, 1), + row( + 'turn-a', + { kind: 'turn', turnId: 'turn-a', state: 'completed', userItemId: 'user-a' }, + thread, + 2 + ), + row('a-tool-1', said('assistant'), inTurn('turn-a'), 3), + // Handed over while A runs, so the host scopes it to A's turn until its own opens. + row('user-b', said('user'), inTurn('turn-a'), 4), + row('a-tool-2', said('assistant'), inTurn('turn-a'), 5), + row('a-answer', said('assistant'), inTurn('turn-a'), 6), + row( + 'turn-b', + { kind: 'turn', turnId: 'turn-b', state: bState, userItemId: 'user-b' }, + thread, + 7 + ), + row('b-answer', said('assistant'), inTurn('turn-b'), 8) + ] +} + +const session = (status: NativeChatLiveSession['status']): NativeChatLiveSession => ({ + messages, + status, + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' +}) + +function expectInOrder(...nodes: Element[]): void { + for (const [index, node] of nodes.slice(1).entries()) { + expect(nodes[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + } +} + +const bar = (label: string | RegExp): Element => + screen.getByText(label).closest('[data-native-chat-turn-status]')! + +describe.each([ + ['states each row’s turn', true], + ['states no scope', false] +])( + 'a message the provider answered after the running turn, on a host that %s', + (_host, statesScope) => { + it("draws A's remaining rows and answer under A's bar, then B's bubble and turn", () => { + render( + + ) + + expectInOrder( + screen.getByText('Run the first job'), + bar('Worked for 17s'), + screen.getByText('FIRST DONE'), + screen.getByText('Then run the second job'), + bar(/Working for/), + screen.getByText('SECOND DONE') + ) + }) + + it('keeps that order once both turns settle', () => { + render( + + ) + + expectInOrder( + screen.getByText('Run the first job'), + bar('Worked for 17s'), + screen.getByText('FIRST DONE'), + screen.getByText('Then run the second job'), + bar('Worked for 51s'), + screen.getByText('SECOND DONE') + ) + }) + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 6e0c29d9d71..6138d662974 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -19,14 +19,19 @@ import { NativeChatDisclosureContext, useNativeChatDisclosures } from './native-chat-disclosure-store' -import { NativeChatTranscriptItems } from './NativeChatTranscriptItems' +import { + NativeChatTranscriptItems, + NativeChatWaitingTranscriptItems +} from './NativeChatTranscriptItems' import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { buildNativeChatTranscriptSlots, + splitNativeChatSlotsWaitingBehindLiveTurn, nativeChatSlotIndexOf } from './native-chat-transcript-slots' import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window' +import { useNativeChatTurnMembership } from './use-native-chat-turn-membership' import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll' import { useNativeChatOlderHistoryAutoload } from './use-native-chat-older-history-autoload' import { NativeChatOlderHistoryRow } from './NativeChatOlderHistoryRow' @@ -39,14 +44,13 @@ import type { import { useNativeChatRailHistoryJump } from './use-native-chat-rail-history-jump' import { nativeChatReaderScrollInputHandlers } from './native-chat-reader-scroll-input' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' import { nativeChatSubagentLabels } from '../../../../shared/native-chat-subagent-attribution' -import { - selectNativeChatActiveTurnKey, - type NativeChatSettledTurns -} from '../../../../shared/native-chat-turn-status' -import { nativeChatRowTurnKeys } from '../../../../shared/native-chat-turn-grouping' +import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import { nativeChatTurnDiffs, type NativeChatDiffReveal, @@ -69,6 +73,7 @@ type NativeChatNavigationRequest = export function NativeChatMessageList({ session, journalItems, + journalSubmissions, railOutline = null, isVisible = true, isWorking, @@ -78,8 +83,6 @@ export function NativeChatMessageList({ allowFileUriLinks = false, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId = null, deliveryNotices, awaitingInput = null, turnActivity, @@ -87,6 +90,8 @@ export function NativeChatMessageList({ }: { session: NativeChatLiveSession journalItems?: readonly AgentJournalRenderItem[] + /** With the items, what places each row in its turn (structured lane). */ + journalSubmissions?: readonly AgentJournalSubmission[] /** User messages older than the loaded window, from the host's outline. */ railOutline?: readonly NativeChatRailOutlineEntry[] | null isVisible?: boolean @@ -99,11 +104,6 @@ export function NativeChatMessageList({ /** Recorded turn durations keyed by user message id (the host's, or the transcript's). * A turn missing here shows the duration this list observed, if it saw the turn run. */ settledTurns?: NativeChatSettledTurns - /** The key the host says anchors the running turn's bar (structured lane). */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership per journal item id (structured lane). - * Rows it does not name keep positional preceding-user grouping. */ - turnKeysByItemId?: ReadonlyMap | null onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotices?: ReadonlyMap @@ -163,18 +163,16 @@ export function NativeChatMessageList({ const subagentLabels = useMemo(() => nativeChatSubagentLabels(messages), [messages]) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) - // Resolve each row's owning turn once. Prefix slice/findLast in the render - // loop becomes quadratic for long transcripts. - const turnKeys = useMemo( - () => nativeChatRowTurnKeys(messages, turnKeysByItemId), - [messages, turnKeysByItemId] - ) + // Each row's turn, which turn is live, and the order the rows draw in, resolved once. + const { + messages: rows, + turnKeys, + liveTurnKey + } = useNativeChatTurnMembership(messages, journalItems, journalSubmissions) const turnDiffs = useMemo( () => - journalItems - ? nativeChatTurnDiffs(messages, turnKeys) - : new Map(), - [journalItems, messages, turnKeys] + journalItems ? nativeChatTurnDiffs(rows, turnKeys) : new Map(), + [journalItems, rows, turnKeys] ) // "Thinking" is real reasoning content at the tail of the turn, not the absence // of output — the latter reports thinking while the request is merely in flight. @@ -182,10 +180,9 @@ export function NativeChatMessageList({ () => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false), [journalItems] ) - const activeTurnKey = selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) const turnStatuses = useNativeChatTurnStatus({ - messages, - activeTurnKey, + turnKeys, + liveTurnKey, isWorking, workingStartedAt, settledTurns, @@ -200,12 +197,12 @@ export function NativeChatMessageList({ ? 'activity' : null const lifecycleWorking = session.transcriptLifecycle?.state === 'working' - const slots = useMemo( + const allSlots = useMemo( () => buildNativeChatTranscriptSlots({ - messages, + messages: rows, turnKeys, - activeTurnKey, + liveTurnKey, receipts, turnStatuses, turnDiffs, @@ -215,18 +212,23 @@ export function NativeChatMessageList({ subagentLabels }), [ - activeTurnKey, + liveTurnKey, expandedTurnIds, isWorking, lifecycleWorking, - messages, receipts, + rows, subagentLabels, turnDiffs, turnKeys, turnStatuses ] ) + // 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] + ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, slots, @@ -408,6 +410,7 @@ export function NativeChatMessageList({ ) : tailRow === 'awaiting-input' ? ( ) : null} + diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx index 635eef94c35..7c62a9efe3e 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx @@ -4,6 +4,11 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' @@ -72,6 +77,40 @@ function session(startedAt: number): NativeChatLiveSession { } } +/** The journal behind `messages`: one turn `user-1` opened, holding every later row. With + * `statesScope` false, the rows carry no turn scope, as an older host writes them. */ +function journalOf( + messages: readonly NativeChatMessage[], + statesScope: boolean +): AgentJournalRenderItem[] { + const scope = (turnScope: AgentJournalTurnScope) => (statesScope ? { turnScope } : {}) + const [opener, ...rest] = messages + const entry = (message: NativeChatMessage, sequence: number): AgentJournalRenderItem => ({ + itemId: message.id, + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: message.role === 'user' ? 'user' : 'assistant', + blocks: [{ type: 'text', text: message.id }] + }, + ...scope({ kind: 'turn', turnItemId: 'turn-1' }) + }) + return [ + { ...entry(opener!, 1), ...scope({ kind: 'thread' }) }, + { + itemId: 'turn-1', + revision: 0, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: opener!.id }, + ...scope({ kind: 'thread' }) + }, + ...rest.map((message, index) => entry(message, index + 3)) + ] +} + // A finished turn is the transcript's resting state, and it should read as the // answer to the prompt — not as the transcript of the work that produced it. describe('NativeChatMessageList settled turn fold', () => { @@ -143,40 +182,46 @@ describe('NativeChatMessageList settled turn fold', () => { // The #23621 shape: the host folded a mid-turn send into the running turn, so // the rows after it are still the opener's. One bar under the opener folds // them all; the steered bubble stays visible and never grows a bar or group. - it("folds the rows after a mid-turn send behind the opener's bar", () => { - const startedAt = Date.now() - 3000 - const base = session(startedAt) - const steer: (typeof base.messages)[number] = { - id: 'mid-b', - role: 'user', - blocks: [{ type: 'text', text: 'Also check the tests.' }], - timestamp: startedAt + 2, - source: 'transcript' + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + "folds the rows after a mid-turn send behind the opener's bar on a host that %s", + (_host, statesScope) => { + const startedAt = Date.now() - 3000 + const base = session(startedAt) + const steer: (typeof base.messages)[number] = { + id: 'mid-b', + role: 'user', + blocks: [{ type: 'text', text: 'Also check the tests.' }], + timestamp: startedAt + 2, + source: 'transcript' + } + const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] + render( + + ) + + expect(screen.getByText('Also check the tests.')).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + expect(screen.queryByText(MORE_NARRATION)).toBeNull() + const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) + expect(toggles).toHaveLength(1) + + fireEvent.click(toggles[0]!) + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() } - const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] - const turnKeysByItemId = new Map(messages.map((message) => [message.id, 'user-1'])) - render( - - ) - - expect(screen.getByText('Also check the tests.')).toBeInTheDocument() - expect(screen.queryByText(NARRATION)).toBeNull() - expect(screen.queryByText(MORE_NARRATION)).toBeNull() - const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) - expect(toggles).toHaveLength(1) - - fireEvent.click(toggles[0]!) - expect(screen.getByText(NARRATION)).toBeInTheDocument() - expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() - }) + ) // The answer is the LAST prose the agent produced. A turn whose final output // is a tool run still answers with the prose before it. diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index e8d714fad1a..300a4f01fb9 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -9,7 +9,8 @@ import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import type { AgentJournalItemBody, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalTurnScope } from '../../../../shared/agent-session-journal-types' // The turn record this host writes, and the legacy status row an older host sends. @@ -649,92 +650,144 @@ describe('NativeChatMessageList turn indicator', () => { expect(screen.getByText('Working for 0s')).toBeInTheDocument() }) - it('keeps the live bar under the prompt that opened the running turn, not a message sent mid-turn', () => { - const prompt = (id: string, text: string): NativeChatLiveSession['messages'][number] => ({ - id, - role: 'user', - blocks: [{ type: 'text', text }], - timestamp: 1, - source: 'transcript' - }) - const messages = [ - prompt('user-a', 'Run four sleeps'), - { - id: 'tool-a', - role: 'assistant' as const, - blocks: [ - { - type: 'tool-call' as const, - name: 'Bash', - input: { command: 'sleep 15' }, - state: 'running' as const - } - ], - timestamp: 2, - source: 'transcript' as const - }, - prompt('user-b', 'Also say banana') - ] - const list = (props: Partial>) => ( - - ) - // B was sent while A's turn runs: the host still names A as the running turn's opener. - const { rerender } = render( - list({ - workingStartedAt: Date.now() - 5000, - settledTurns: new Map(), - activeTurnOpenedBy: 'user-a' + it.each([ + ["a host that states each row's turn", true], + ['a host that states none', false] + ])( + 'keeps the live bar under the prompt that opened the running turn, not a message sent mid-turn, on %s', + (_, scoped) => { + const prompt = (id: string, text: string): NativeChatLiveSession['messages'][number] => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' }) - ) - const barA = screen.getByText('Working for 5s').closest('[data-native-chat-turn-status]') - expect(screen.getAllByText(/Working for/)).toHaveLength(1) - expect(screen.getByText('Run four sleeps').compareDocumentPosition(barA!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) - expect(barA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) + const messages = [ + prompt('user-a', 'Run four sleeps'), + { + id: 'tool-a', + role: 'assistant' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'Bash', + input: { command: 'sleep 15' }, + state: 'running' as const + } + ], + timestamp: 2, + source: 'transcript' as const + }, + prompt('user-b', 'Also say banana') + ] + // B was handed over while A's turn runs, so a host that states scopes puts it in that turn. + const t1: AgentJournalTurnScope = { kind: 'turn', turnItemId: 't1' } + const thread: AgentJournalTurnScope = { kind: 'thread' } + const row = ( + itemId: string, + sequence: number, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence, + observedAt: sequence, + body, + ...(scoped ? { turnScope } : {}) + }) + const journal = (t1State: 'running' | 'completed', t2State?: 'running' | 'completed') => [ + row('user-a', 1, { kind: 'message', role: 'user', blocks: [] }, thread), + row('t1', 2, { kind: 'turn', turnId: 't1', state: t1State, userItemId: 'user-a' }, thread), + row('tool-a', 3, { kind: 'message', role: 'assistant', blocks: [] }, t1), + row('user-b', 4, { kind: 'message', role: 'user', blocks: [] }, t1), + ...(t2State + ? [ + row( + 't2', + 5, + { kind: 'turn', turnId: 't2', state: t2State, userItemId: 'user-b' }, + thread + ) + ] + : []) + ] + const list = (props: Partial>) => ( + + ) + // B was sent while A's turn runs: the host's record still names A as the running turn's opener. + const { rerender } = render( + list({ + workingStartedAt: Date.now() - 5000, + settledTurns: new Map(), + journalItems: journal('running') + }) + ) + const barA = screen.getByText('Working for 5s').closest('[data-native-chat-turn-status]') + expect(screen.getAllByText(/Working for/)).toHaveLength(1) + expect(screen.getByText('Run four sleeps').compareDocumentPosition(barA!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(barA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) - // B's own turn opens: A settles in place and B counts from A's end. - rerender( - list({ - workingStartedAt: Date.now() - 1000, - settledTurns: new Map([['user-a', { startedAt: 1, workedSeconds: 17 }]]), - activeTurnOpenedBy: 'user-b' - }) - ) - expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) - const barB = screen.getByText('Working for 1s').closest('[data-native-chat-turn-status]') - expect(screen.getByText('Also say banana').compareDocumentPosition(barB!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) + // A settles in place: under its prompt, above B. A's tool work is A's on either host (read + // from its scope, or from journal order), so the settled bar becomes that work's fold toggle. + const expectSettledUnderA = () => { + const settledA = screen + .getByText('Worked for 17s') + .closest('[data-native-chat-turn-status]') + expect(screen.getByText('Run four sleeps').compareDocumentPosition(settledA!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(settledA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + } - rerender( - list({ - isWorking: false, - session: { ...session, status: 'ready', messages }, - workingStartedAt: null, - settledTurns: new Map([ - ['user-a', { startedAt: 1, workedSeconds: 17 }], - ['user-b', { startedAt: 2, workedSeconds: 51 }] - ]), - activeTurnOpenedBy: null - }) - ) - expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) - expect( - screen - .getByText('Also say banana') - .compareDocumentPosition(screen.getByText('Worked for 51s')) - ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(screen.queryByText(/Working for/)).toBeNull() - }) + // B's own turn opens: A settles in place and B counts from A's end. + rerender( + list({ + workingStartedAt: Date.now() - 1000, + settledTurns: new Map([['user-a', { startedAt: 1, workedSeconds: 17 }]]), + journalItems: journal('completed', 'running') + }) + ) + expectSettledUnderA() + const barB = screen.getByText('Working for 1s').closest('[data-native-chat-turn-status]') + expect(screen.getByText('Also say banana').compareDocumentPosition(barB!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + + rerender( + list({ + isWorking: false, + session: { ...session, status: 'ready', messages }, + workingStartedAt: null, + settledTurns: new Map([ + ['user-a', { startedAt: 1, workedSeconds: 17 }], + ['user-b', { startedAt: 2, workedSeconds: 51 }] + ]), + journalItems: journal('completed', 'completed') + }) + ) + expectSettledUnderA() + expect( + screen + .getByText('Also say banana') + .compareDocumentPosition(screen.getByText('Worked for 51s')) + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.queryByText(/Working for/)).toBeNull() + } + ) it("uses the completed caret to expand that turn's tool details", () => { const startedAt = Date.now() - 3000 diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx new file mode 100644 index 00000000000..ec6ca5b3734 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx @@ -0,0 +1,432 @@ +// @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 { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} +const say = ( + itemId: string, + role: 'user' | 'assistant', + text: string, + scope: AgentJournalTurnScope = THREAD +) => item(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, scope) +const work = (itemId: string, scope: AgentJournalTurnScope) => + item( + itemId, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }] + }, + scope + ) +const settledTurn = (itemId: string, userItemId: string, seconds: number, start: number) => + item(itemId, { + kind: 'turn', + turnId: itemId, + state: 'completed', + outcome: 'success', + userItemId, + startedAt: start, + completedAt: start + seconds * 1000 + }) + +/** "List three fruits", answered and settled in 4s. */ +function fruitTurn(): AgentJournalRenderItem[] { + return [ + say('u1', 'user', 'List three fruits'), + settledTurn('t1', 'u1', 4, 1_000), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + work('t1-work', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] +} + +function journalList( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + { + isWorking = false, + workingStartedAt = null, + hostTiming = true + }: { isWorking?: boolean; workingStartedAt?: number | null; hostTiming?: boolean } = {} +): React.JSX.Element { + return ( + + ) +} + +function renderJournal( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + isWorking = false +): void { + render(journalList(items, submissions, { isWorking })) +} + +function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) +} + +describe('NativeChatMessageList turns from the turn record', () => { + it('gives /compact its own turn and result, and leaves the turn before it untouched', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + renderJournal(items, [ + { + clientMessageId: 'cmd-1', + fence: 1, + payloadFingerprint: 'f', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: 12_000 + } + ]) + + expect(screen.getByText('Apple, banana, cherry.')).toBeInTheDocument() + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + const compact = screen.getByText('/compact') + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 2s') + expect(follows(compact, previousStatus)).toBe(true) + expect(follows(ownStatus, compact)).toBe(true) + expect(screen.getByRole('separator', { name: 'Context compacted' })).toBeInTheDocument() + }) + + it('draws a message waiting behind /compact after the compaction, never above it', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + // Accepted while the command ran, so written before the command's result. + say(held, 'user', 'Say DONE'), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: + handedOverAt === undefined && clientMessageId === 'held' ? 'pending' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + // The command settled; the loop has not yet handed the held message over. + renderJournal(items, [submission('cmd-1', 9_500), submission('held')]) + + expect( + follows( + screen.getByText('Say DONE'), + screen.getByRole('separator', { name: 'Context compacted' }) + ) + ).toBe(true) + }) + + it('keeps the row that says why a turn stopped visible in its folded turn', () => { + const items = [ + ...fruitTurn(), + item( + 'exit', + { kind: 'status', text: 'The agent exited unexpectedly.', tone: 'error' }, + inTurn('t1') + ) + ] + renderJournal(items) + + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + expect(screen.getByText('The agent exited unexpectedly.')).toBeInTheDocument() + }) + + it('folds a turn the provider resumed on its own under its own status', () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + // Claude keys a resumed turn's record on itself: no message opened it. + settledTurn(wake, wake, 9, 20_000), + say('wake-narration', 'assistant', 'The background build finished.', inTurn(wake)), + work('wake-work', inTurn(wake)), + say('wake-answer', 'assistant', 'All tests pass now.', inTurn(wake)) + ] + renderJournal(items) + + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 9s') + expect(follows(ownStatus, screen.getByText('Apple, banana, cherry.'))).toBe(true) + expect(follows(ownStatus, previousStatus)).toBe(true) + expect(screen.queryByText('The background build finished.')).toBeNull() + expect(screen.getByText('All tests pass now.')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Toggle turn details' })).toHaveLength(2) + }) + + it("keeps the settled turn's duration while a turn the provider opened runs after it", () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + item(wake, { + kind: 'turn', + turnId: wake, + state: 'running', + userItemId: wake, + startedAt: 20_000 + }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + renderJournal(items, [], true) + + expect(screen.getByText('Worked for 4s')).toBeInTheDocument() + expect(screen.getByText('Checking the background build.')).toBeInTheDocument() + }) + + it('clocks a turn the provider opened under its own key, not the user turn before it', () => { + const wake = 'wake' + const ask = say('u1', 'user', 'List three fruits') + const answer = (state: 'running' | 'completed') => [ + ask, + item('t1', { kind: 'turn', turnId: 't1', state, userItemId: 'u1' }), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] + // No host durations: this client's own clock is all the rows have. + const local = { hostTiming: false } + const { rerender } = render( + journalList(answer('running'), [], { + ...local, + isWorking: true, + workingStartedAt: Date.now() - 7_000 + }) + ) + rerender(journalList(answer('completed'), [], local)) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + + const woken = [ + ...answer('completed'), + item(wake, { kind: 'turn', turnId: wake, state: 'running', userItemId: 'claude:wake' }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + rerender(journalList(woken, [], { ...local, isWorking: true, workingStartedAt: Date.now() })) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + }) + + it('draws a message waiting behind a running /compact after its live activity', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'running', + userItemId: compactEntry, + startedAt: 10_000 + }), + say(held, 'user', 'Say DONE') + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: handedOverAt === undefined ? 'pending' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + const { container } = render( + journalList(items, [submission('cmd-1', 9_500), submission('held')], { + isWorking: true, + workingStartedAt: 10_000 + }) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('/compact'))).toBe(true) + expect(follows(screen.getByText('Say DONE'), activity!)).toBe(true) + }) + + it("keeps a steer on its way into an ordinary running turn above that turn's activity", () => { + const steer = agentJournalSubmissionKey('steer') + const items = [ + say('u1', 'user', 'List three fruits'), + item('t1', { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: 'u1', + startedAt: 10_000 + }), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + say(steer, 'user', 'Make it four') + ] + const { container } = render( + journalList( + items, + [ + { + clientMessageId: 'steer', + fence: 1, + payloadFingerprint: 'steer', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 10_500, + resolvedAt: null, + handoverRecorded: true + } + ], + { isWorking: true, workingStartedAt: 10_000 } + ) + ) + + // Handed over within moments, where it already is: it must not cross the activity line to wait. + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Make it four'))).toBe(true) + }) + + it('keeps a message whose own start is pending above the activity that start reports', () => { + const waiting = agentJournalSubmissionKey('first') + const { container } = render( + journalList( + [say(waiting, 'user', 'Hello there')], + [ + { + clientMessageId: 'first', + fence: 1, + payloadFingerprint: 'first', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true + } + ], + { isWorking: true, workingStartedAt: 9_000 } + ) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Hello there'))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 1f416bcc070..935d247309a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -273,6 +273,7 @@ export function NativeChatStructuredSession( ) } + +/** Rows drawn after the live turn's activity, outside the window: few, and the newest there are. */ +export function NativeChatWaitingTranscriptItems({ + slots, + context +}: { + slots: readonly NativeChatTranscriptSlot[] + context: NativeChatTranscriptRowContext +}): React.JSX.Element { + return ( + <> + {slots.map((slot) => ( + + ))} + + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index 3a08c6a503e..a541c601966 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -10,7 +10,6 @@ import { NATIVE_CHAT_RAIL_MAX_TICKS, type NativeChatRailItem } from './native-chat-message-rail-items' -import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function text(id: string, body: string, role: NativeChatMessage['role'] = 'assistant') { return { @@ -43,7 +42,7 @@ function slotsOf(messages: NativeChatMessage[]) { return buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index b1efe034acd..f84a99ed2c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -18,7 +18,6 @@ import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } @@ -82,7 +81,7 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour const slots = buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: turn, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index 674e14d6bf8..30f818d8d03 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -93,13 +93,13 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): // Why: the tail bubbles form fixed tiers that timestamps alone can't express. // The streaming preview (null timestamp) must follow real content but sit ahead -// of the optimistic composer echoes, which carry finite `sentAt` timestamps that -// would otherwise sort past it. Rank first, then timestamp within a tier. +// of the optimistic composer echoes and queued sends, which carry finite timestamps +// that would otherwise sort past it. Rank first, then timestamp within a tier. function messageSortRank(message: NativeChatMessage): number { if (message.id === NATIVE_CHAT_STREAMING_ID) { return 1 } - if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { + if (message.queued || isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { return 2 } return 0 diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 4c908375576..4278f216e87 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' import { - selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' @@ -39,7 +44,7 @@ function build( return buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: undefined, receipts: new Map(), turnStatuses: NO_STATUSES, turnDiffs: new Map(), @@ -111,7 +116,7 @@ describe('transcript slots', () => { it('keeps a message whose only content is a turn status under it', () => { const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 4 } const slots = build([text('u', '', 'user')], { - activeTurnKey: 'u', + liveTurnKey: 'u', turnStatuses: { active: status, completedByTurn: {} } }) expect(slots).toHaveLength(1) @@ -141,7 +146,7 @@ describe('transcript slots', () => { it('puts the running turn bar under the prompt it answers', () => { const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null } const slots = build([text('u', 'ask', 'user'), text('a', 'answer')], { - activeTurnKey: 'u', + liveTurnKey: 'u', turnStatuses: { active: status, completedByTurn: {} }, isWorking: true }) @@ -206,6 +211,217 @@ describe('a send the host rejected', () => { }) }) +describe('a turn no message opened', () => { + const settled = (workedSeconds: number): NativeChatTurnStatus => ({ + startedAt: 1, + thinking: false, + workedSeconds + }) + + it('draws its status at its first row and folds its work behind it', () => { + const messages = [ + text('u1', 'List three fruits', 'user'), + text('a1', 'Apple, banana, cherry.'), + toolRun('wake-tool'), + text('wake-answer', 'The background task finished.') + ] + const slots = build(messages, { + turnKeys: ['u1', 'u1', 'wake', 'wake'], + turnStatuses: { + active: settled(4), + completedByTurn: { u1: settled(4), wake: settled(9) } + } + }) + const statusOf = (id: string) => + slots.find((slot) => slot.message.id === id)?.status?.workedSeconds + expect(statusOf('u1')).toBe(4) + expect(statusOf('wake-tool')).toBe(9) + expect(statusOf('wake-answer')).toBeUndefined() + const toolSlot = slots.find((slot) => slot.message.id === 'wake-tool') + expect(toolSlot).toMatchObject({ folded: true, turnFolds: true, turnKey: 'wake' }) + }) + + it('keeps a row that reports its turn ending visible in a folded turn', () => { + const messages = [ + text('u1', 'go', 'user'), + toolRun('work'), + { + ...text('exit', 'The agent exited unexpectedly.', 'system'), + blocks: [{ type: 'text' as const, text: 'The agent exited unexpectedly.', tone: 'error' }] + } + ] + const slots = build(messages, { + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + // The folded work takes no slot; the report of the end still does. + expect(slots.map((slot) => [slot.message.id, slot.folded])).toEqual([ + ['u1', false], + ['exit', false] + ]) + }) +}) + +describe('the live turn', () => { + const settled = (workedSeconds: number): NativeChatTurnStatus => ({ + startedAt: 1, + thinking: false, + workedSeconds + }) + const working: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null } + const THREAD: AgentJournalTurnScope = { kind: 'thread' } + const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + let sequence = 0 + const entry = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD + ): AgentJournalRenderItem => { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } + } + const record = (itemId: string, userItemId: string, state: 'running' | 'completed') => + entry(itemId, { kind: 'turn', turnId: itemId, state, userItemId }) + const row = (id: string, turnScope: AgentJournalTurnScope = THREAD) => + entry( + id, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] }, + turnScope + ) + + /** "List three fruits", answered and settled; then a turn the provider opened on its own. */ + const messages = [ + text('u1', 'List three fruits', 'user'), + text('a1', 'Apple, banana, cherry.'), + toolRun('wake-tool'), + text('wake-note', 'Checking the background build.') + ] + function wakeJournal(state: 'running' | 'completed'): AgentJournalRenderItem[] { + return [ + entry('u1', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }), + record('t1', 'u1', 'completed'), + row('a1', inTurn('t1')), + record('wake', 'claude:wake', state), + row('wake-tool', inTurn('wake')), + row('wake-note', inTurn('wake')) + ] + } + function buildLive( + rows: NativeChatMessage[], + journal: AgentJournalRenderItem[] | null, + overrides: Partial[0]> + ) { + const { turnKeys, liveTurnKey } = nativeChatTurnMembership( + rows, + journal ? { items: journal, submissions: [] } : null + ) + return build(rows, { turnKeys, liveTurnKey, ...overrides }) + } + const slotOf = (slots: ReturnType, id: string) => + slots.find((slot) => slot.message.id === id) + + it('leaves the settled user turn its duration while a turn the provider opened runs', () => { + const slots = buildLive(messages, wakeJournal('running'), { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect(slotOf(slots, 'u1')?.status?.workedSeconds).toBe(4) + expect( + slots.filter((slot) => slot.status !== undefined).map((slot) => slot.message.id) + ).toEqual(['u1', 'wake-tool']) + }) + + it('draws a turn the provider opened at its first row, running and then settled', () => { + const running = buildLive(messages, wakeJournal('running'), { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect(running.map((slot) => [slot.message.id, slot.status])).toEqual([ + ['u1', settled(4)], + ['a1', undefined], + ['wake-tool', working], + ['wake-note', undefined] + ]) + const ended = buildLive(messages, wakeJournal('completed'), { + turnStatuses: { active: settled(4), completedByTurn: { u1: settled(4), wake: settled(9) } } + }) + expect(slotOf(ended, 'u1')?.status?.workedSeconds).toBe(4) + expect(slotOf(ended, 'wake-tool')?.status?.workedSeconds).toBe(9) + }) + + it('keeps the running turn live, and the settled turn before it settled', () => { + const slots = buildLive(messages, wakeJournal('running'), { isWorking: true }) + expect(slots.map((slot) => [slot.message.id, slot.turnKey, slot.activeTurnIsWorking])).toEqual([ + ['u1', 'u1', false], + ['a1', 'u1', false], + ['wake-tool', 'wake', true], + ['wake-note', 'wake', true] + ]) + }) + + it('leaves the settled user turn alone while the running turn has drawn nothing yet', () => { + const journal = wakeJournal('running').slice(0, 4) + const slots = buildLive(messages.slice(0, 2), journal, { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect( + slots.map((slot) => [slot.message.id, slot.status?.workedSeconds, slot.activeTurnIsWorking]) + ).toEqual([ + ['u1', 4, false], + ['a1', undefined, false] + ]) + }) + + it('still draws a running turn a message opened on that message, with its rows live', () => { + const journal = [ + entry('u1', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }), + record('t1', 'u1', 'running'), + row('a1', inTurn('t1')) + ] + const rows = messages.slice(0, 2) + const live = buildLive(rows, journal, { isWorking: true }) + expect(live.map((slot) => [slot.turnKey, slot.activeTurnIsWorking])).toEqual([ + ['u1', true], + ['u1', true] + ]) + const stopped = buildLive(rows, journal, { + turnStatuses: { active: settled(3), completedByTurn: {} } + }) + expect(slotOf(stopped, 'u1')?.status?.workedSeconds).toBe(3) + }) + + it('reads a turn the provider opened by journal order where the host states no scope', () => { + const unscoped = wakeJournal('running').map(({ turnScope: _scope, ...rest }) => rest) + const slots = buildLive(messages, unscoped, { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect( + slots.map((slot) => [slot.turnKey, slot.status?.workedSeconds, slot.activeTurnIsWorking]) + ).toEqual([ + ['u1', 4, false], + ['u1', undefined, false], + ['wake', null, true], + ['wake', undefined, true] + ]) + }) + + it('keeps the newest user row live with no journal', () => { + const slots = buildLive(messages, null, { + isWorking: true, + turnStatuses: { active: settled(2), completedByTurn: {} } + }) + expect( + slots.map((slot) => [slot.turnKey, slot.status?.workedSeconds, slot.activeTurnIsWorking]) + ).toEqual([ + ['u1', 2, true], + ['u1', undefined, true], + ['u1', undefined, true], + ['u1', undefined, true] + ]) + }) +}) + // Rows belong to the turn the host says owns them, not to the nearest preceding // user bubble. The owned turn keys reshape the fold, the bar and liveness. describe('turn-owned grouping', () => { @@ -257,7 +473,7 @@ describe('turn-owned grouping', () => { const messages = [text('u1', 'earlier', 'user'), toolRun('w1'), text('done', 'Woke up.')] const slots = build(messages, { turnKeys: ['u1', 'wake', 'wake'], - activeTurnKey: 'wake', + liveTurnKey: 'wake', turnStatuses: { active: settled, completedByTurn: {} }, isWorking: true }) @@ -302,7 +518,7 @@ describe('turn-owned grouping', () => { const messages = [text('A', 'go', 'user'), toolRun('t1'), text('C', 'next up', 'user')] const slots = build(messages, { turnKeys: ['A', 'A', 'C'], - activeTurnKey: 'A', + liveTurnKey: 'A', isWorking: true }) expect(slots.map((slot) => [slot.message.id, slot.activeTurnIsWorking])).toEqual([ 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 b3631fb8012..7ec4a81653d 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 @@ -7,6 +7,7 @@ // single place that answers "does this message take a slot?", and it answers it // with the same derivation the row itself renders from. +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { isBackgroundTaskBlock, isSubagentGroupBlock, @@ -15,11 +16,9 @@ import { } from '../../../../shared/native-chat-types' import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' import { nativeChatSubagentLabel } from '../../../../shared/native-chat-subagent-attribution' -import { - NATIVE_CHAT_UNANCHORED_TURN_KEY, - type NativeChatTurnStatus -} from '../../../../shared/native-chat-turn-status' -import { nativeChatSelfAnchoredTurnRows } from '../../../../shared/native-chat-turn-grouping' +import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' +import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../../shared/native-chat-turn-membership' +import { nativeChatTurnBarRows } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnFold, type NativeChatTurnFoldRow @@ -67,8 +66,9 @@ export type NativeChatTranscriptSlot = { export type NativeChatTranscriptSlotsInput = { messages: readonly NativeChatMessage[] turnKeys: readonly (string | undefined)[] - /** The transcript key whose bar carries the live turn's status. */ - activeTurnKey: string + /** The live turn (`nativeChatTurnMembership`): its bar carries the running clock and its rows + * stay live. Undefined when no row has opened one. */ + liveTurnKey: string | undefined receipts: ReadonlyMap turnStatuses: { active: NativeChatTurnStatus | null @@ -90,7 +90,7 @@ export function buildNativeChatTranscriptSlots( const { messages, turnKeys, - activeTurnKey, + liveTurnKey, receipts, turnStatuses, turnDiffs, @@ -113,6 +113,10 @@ export function buildNativeChatTranscriptSlots( outlivesTurn: message.blocks.some( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) ), + reportsTurnOutcome: message.blocks.some( + (block) => + block.type === 'text' && (block.tone === 'error' || block.presentation === 'compaction') + ), ...(agentId === null ? {} : { agentId }) } }) @@ -148,8 +152,7 @@ export function buildNativeChatTranscriptSlots( settledTurnKeys, expandedTurnKeys }) - // A turn with no user bubble (provider-opened) anchors its bar at its first row. - const selfAnchors = nativeChatSelfAnchoredTurnRows(messages, turnKeys) + const bars = nativeChatTurnBarRows(messages, turnKeys) // A turn's rows need not be contiguous (another turn's prompt can land among // them), so its rollup goes under its last row, not every run boundary. const lastRowByTurn = new Map() @@ -162,19 +165,14 @@ export function buildNativeChatTranscriptSlots( for (const [index, message] of messages.entries()) { const turnKey = turnKeys[index] const receipt = receipts.get(message.id) - const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index - // Only the bubble that opened a turn carries its bar: a message the provider - // folded into a running turn shares the turn's key but not its bar. + const bar = turnKey === undefined ? undefined : bars.get(turnKey) + // A turn's bar draws at its first row, so a message folded into it (a steer) carries none. const candidateStatus = - message.role === 'user' && message.id === activeTurnKey - ? turnStatuses.active - : message.role === 'user' && turnKey === message.id - ? turnStatuses.completedByTurn[turnKey] - : anchorsTurnHere - ? turnKey === activeTurnKey - ? (turnStatuses.active ?? turnStatuses.completedByTurn[turnKey]) - : turnStatuses.completedByTurn[turnKey] - : undefined + turnKey === undefined || bar?.index !== index + ? undefined + : turnKey === liveTurnKey + ? turnStatuses.active + : turnStatuses.completedByTurn[turnKey] // The live turn's bar carries its running clock; it settles in place. const status = candidateStatus ?? undefined const turnDiff = @@ -195,13 +193,12 @@ export function buildNativeChatTranscriptSlots( // Liveness is the owning turn's, not the newest prompt's: a running turn's // rows stay live while a newer message waits behind it. activeTurnIsWorking: - (turnKey === activeTurnKey || - (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) && + (liveTurnKey ? turnKey === liveTurnKey : turnKey === undefined) && (isWorking || lifecycleWorking), trailingRun: trailingRunIndexes.has(index), receipt, status: status ?? undefined, - statusAbove: anchorsTurnHere && status !== undefined, + statusAbove: bar?.above === true && status !== undefined, folded, turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey), turnDiff, @@ -230,3 +227,19 @@ export function nativeChatSlotIndexOf( } return slots.findIndex((slot) => slot.message.id === messageId) } + +/** Splits off the slots of messages waiting behind the live turn: they draw after its live + * activity, not inside it. */ +export function splitNativeChatSlotsWaitingBehindLiveTurn( + slots: readonly NativeChatTranscriptSlot[], + journalItems: readonly AgentJournalRenderItem[] | undefined +): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { + const waiting = nativeChatMessagesWaitingBehindLiveTurn( + slots.map((slot) => slot.message), + journalItems + ) + return { + slots: slots.filter((slot) => !waiting.has(slot.message.id)), + waitingSlots: slots.filter((slot) => waiting.has(slot.message.id)) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index 6772eec7a47..518e33a9586 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -8,7 +8,6 @@ import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import { useNativeChatMessageRail } from './use-native-chat-message-rail' -import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function message(id: string, role: NativeChatMessage['role']): NativeChatMessage { return { @@ -32,7 +31,7 @@ function slotsOf(messages: NativeChatMessage[]) { return buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts new file mode 100644 index 00000000000..1793dcc1d68 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' +import { nativeChatRowsInDrawOrder } from '../../../../shared/native-chat-turn-grouping' + +export type NativeChatTurnRows = { + /** The rows in the order the transcript draws them. */ + messages: readonly NativeChatMessage[] + /** Each drawn row's turn, by index into `messages`. */ + turnKeys: readonly (string | undefined)[] + liveTurnKey: string | undefined +} + +/** Each row's turn, which turn is live, and the order the rows draw in, resolved once: from the + * turn record when the host states scopes, else by journal order. */ +export function useNativeChatTurnMembership( + messages: readonly NativeChatMessage[], + journalItems: readonly AgentJournalRenderItem[] | undefined, + journalSubmissions: readonly AgentJournalSubmission[] | undefined +): NativeChatTurnRows { + return useMemo(() => { + const { turnKeys, liveTurnKey, drawOrder } = nativeChatTurnMembership( + messages, + journalItems ? { items: journalItems, submissions: journalSubmissions ?? [] } : null + ) + return { + messages: nativeChatRowsInDrawOrder(messages, drawOrder), + turnKeys: nativeChatRowsInDrawOrder(turnKeys, drawOrder), + liveTurnKey + } + }, [journalItems, journalSubmissions, messages]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts index 33a795b8b9d..6bf7f2fdc81 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts @@ -1,6 +1,6 @@ -import { useLayoutEffect, useState } from 'react' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { useLayoutEffect, useMemo, useState } from 'react' import { + NATIVE_CHAT_UNANCHORED_TURN_KEY, reduceNativeChatTurnTiming, selectNativeChatTurnStatuses, type NativeChatSettledTurns, @@ -11,16 +11,17 @@ import { export type { NativeChatTurnStatus } export function useNativeChatTurnStatus({ - messages, - activeTurnKey, + turnKeys, + liveTurnKey, isWorking, workingStartedAt, settledTurns, thinking = false }: { - messages: readonly NativeChatMessage[] - /** The user message whose bar carries the live clock (`selectNativeChatActiveTurnKey`). */ - activeTurnKey: string + /** Each row's turn, as `nativeChatTurnMembership` places it. */ + turnKeys: readonly (string | undefined)[] + /** The live turn, whose bar carries the running clock (`nativeChatTurnMembership`). */ + liveTurnKey: string | undefined isWorking: boolean workingStartedAt?: number | null /** Recorded durations (the host's journal or the transcript); they outrank what this client observed. */ @@ -31,12 +32,14 @@ export function useNativeChatTurnStatus({ active: NativeChatTurnStatus | null completedByTurn: Readonly> } { + const activeTurnKey = liveTurnKey ?? NATIVE_CHAT_UNANCHORED_TURN_KEY const [timingByTurn, setTimingByTurn] = useState({}) + const validTurnKeys = useMemo( + () => new Set(turnKeys.filter((turnKey) => turnKey !== undefined)), + [turnKeys] + ) useLayoutEffect(() => { - const validTurnKeys = new Set( - messages.filter((message) => message.role === 'user').map((message) => message.id) - ) setTimingByTurn((current) => reduceNativeChatTurnTiming(current, { activeTurnKey, @@ -46,7 +49,7 @@ export function useNativeChatTurnStatus({ now: Date.now() }) ) - }, [activeTurnKey, isWorking, messages, workingStartedAt]) + }, [activeTurnKey, isWorking, validTurnKeys, workingStartedAt]) return selectNativeChatTurnStatuses(timingByTurn, { activeTurnKey, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx new file mode 100644 index 00000000000..6fc339e90c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + outboxSend: vi.fn() +})) +let items: AgentJournalRenderItem[] = [] + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: () => 'operation-1', + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: mocks.outboxSend, + retry: vi.fn() + }) +})) + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { useStructuredAgentSession } from './use-structured-agent-session' + +function answer(text: string, scoped: boolean): AgentJournalRenderItem { + return { + itemId: text, + revision: 0, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, + ...(scoped ? { turnScope: { kind: 'thread' as const } } : {}) + } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.outboxSend.mockReturnValue(true) +}) + +/** Starts `/compact` and leaves its reply outstanding, then sends a message. */ +function sendDuringCommand(): boolean { + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.conversationCommand' ? new Promise(() => {}) : Promise.resolve(null) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + act(() => { + void result.current.runConversationCommand('compact') + }) + return result.current.send('typed during the command') +} + +it('still refuses a message locally while an older host runs a command', () => { + items = [answer('from an older host', false)] + + expect(sendDuringCommand()).toBe(false) + expect(mocks.outboxSend).not.toHaveBeenCalled() +}) + +it('queues a message typed during a command on a host that runs it as a turn', () => { + items = [answer('from this host', true)] + + expect(sendDuringCommand()).toBe(true) + expect(mocks.outboxSend).toHaveBeenCalledOnce() +}) 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 8756d47aa71..ce66d47cbce 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 @@ -30,6 +30,7 @@ import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisi import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal' import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage' import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' +import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -169,14 +170,15 @@ export function useStructuredAgentSession(args: { /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, blockedClientMessageId: outboxController.blockedClientMessageId, + // A message typed during a command queues behind it on the host. send: (...input: Parameters) => - !commandPending.current && outboxController.send(...input), + // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. + (!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) && + outboxController.send(...input), retry: outboxController.retry, isWorking: transportState.isWorking, workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, - activeTurnOpenedBy: transportState.turnTiming.activeTurnOpenedBy, - turnKeysByItemId: transportState.turnTiming.turnKeysByItemId, turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx index d065f34f81c..c946c830866 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx @@ -76,7 +76,8 @@ describe('useStructuredAgentTurnTiming', () => { vi.useFakeTimers() vi.setSystemTime(CLIENT_NOW) const running = [ - user('u1', 1), + // The acknowledged send: its entry's key is the submission's, not the provider's. + user('orca:first', 1), lifecycle( 't1', 2, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts index dd6b1662824..e12975bf5ce 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts @@ -12,10 +12,8 @@ import { } from '../../../../shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the transcript key that owns the running turn's bar, each - * row's owning turn, and a - * skew-free start for the live counter whose host-to-local conversion is latched - * once per turn. */ + * off the journal, and a skew-free start for the live counter whose host-to-local + * conversion is latched once per turn. */ export function useStructuredAgentTurnTiming( { items, @@ -27,13 +25,8 @@ export function useStructuredAgentTurnTiming( hostClock?: StructuredAgentHostClock | null }, turnId: string | null -): { - settledTurns: NativeChatSettledTurns - workingStartedAt: number | null - activeTurnOpenedBy: string | null - turnKeysByItemId: ReadonlyMap -} { - const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( +): { settledTurns: NativeChatSettledTurns; workingStartedAt: number | null } { + const { settledTurns, runningTiming } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) @@ -50,10 +43,5 @@ export function useStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { - settledTurns, - workingStartedAt: step.workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId - } + return { settledTurns, workingStartedAt: step.workingStartedAt } } diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 515ba2300c5..82fd9caca69 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -120,6 +120,22 @@ describe('the words written beside a failure fact', () => { ) }) + it('names /compact as the next step for the start a /compact needed', () => { + const compact = (kind: AgentSessionFailureKind) => + agentSessionFailureSentence({ kind }, 'rejection', { + agentName: 'Claude', + command: 'compact' + }) + expect(compact('notSignedIn')).toBe( + 'Claude is not signed in for the selected account. Sign in, then run /compact again.' + ) + expect(compact('startFailed')).toBe("Claude couldn't start. Run /compact again.") + expect(compact('restartFailed')).toBe("Claude couldn't restart. Run /compact again.") + expect(compact('providerStartFailed')).toBe( + 'Claude stopped before it finished starting. Run /compact again.' + ) + }) + it('names the agent that stopped starting, and a failed start or restart gives a next step', () => { const sentence = (kind: AgentSessionFailureKind, agentName?: string) => agentSessionFailureSentence({ kind }, 'rejection', { agentName }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 06befa038eb..2b334cf9629 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -16,6 +16,7 @@ import { type SubmissionRejectionFact, type SubmissionRejectionKind } from './agent-session-failure' +import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { DISPATCH_REJECTED_CANCELLED, @@ -52,7 +53,7 @@ export type AgentSessionFailureWordsContext = { provider?: 'claude' | 'codex' /** The conversation command a failed start was for, so the next step is to run it again * rather than to send a message. */ - command?: 'clear' + command?: AgentSessionConversationCommand /** The surface retries for the person — its own Retry beside the words, or a read that reconnects * on its own — so they leave out sending or trying again. */ retryControl?: boolean @@ -109,7 +110,7 @@ function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefine /** What to do once the start can work, for a sentence that ends in it. */ function retryStep({ command }: AgentSessionFailureWordsContext): string { - return command === 'clear' ? 'run /clear again' : 'send your message again' + return command ? `run /${command} again` : 'send your message again' } /** The next step after a start or restart that failed: the command, or the message, again. */ @@ -117,7 +118,7 @@ function startRetry({ command, retryControl }: AgentSessionFailureWordsContext): if (retryControl) { return '' } - return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' + return command ? ` Run /${command} again.` : ' Send your message to try again.' } function couldNot(verb: string): Sentence { @@ -201,6 +202,8 @@ const FAILURE_SENTENCES = { retryControl ? 'This message was not delivered.' : 'This message was not delivered. Send it again to continue.', + commandRefused: ({ retryControl }) => + `This command didn't run.${retryControl ? '' : ' Try it again.'}`, compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', cancelUnconfirmed: () => 'Cancellation was not confirmed.', diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index ee4d5f739b2..13568348f34 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -34,6 +34,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'chatClosed', 'hostRestarted', 'notDelivered', + /** A conversation command the chat's state refused when its turn to run came. */ + 'commandRefused', 'compactionFailed', 'compactionUnconfirmed', 'cancelUnconfirmed', @@ -119,7 +121,8 @@ export type AgentSessionFailureFact = { kind: AgentSessionFailureKind /** Provider-authored only; absent whenever Orca wrote the words. */ detail?: ProviderDiagnostic - /** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. */ + /** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. On + * `commandRefused`: the refusal that kept the command from running. */ refusal?: AgentSessionRefusalReference /** On `attachmentInvalid`: which check the attachment failed. */ attachment?: AgentSessionAttachmentProblem diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index d193db01846..c6d3fc7f5e6 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -178,7 +178,8 @@ const MessageBody = z.object({ role: z.string().min(1), blocks: z.array(Block), // Open like roles: a send mode a newer build writes must not turn the row malformed. - sentAs: z.string().min(1).optional() + sentAs: z.string().min(1).optional(), + command: z.object({ name: z.string().min(1) }).optional() }) const ThreadGoal = z.object({ @@ -276,7 +277,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ requestedAt: z.number().finite().positive().optional(), completedAt: z.number().finite().positive().optional(), durationMs: z.number().finite().nonnegative().optional(), - contextUsage: AgentSessionContextUsageSchema.optional() + contextUsage: AgentSessionContextUsageSchema.optional(), + providerTurnId: z.string().min(1).optional() }) ]) @@ -294,6 +296,13 @@ export const AgentJournalProducerLinkageFields = { attempt: z.number().int().optional() } as const +/** Open like the other persisted vocabularies: a scope kind a newer host states must not turn + * the row malformed. A reader places only `turn` with an id; anything else reads as `thread`. */ +export const AgentJournalTurnScopeSchema = z.object({ + kind: z.string().min(1), + turnItemId: z.string().min(1).optional() +}) + export const AgentJournalRenderItemSchema = z.object({ itemId: z.string().min(1), revision: z.number().int(), @@ -303,6 +312,7 @@ export const AgentJournalRenderItemSchema = z.object({ observedAt: z.number(), recovered: z.literal(true).optional(), recoveredAt: z.number().optional(), + turnScope: AgentJournalTurnScopeSchema.optional(), ...AgentJournalProducerLinkageFields }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a17561e6b81..2ca143b8681 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -103,6 +103,9 @@ export type AgentJournalMessageItem = { /** Absent ⇒ an ordinary turn input. `goal` ⇒ the text was set as the thread * goal's objective, and the provider pursues it without a turn of its own. */ sentAs?: AgentJournalMessageSendMode + /** Present on a conversation command the user sent, such as `/compact`. The text is what the + * user typed; this names the command so no reader parses it. Open like `sentAs`. */ + command?: { name: string } } export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' @@ -228,6 +231,9 @@ export type AgentJournalTurnLifecycle = { /** What the provider said about its context window during or after this turn. * Usually written by a later revision, since the provider answers after the end. */ contextUsage?: AgentSessionContextUsage + /** On a turn a conversation command opened: the provider turn that carried out the command, + * once the provider opened one. Nothing else re-derives it after the command settles. */ + providerTurnId?: string } /** Provider thread-goal lifecycle. Open like other persisted vocabularies: a @@ -341,6 +347,18 @@ export type AgentJournalProducerLinkage = { attempt?: number } +/** Which turn a row belongs to, stated by the write that created it. `turn` names the turn + * record's journal key; `thread` is a row that belongs to no turn — a notice about the + * conversation, or a message not yet delivered into one. Turn records themselves are `thread`. */ +export type AgentJournalTurnScope = { kind: 'turn'; turnItemId: string } | { kind: 'thread' } + +export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread' } + +/** Who produced a row and which turn it belongs to: what every item write states. */ +export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { + turnScope: AgentJournalTurnScope +} + /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -364,6 +382,8 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recovered?: true /** When crash reconciliation wrote this revision; present exactly when `recovered` is. */ recoveredAt?: number + /** Absent only from a host that predates it. */ + turnScope?: AgentJournalTurnScope } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/src/shared/agent-session-rewind.ts b/src/shared/agent-session-rewind.ts index 3767a1c59fc..a3cc94de7ec 100644 --- a/src/shared/agent-session-rewind.ts +++ b/src/shared/agent-session-rewind.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { AgentJournalItemBodySchema } from './agent-session-journal-schemas' +import { + AgentJournalItemBodySchema, + AgentJournalProducerLinkageFields, + AgentJournalTurnScopeSchema +} from './agent-session-journal-schemas' import { parseAgentJournalItemKey } from './agent-session-journal-item-key' import type { AgentSessionMutationEnvelope } from './agent-session-wire' @@ -42,7 +46,10 @@ export const AgentSessionRewindRecordSchema = z.object({ z.object({ itemId: Key.refine((key) => parseAgentJournalItemKey(key) !== null), body: AgentJournalItemBodySchema, - observedAt: z.number().finite() + observedAt: z.number().finite(), + // Absent on records written before rows stated them: the rebuild then places the row by position. + turnScope: AgentJournalTurnScopeSchema.optional(), + ...AgentJournalProducerLinkageFields }) ) .max(10_000) diff --git a/src/shared/agent-session-turn-record.ts b/src/shared/agent-session-turn-record.ts index d321b663b7b..2932eef5c1f 100644 --- a/src/shared/agent-session-turn-record.ts +++ b/src/shared/agent-session-turn-record.ts @@ -59,12 +59,17 @@ export function agentJournalTurnBody(turn: AgentJournalTurnLifecycle): AgentJour return { kind: 'turn', ...turn } } -/** The pre-v3 carrier, for clients that predate the `turn` item. The agent name +/** The pre-v3 carrier, for clients that predate the `turn` item. The agent name is the session's + * when the caller knows it — a command's turn is keyed `orca:…` whatever agent ran it — else it * comes from the lifecycle identity (`legacy::…`). */ export function legacyAgentJournalTurnStatusBody( turn: AgentJournalTurnLifecycle, - itemId: string + itemId: string, + sessionAgent?: string | null ): AgentJournalStatusItem { - const agent = itemId.startsWith('legacy:claude:') ? 'Claude' : 'Codex' + const agent = + (sessionAgent ?? (itemId.startsWith('legacy:claude:') ? 'claude' : 'codex')) === 'claude' + ? 'Claude' + : 'Codex' return { kind: 'status', text: agentTurnLifecycleText(agent, turn.state), turnLifecycle: turn } } diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index d6bba89e552..7608bb3864b 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -11,6 +11,7 @@ function row(overrides: Partial = {}): NativeChatTurnFold role: 'assistant', rendersProse: true, outlivesTurn: false, + reportsTurnOutcome: false, ...overrides } } @@ -146,6 +147,23 @@ describe('nativeChatTurnFold', () => { }) expect(foldedRows.size).toBe(0) }) + + it('never folds a row reporting how its turn ended, and keeps the last prose as the answer', () => { + const rows = [ + row({ role: 'user' }), + row(), + row({ role: 'system', rendersProse: true, reportsTurnOutcome: true }), + row(), + row({ role: 'system', rendersProse: true, reportsTurnOutcome: true }) + ] + const { foldedRows } = nativeChatTurnFold({ + rows, + settledTurnKeys: SETTLED, + expandedTurnKeys: NONE + }) + expect([...foldedRows]).toEqual([1]) + expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(3) + }) }) describe("a subagent's words are never the turn's answer", () => { diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 52407a9d524..9c97e2eea58 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -25,6 +25,9 @@ export type NativeChatTurnFoldRow = { * spawn roster or a background task. That row is the durable report of how * the work ended — often the only one — so it never folds. */ outlivesTurn: boolean + /** Whether the row reports how its turn ended — an error, or a compaction's result. A fold + * that hid it would leave the turn's status as the only trace of why it stopped. */ + reportsTurnOutcome: boolean /** The subagent that produced the row. Absent ⇒ the session's own agent. */ agentId?: string } @@ -87,11 +90,12 @@ export function nativeChatTurnFold({ for (const [index, row] of rows.entries()) { const { turnKey } = row // Outside the fold by construction: the reader's own message anchors the - // turn, and a roster or background-task row outlives it. + // turn, a roster or background-task row outlives it, and an end report explains it. if ( turnKey === undefined || row.role === 'user' || row.outlivesTurn || + row.reportsTurnOutcome || !settledTurnKeys.has(turnKey) ) { continue diff --git a/src/shared/native-chat-turn-grouping.test.ts b/src/shared/native-chat-turn-grouping.test.ts index 5c6425dd7ed..fed223cc83f 100644 --- a/src/shared/native-chat-turn-grouping.test.ts +++ b/src/shared/native-chat-turn-grouping.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from './native-chat-types' -import { nativeChatRowTurnKeys, nativeChatSelfAnchoredTurnRows } from './native-chat-turn-grouping' +import { + nativeChatRowTurnKeys, + nativeChatTurnBarRows, + nativeChatTurnDrawOrder +} from './native-chat-turn-grouping' function message(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' } @@ -62,15 +66,66 @@ describe('nativeChatRowTurnKeys', () => { }) }) -describe('nativeChatSelfAnchoredTurnRows', () => { - it('anchors a turn with no user bubble at its first rendered row', () => { +describe('nativeChatTurnBarRows', () => { + it('anchors a turn with no user bubble above its first rendered row', () => { const messages = [message('A', 'user'), message('t1'), message('t2'), message('t3')] const turnKeys = ['A', 'A', 'wake-turn', 'wake-turn'] - expect(nativeChatSelfAnchoredTurnRows(messages, turnKeys)).toEqual(new Map([['wake-turn', 2]])) + expect(nativeChatTurnBarRows(messages, turnKeys)).toEqual( + new Map([ + ['A', { index: 0, above: false }], + ['wake-turn', { index: 2, above: true }] + ]) + ) }) - it('never anchors a turn whose key is a rendered message', () => { + it('puts a turn opened by a rendered message under that message', () => { const messages = [message('A', 'user'), message('t1')] - expect(nativeChatSelfAnchoredTurnRows(messages, ['A', 'A']).size).toBe(0) + expect(nativeChatTurnBarRows(messages, ['A', 'A']).get('A')).toEqual({ index: 0, above: false }) + }) +}) + +describe('nativeChatTurnDrawOrder', () => { + it("draws a message that opened a later turn after the earlier turn's remaining rows", () => { + // B was sent while A ran and Claude queued it: A's tool run and answer follow B in the journal. + const messages = [ + message('A', 'user'), + message('a1'), + message('B', 'user'), + message('a2'), + message('a-done'), + message('b1') + ] + const turnKeys = ['A', 'A', 'B', 'A', 'A', 'B'] + expect(nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B']))).toEqual([ + 0, 1, 3, 4, 2, 5 + ]) + }) + + it('keeps each of several queued openers ahead of its own rows', () => { + const messages = [ + message('A', 'user'), + message('B', 'user'), + message('C', 'user'), + message('a-done'), + message('b1'), + message('c1') + ] + const turnKeys = ['A', 'B', 'C', 'A', 'B', 'C'] + const order = nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B', 'C'])) + expect(order?.map((index) => messages[index]!.id)).toEqual([ + 'A', + 'a-done', + 'B', + 'b1', + 'C', + 'c1' + ]) + }) + + it('leaves a steer, and a message that opened no turn, where they were written', () => { + const messages = [message('A', 'user'), message('steer', 'user'), message('a1')] + expect(nativeChatTurnDrawOrder(messages, ['A', 'A', 'A'], new Set(['A']))).toBeNull() + const waiting = [message('A', 'user'), message('C', 'user'), message('a1')] + expect(nativeChatTurnDrawOrder(waiting, ['A', 'C', 'A'], new Set(['A']))).toBeNull() }) }) diff --git a/src/shared/native-chat-turn-grouping.ts b/src/shared/native-chat-turn-grouping.ts index 7c181352db5..97570b23edd 100644 --- a/src/shared/native-chat-turn-grouping.ts +++ b/src/shared/native-chat-turn-grouping.ts @@ -1,20 +1,21 @@ -// Which turn each transcript row belongs to, and where a turn with no user -// bubble anchors its bar. Shared because desktop and mobile both group rows and -// place bars from these keys, and a row grouped differently on each surface is -// the same bug twice. +// Which turn each transcript row belongs to, where each turn draws its bar, and the order the rows +// draw in. Shared because desktop and mobile both group rows and place bars from these keys, and a +// row grouped differently on each surface is the same bug twice. -import type { NativeChatMessage } from './native-chat-types' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { NativeChatRole } from './native-chat-types' + +type NativeChatTurnRow = { id: string; role: NativeChatRole } /** - * Resolve each row's turn key. The host's attribution (`turnKeysByItemId`, from - * `selectStructuredAgentTurnBars`) wins, so rows after a mid-turn send stay with - * the turn that produced them. Rows it cannot name keep positional grouping — - * an unmapped user row keys itself, anything else inherits the previous row's - * key — which on a host without turn attribution is exactly today's - * preceding-user-message grouping. + * Resolve each row's turn key. The host's attribution (`turnKeysByItemId`) wins, so rows after a + * mid-turn send stay with the turn that produced them. Rows it cannot name keep positional + * grouping — an unmapped user row keys itself, anything else inherits the previous row's key — + * which with no attribution is exactly preceding-user-message grouping. */ export function nativeChatRowTurnKeys( - messages: readonly NativeChatMessage[], + messages: readonly NativeChatTurnRow[], turnKeysByItemId?: ReadonlyMap | null ): (string | undefined)[] { let currentTurnKey: string | undefined @@ -31,19 +32,124 @@ export function nativeChatRowTurnKeys( }) } -/** For each turn key with no message of its own — a turn the provider opened, or - * whose opener is outside the loaded window — the row index that anchors its - * bar: the turn's first rendered row, matching the turn's own position. */ -export function nativeChatSelfAnchoredTurnRows( - messages: readonly NativeChatMessage[], +/** + * Turn ownership for a host that states no turn scope, read from journal order: every item between + * a root turn record and the next belongs to that record's turn (the record is appended when the + * turn opens, and an opener's user item is written ahead of dispatch). `recordKeys` maps each root + * record to its anchor, or null when it names no opener. A user item that opened any turn keys + * itself; one the provider folded into a running turn (a steer) takes that turn's key, but only + * once the turn produces more rows after it, so a fresh tail send is not pulled into the turn it + * is merely waiting behind. Items before the first record stay absent. + */ +export function nativeChatJournalOrderTurnKeys( + items: readonly AgentJournalRenderItem[], + recordKeys: ReadonlyMap +): ReadonlyMap { + const openers = new Set([...recordKeys.values()].filter((key) => key !== null)) + const keys = new Map() + let currentKey: string | null = null + // User items folded into the current turn, held until a later row proves the turn continued. + let pendingUserItemIds: string[] = [] + for (const item of items) { + if (recordKeys.has(item.itemId)) { + currentKey = recordKeys.get(item.itemId) ?? null + pendingUserItemIds = [] + continue + } + if (readAgentJournalTurn(item.body)) { + continue + } + if (item.body.kind === 'message' && item.body.role === 'user') { + if (openers.has(item.itemId)) { + keys.set(item.itemId, item.itemId) + } else if (currentKey !== null) { + pendingUserItemIds.push(item.itemId) + } + continue + } + if (currentKey !== null) { + for (const userItemId of pendingUserItemIds) { + keys.set(userItemId, currentKey) + } + pendingUserItemIds = [] + keys.set(item.itemId, currentKey) + } + } + return keys +} + +/** Where each turn draws its bar: its first row, and above that row when the turn has no user + * bubble of its own (one the provider opened, or whose opener is outside the loaded window). */ +export function nativeChatTurnBarRows( + messages: readonly { id: string }[], turnKeys: readonly (string | undefined)[] -): ReadonlyMap { - const messageIds = new Set(messages.map((message) => message.id)) - const anchors = new Map() +): ReadonlyMap { + const bars = new Map() turnKeys.forEach((turnKey, index) => { - if (turnKey !== undefined && !messageIds.has(turnKey) && !anchors.has(turnKey)) { - anchors.set(turnKey, index) + if (turnKey !== undefined && !bars.has(turnKey)) { + bars.set(turnKey, { index, above: messages[index]?.id !== turnKey }) } }) - return anchors + return bars +} + +/** + * Row indexes in the order the transcript draws them, or null when that is journal order. A + * message is written when it is sent, but a provider that queues it behind the running turn opens + * its turn only after that turn ends: the rows the earlier turn wrote meanwhile are still that + * turn's. So a message that opened a turn draws after them, just before its own turn's rows. + */ +export function nativeChatTurnDrawOrder( + messages: readonly NativeChatTurnRow[], + turnKeys: readonly (string | undefined)[], + openers: ReadonlySet +): number[] | null { + const opensTurn = (index: number): boolean => + messages[index]?.role === 'user' && + turnKeys[index] === messages[index].id && + openers.has(messages[index].id) + const firstRow = new Map() + turnKeys.forEach((turnKey, index) => { + if (turnKey !== undefined && !firstRow.has(turnKey)) { + firstRow.set(turnKey, index) + } + }) + const movedAfter = new Map() + const moved = new Set() + for (const [index, message] of messages.entries()) { + if (!opensTurn(index)) { + continue + } + let after = -1 + for (let row = index + 1; row < messages.length; row += 1) { + const turnKey = turnKeys[row] + if (turnKey === undefined || opensTurn(row)) { + continue + } + // Its own turn's rows, or a later turn's, end the earlier turns' rows it waited behind. + if (turnKey === message.id || (firstRow.get(turnKey) ?? row) > index) { + break + } + after = row + } + if (after !== -1) { + moved.add(index) + movedAfter.set(after, [...(movedAfter.get(after) ?? []), index]) + } + } + if (moved.size === 0) { + return null + } + return messages.flatMap((_, index) => [ + ...(moved.has(index) ? [] : [index]), + ...(movedAfter.get(index) ?? []) + ]) +} + +/** `rows` in the transcript's draw order. */ +export function nativeChatRowsInDrawOrder( + rows: readonly T[], + drawOrder: readonly number[] | null +): readonly T[] { + return drawOrder === null ? rows : drawOrder.map((index) => rows[index]!) } diff --git a/src/shared/native-chat-turn-membership.test.ts b/src/shared/native-chat-turn-membership.test.ts new file mode 100644 index 00000000000..53963b73ef0 --- /dev/null +++ b/src/shared/native-chat-turn-membership.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey, agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { nativeChatRowsInDrawOrder } from './native-chat-turn-grouping' +import { nativeChatTurnMembership, structuredAgentTurnAnchors } from './native-chat-turn-membership' +import type { NativeChatRole } from './native-chat-types' + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + /** Null for a host that predates scopes. */ + turnScope: AgentJournalTurnScope | null = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(turnScope ? { turnScope } : {}) + } +} + +const user = (itemId: string, scope?: AgentJournalTurnScope | null) => + item(itemId, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }, scope) +const assistant = (itemId: string, scope?: AgentJournalTurnScope | null) => + item( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + scope + ) +const status = (itemId: string, scope?: AgentJournalTurnScope | null) => + item(itemId, { kind: 'status', text: itemId }, scope) +const turn = ( + itemId: string, + userItemId: string | undefined, + scope?: AgentJournalTurnScope | null, + state: 'running' | 'completed' = 'completed' +) => + item( + itemId, + { + kind: 'turn', + turnId: itemId, + state, + ...(userItemId === undefined ? {} : { userItemId }) + }, + scope + ) +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + +/** Rows as a surface hands them over: journal items that draw, in order. */ +function rows(items: readonly AgentJournalRenderItem[]): { id: string; role: NativeChatRole }[] { + return items.flatMap((entry): { id: string; role: NativeChatRole }[] => { + if (entry.body.kind === 'message') { + return [{ id: entry.itemId, role: entry.body.role === 'user' ? 'user' : 'assistant' }] + } + return entry.body.kind === 'status' ? [{ id: entry.itemId, role: 'system' }] : [] + }) +} + +function keys( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +) { + return nativeChatTurnMembership(rows(items), { items, submissions }).turnKeys +} + +function liveTurnKey(items: readonly AgentJournalRenderItem[]) { + return nativeChatTurnMembership(rows(items), { items, submissions: [] }).liveTurnKey +} + +describe('nativeChatTurnMembership', () => { + it('groups rows by the turn their scope names, and a steer into the turn it joined', () => { + const items = [ + user('u1'), + turn('t1', 'u1'), + assistant('a1', inTurn('t1')), + user('steer', inTurn('t1')), + assistant('a2', inTurn('t1')), + status('note') + ] + expect(keys(items)).toEqual(['u1', 'u1', 'u1', 'u1', undefined]) + }) + + it('keys a turn no message opened on its own record', () => { + const items = [ + user('u1'), + turn('t1', 'u1'), + assistant('a1', inTurn('t1')), + // A provider-resumed turn: its record names no present user entry. + turn('wake', 'codex:thread:wake:0'), + assistant('a2', inTurn('wake')) + ] + expect(keys(items)).toEqual(['u1', 'u1', 'wake']) + }) + + it('anchors a command turn on its /compact entry, and leaves the turn before it alone', () => { + const compactKey = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const items = [ + user('u1'), + turn('t1', 'u1'), + assistant('a1', inTurn('t1')), + item(compactKey, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + turn(commandTurn, compactKey), + status('compacted', inTurn(commandTurn)) + ] + expect(keys(items)).toEqual(['u1', 'u1', compactKey, compactKey]) + }) + + it('resolves a turn record naming the provider item a submission adopted', () => { + const sent = agentJournalSubmissionKey('send-1') + const items = [user(sent), turn('t1', 'codex:thread:t1:0'), assistant('a1', inTurn('t1'))] + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: 'send-1', + fence: 1, + payloadFingerprint: 'f', + dispatchState: 'accepted', + providerItemId: 'codex:thread:t1:0', + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + ] + expect(keys(items, submissions)).toEqual([sent, sent]) + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(sent) + }) + + it('places a conversation row, and a row naming a turn the journal no longer holds, in none', () => { + const items = [ + user('u1'), + turn('t1', 'u1'), + status('restart-note'), + assistant('orphan', inTurn('gone')) + ] + expect(keys(items)).toEqual(['u1', undefined, undefined]) + }) + + it('groups by position for a host that states no scope, and with no journal', () => { + const items = [user('u1', null), assistant('a1', null), user('u2', null), status('s', null)] + expect(keys(items)).toEqual(['u1', 'u1', 'u2', 'u2']) + expect(nativeChatTurnMembership(rows(items)).turnKeys).toEqual(['u1', 'u1', 'u2', 'u2']) + }) +}) + +describe('the live turn', () => { + it('is a running turn the provider opened on its own, not the settled user turn before it', () => { + const settled = [user('u1'), turn('t1', 'u1'), assistant('a1', inTurn('t1'))] + const wake = turn('wake', 'codex:thread:wake:0', THREAD, 'running') + // Just opened: nothing drawn for it yet, and still the live turn. + expect(liveTurnKey([...settled, wake])).toBe('wake') + expect(liveTurnKey([...settled, wake, assistant('a2', inTurn('wake'))])).toBe('wake') + }) + + it('is the user row that opened the running turn, including after a steer into it', () => { + const items = [ + user('u1'), + turn('t1', 'u1', THREAD, 'running'), + assistant('a1', inTurn('t1')), + user('steer', inTurn('t1')) + ] + expect(liveTurnKey(items)).toBe('u1') + }) + + it('is the newest user row while its send has not opened a turn', () => { + const items = [user('u1'), turn('t1', 'u1'), assistant('a1', inTurn('t1')), user('u2')] + expect(liveTurnKey(items)).toBe('u2') + }) + + it('is the newest user row for a host that states no scope, and with no journal', () => { + const items = [ + user('u1', null), + turn('wake', undefined, null, 'running'), + assistant('a1', null), + user('u2', null), + status('s', null) + ] + expect(liveTurnKey(items)).toBe('u2') + expect(nativeChatTurnMembership(rows(items)).liveTurnKey).toBe('u2') + expect(nativeChatTurnMembership([]).liveTurnKey).toBeUndefined() + }) +}) + +// A message sent while A runs, which the provider queued behind A: its turn opens only after A's +// remaining rows, which the journal wrote after it. +describe('a message the provider answered after the running turn', () => { + const journal = (statesScope: boolean) => { + const scope = (turnItemId?: string) => + statesScope ? (turnItemId ? inTurn(turnItemId) : THREAD) : null + return [ + user('A', scope()), + turn('tA', 'A', scope()), + assistant('a1', scope('tA')), + user('B', scope('tA')), + assistant('a-tool', scope('tA')), + assistant('FIRST DONE', scope('tA')), + turn('tB', 'B', scope(), 'running'), + assistant('b1', scope('tB')) + ] + } + + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])("draws A's remaining rows in A's turn, then B, on a host that %s", (_host, statesScope) => { + const items = journal(statesScope) + const transcript = rows(items) + const membership = nativeChatTurnMembership(transcript, { items, submissions: [] }) + const drawn = nativeChatRowsInDrawOrder( + transcript.map((row, index) => [row.id, membership.turnKeys[index]]), + membership.drawOrder + ) + expect(drawn).toEqual([ + ['A', 'A'], + ['a1', 'A'], + ['a-tool', 'A'], + ['FIRST DONE', 'A'], + ['B', 'B'], + ['b1', 'B'] + ]) + expect(membership.liveTurnKey).toBe('B') + }) +}) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts new file mode 100644 index 00000000000..67cd3172e6c --- /dev/null +++ b/src/shared/native-chat-turn-membership.ts @@ -0,0 +1,228 @@ +// Which turn each transcript row belongs to, and which turn is live, read from the turn record +// rather than from position. +// +// A turn's anchor is the user entry that opened it — its record's `userItemId`, directly or +// through the provider item a submission adopted — or, for a turn no entry opened (one the +// provider resumed on its own), the turn record itself. A row belongs to the turn its stated scope +// names; a row scoped to the conversation, or to a turn the journal no longer holds, belongs to +// none. A host that states no scope is read by journal order, and by position where that names +// nothing. +// Shared by desktop and mobile, whose keys must agree. + +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import { + nativeChatJournalOrderTurnKeys, + nativeChatRowTurnKeys, + nativeChatTurnDrawOrder +} from './native-chat-turn-grouping' +import type { NativeChatRole } from './native-chat-types' +import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' +import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' + +/** Whether the host writing this journal states each row's turn. Only a host that runs `/compact` + * as a turn of the send path does, so this is also how a client tells that host from an older one. */ +export function hostStatesTurnScopes(items: readonly AgentJournalRenderItem[]): boolean { + return items.some((item) => item.turnScope !== undefined) +} + +export type NativeChatTurnJournal = { + items: readonly AgentJournalRenderItem[] + submissions: readonly AgentJournalSubmission[] +} + +/** + * Each root turn record's anchor, by the record's item id. `userItemId` resolves to a present user + * entry, directly or through a submission's adopted provider item; a record that names no entry + * falls back to the nearest user entry before it, which only older hosts write. A key nothing + * resolves yet is the send still in flight ahead of the record — Codex reports a turn open before + * it echoes the send — and with none, the turn anchors on its own record. + */ +export function structuredAgentTurnAnchors( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +): ReadonlyMap { + const userItemIds = new Set( + items.flatMap((item) => + item.body.kind === 'message' && item.body.role === 'user' ? [item.itemId] : [] + ) + ) + const aliases = new Map() + // Codex folds a send issued mid-turn into the running turn under the SAME provider key, so the + // earliest submission that names a key is the prompt that opened the turn. + for (const submission of submissions) { + if (submission.providerItemId && !aliases.has(submission.providerItemId)) { + aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) + } + } + const inFlight = new Set( + submissions + .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) + const anchors = new Map() + let precedingUserItemId: string | null = null + let inFlightSinceLastTurn: string | null = null + for (const item of items) { + if (userItemIds.has(item.itemId)) { + precedingUserItemId = item.itemId + if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { + inFlightSinceLastTurn = item.itemId + } + continue + } + const turn = readAgentJournalTurn(item.body) + if (!turn || !isRootAgentJournalItem(item)) { + continue + } + anchors.set( + item.itemId, + anchorOf(item.itemId, turn, userItemIds, aliases, precedingUserItemId, inFlightSinceLastTurn) + ) + inFlightSinceLastTurn = null + } + return anchors +} + +function anchorOf( + turnItemId: string, + turn: AgentJournalTurnLifecycle, + userItemIds: ReadonlySet, + aliases: ReadonlyMap, + precedingUserItemId: string | null, + inFlightUserItemId: string | null +): string { + const key = turn.userItemId + if (key === undefined) { + return precedingUserItemId ?? turnItemId + } + if (userItemIds.has(key)) { + return key + } + const aliased = aliases.get(key) + return aliased !== undefined && userItemIds.has(aliased) + ? aliased + : (inFlightUserItemId ?? turnItemId) +} + +export type NativeChatTurnMembership = { + /** Each row's turn by index: the anchor of the turn it belongs to, or undefined for none. */ + turnKeys: (string | undefined)[] + /** The turn live now, whose bar carries the running clock and whose rows stay live: the running + * root turn's anchor, else the newest user row's turn (a send whose turn has not opened yet). A + * turn the provider opened on its own is live without one. */ + liveTurnKey: string | undefined + /** Row indexes in the order the transcript draws them (`nativeChatTurnDrawOrder`), or null when + * that is the order given. */ + drawOrder: readonly number[] | null +} + +/** + * Places each row in its turn. A user entry that anchors a turn, or is scoped to none, keys + * itself; one delivered into a running turn (a steer) takes that turn's key. A host that states no + * scope is read by journal order instead (`nativeChatJournalOrderTurnKeys`). + */ +export function nativeChatTurnMembership( + messages: readonly { id: string; role: NativeChatRole }[], + journal?: NativeChatTurnJournal | null +): NativeChatTurnMembership { + if (!journal) { + const turnKeys = nativeChatRowTurnKeys(messages) + return { turnKeys, liveTurnKey: newestUserTurnKey(messages, turnKeys), drawOrder: null } + } + const anchors = structuredAgentTurnAnchors(journal.items, journal.submissions) + const running = liveStructuredAgentSessionTurnScope(journal.items) + if (!hostStatesTurnScopes(journal.items)) { + const recordKeys = namedRecordKeys(journal.items, anchors) + const turnKeys = nativeChatRowTurnKeys( + messages, + nativeChatJournalOrderTurnKeys(journal.items, recordKeys) + ) + const runningNamed = running.kind === 'turn' ? recordKeys.get(running.turnItemId) : null + return { + turnKeys, + liveTurnKey: runningNamed ?? newestUserTurnKey(messages, turnKeys), + drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoringUserItems(recordKeys)) + } + } + const runningKey = running.kind === 'turn' ? anchors.get(running.turnItemId) : undefined + const anchoring = new Set(anchors.values()) + const scopes = new Map(journal.items.map((item) => [item.itemId, item.turnScope])) + const turnKeys = messages.map((message) => { + const scope = scopes.get(message.id) + const turnKey = + scope?.kind === 'turn' && scope.turnItemId ? anchors.get(scope.turnItemId) : undefined + if (message.role !== 'user') { + return turnKey + } + return anchoring.has(message.id) ? message.id : (turnKey ?? message.id) + }) + return { + turnKeys, + liveTurnKey: runningKey ?? newestUserTurnKey(messages, turnKeys), + drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoring) + } +} + +/** Each root record's anchor, or null for a record that names no opener (only older hosts write + * one): such a record owns no rows, which keep their position. */ +function namedRecordKeys( + items: readonly AgentJournalRenderItem[], + anchors: ReadonlyMap +): ReadonlyMap { + const keys = new Map() + for (const item of items) { + const anchor = anchors.get(item.itemId) + if (anchor !== undefined) { + keys.set( + item.itemId, + readAgentJournalTurn(item.body)?.userItemId === undefined ? null : anchor + ) + } + } + return keys +} + +function anchoringUserItems(recordKeys: ReadonlyMap): ReadonlySet { + return new Set([...recordKeys.values()].filter((key) => key !== null)) +} + +/** + * 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. + */ +export function nativeChatMessagesWaitingBehindLiveTurn( + messages: readonly { id: string; queued?: true }[], + items: readonly AgentJournalRenderItem[] | null | undefined +): ReadonlySet { + const queued = messages.filter((message) => message.queued === true) + return new Set( + queued.length > 0 && items && commandTurnRunning(items) + ? queued.map((message) => message.id) + : [] + ) +} + +function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { + const running = liveStructuredAgentSessionTurnScope(items) + const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body + return ( + running.kind === 'turn' && + isStructuredAgentSessionCommandTurn(readAgentJournalTurn(bodyOf(running.turnItemId)), bodyOf) + ) +} + +function newestUserTurnKey( + messages: readonly { role: NativeChatRole }[], + turnKeys: readonly (string | undefined)[] +): string | undefined { + const index = messages.findLastIndex((message) => message.role === 'user') + return index === -1 ? undefined : turnKeys[index] +} diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index acbab30aabf..3773a9bf423 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -4,8 +4,6 @@ // (used directly — mobile ships English only) so the two surfaces never drift. // Everything here is pure; each platform owns its own clock. -import type { NativeChatMessage } from './native-chat-types' - export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', @@ -106,20 +104,6 @@ export type NativeChatTurnTimingByTurn = Readonly message.role === 'user')?.id ?? - NATIVE_CHAT_UNANCHORED_TURN_KEY - ) -} - /** The turn-timing state machine, lifted out of the React hook so desktop and * mobile stamp start/stop identically. Returns the same reference when nothing * changed so callers can bail out of a state update. */ diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 392ebb5b8f6..b090666511c 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -208,6 +208,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { turnId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode + /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ + queued?: 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-command-entry.ts b/src/shared/structured-agent-session-command-entry.ts new file mode 100644 index 00000000000..fd76b45d43a --- /dev/null +++ b/src/shared/structured-agent-session-command-entry.ts @@ -0,0 +1,54 @@ +// A conversation command the user sent, such as `/compact`, and the turn it ran as. Neither is a +// request of the user's to the agent: the sidebar's prompt, preview and verdict, the completion +// feed and restart resume all read past them to the last real request. + +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnLifecycle +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' + +export function isStructuredAgentSessionCommandEntry( + body: AgentJournalItemBody | null | undefined +): boolean { + return body?.kind === 'message' && body.command !== undefined +} + +/** A turn a command entry opened: the entry its record names carries the command. */ +export function isStructuredAgentSessionCommandTurn( + turn: Pick | null | undefined, + bodyOf: (itemId: string) => AgentJournalItemBody | null | undefined +): boolean { + return ( + turn?.userItemId !== undefined && isStructuredAgentSessionCommandEntry(bodyOf(turn.userItemId)) + ) +} + +/** The item ids of every command turn record in `items`, for readers that skip their rows. */ +export function structuredAgentSessionCommandTurnItemIds( + items: readonly AgentJournalRenderItem[] +): ReadonlySet { + const bodies = new Map(items.map((item) => [item.itemId, item.body])) + const ids = new Set() + for (const item of items) { + if ( + isStructuredAgentSessionCommandTurn(readAgentJournalTurn(item.body), (id) => bodies.get(id)) + ) { + ids.add(item.itemId) + } + } + return ids +} + +/** A command entry, or a row its turn produced. */ +export function isStructuredAgentSessionCommandRow( + item: Pick, + commandTurnItemIds: ReadonlySet +): boolean { + const scope = item.turnScope + return ( + isStructuredAgentSessionCommandEntry(item.body) || + (scope?.kind === 'turn' && commandTurnItemIds.has(scope.turnItemId)) + ) +} diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index be16c8a3fbd..95f522a765d 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -56,7 +56,9 @@ describe('classifyDispatchRejection', () => { // Orca stopped a start that hung: the agent failed, and the message never reached it. ['hostStopped', 'undelivered', 'failure'], ['writeFailed', 'transport', 'failure'], - ['queueFull', 'transport', 'failure'] + ['queueFull', 'transport', 'failure'], + // The chat's state refused a queued command when its turn to run came. + ['commandRefused', 'content', 'failure'] ] as const)('reads the written %s rejection as %s, verdict %s', (kind, category, verdict) => { const written = agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection', diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 68e40a53039..57b8e745ca5 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -95,6 +95,7 @@ const KIND_CATEGORY = { attachmentInvalid: 'content', attachmentUnreadable: 'content', emptyMessage: 'content', + commandRefused: 'content', queueFull: 'transport', writeFailed: 'transport', hostFault: 'transport' @@ -119,6 +120,7 @@ const KIND_VERDICT = { attachmentInvalid: 'failure', attachmentUnreadable: 'failure', emptyMessage: 'failure', + commandRefused: 'failure', queueFull: 'failure', writeFailed: 'failure', hostFault: 'failure', diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 7cdb1b2d36a..6bb7f117bf8 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -24,13 +24,21 @@ import { projectStructuredAgentSessionStatusSummary } from './structured-agent-s const START_FAILURE = 'Claude is not signed in.' -function userEntry(clientMessageId: string, sequence: number): AgentJournalRenderItem { +/** `intoTurn` names the turn the message's handover delivered it into — a steer. */ +function userEntry( + clientMessageId: string, + sequence: number, + intoTurn?: string +): AgentJournalRenderItem { return { itemId: agentJournalSubmissionKey(clientMessageId), revision: 0, sequence, observedAt: sequence, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + turnScope: intoTurn + ? { kind: 'turn', turnItemId: `codex:turn:${intoTurn}` } + : { kind: 'thread' } } } @@ -183,7 +191,7 @@ const ROWS: Row[] = [ items: [ userEntry('m1', 1), turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 50 }), - userEntry('steer', 3) + userEntry('steer', 3, 't1') ], submissions: [ sent('m1', { dispatchState: 'accepted' }), diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 9679c0c12bd..c6742e8408b 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -1,9 +1,11 @@ // The session's latest request and what became of it: the verdict a sidebar row reports once -// the session is idle, and whether there is a request to list at all. +// the session is idle, whether there is a request to list at all, and the prompt and answer its row +// quotes. // // A request is either a turn, whose record carries the provider's verdict, or a send that never -// became one because the agent or its start refused it. A send inside a running turn (a steer) -// is not a request of its own: the turn it joined answers for it. +// became one because the agent or its start refused it. A send its handover placed inside a +// running turn (a steer) is not a request of its own: the turn it joined answers for it. Nor is a +// conversation command. import type { AgentJournalRenderItem, @@ -16,6 +18,12 @@ import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' +import { + isStructuredAgentSessionCommandEntry, + isStructuredAgentSessionCommandRow, + structuredAgentSessionCommandTurnItemIds +} from './structured-agent-session-command-entry' +import type { NativeChatBlock } from './native-chat-types' export type StructuredAgentSessionLatestRequest = { kind: 'turn' | 'refused-send' @@ -36,9 +44,16 @@ export function latestStructuredAgentSessionRequest( submissions: readonly AgentJournalSubmission[] ): StructuredAgentSessionLatestRequest | null { const rejected = rejectedSubmissionsByItem(submissions) + const commandTurns = structuredAgentSessionCommandTurnItemIds(items) for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] - if (!item || !isRootAgentJournalItem(item)) { + // A conversation command is not a request: the verdict stays the last real request's. + if ( + !item || + !isRootAgentJournalItem(item) || + commandTurns.has(item.itemId) || + isStructuredAgentSessionCommandEntry(item.body) + ) { continue } const turn = readAgentJournalTurn(item.body) @@ -56,7 +71,8 @@ export function latestStructuredAgentSessionRequest( if ( submission && classifyDispatchRejection(submission).verdict === 'failure' && - !deliveredIntoRunningTurn(items, index, submission) + // Handed into a running turn (a steer): that turn answers for it. + item.turnScope?.kind !== 'turn' ) { return { kind: 'refused-send', @@ -111,31 +127,6 @@ function rejectedSubmissionsByItem( return rejected } -/** A send handed over while the turn before it was still running joined that turn. Read off the - * host clock, because a turn record's end revises it in place and keeps no sequence of its own. - * A send never handed over reached no turn. */ -function deliveredIntoRunningTurn( - items: readonly AgentJournalRenderItem[], - index: number, - submission: AgentJournalSubmission -): boolean { - // Older hosts dispatched a send as they recorded it. - const deliveredAt = - submission.handedOverAt ?? (submission.handoverRecorded ? undefined : submission.submittedAt) - if (deliveredAt === undefined) { - return false - } - for (let previous = index - 1; previous >= 0; previous -= 1) { - const item = items[previous] - const turn = readAgentJournalTurn(item?.body) - if (item && turn) { - const endedAt = turnEndedAt(item, turn) - return turn.state === 'running' || (endedAt !== undefined && endedAt > deliveredAt) - } - } - return false -} - /** A turn recovery settled ended when that settle was written: when the user learns it stopped. */ function turnEndedAt( item: AgentJournalRenderItem, @@ -143,3 +134,63 @@ function turnEndedAt( ): number | undefined { return item.recoveredAt ?? turn.completedAt } + +function messageProse(blocks: readonly NativeChatBlock[]): string { + return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') +} + +/** The newest prompt the session's own user turn carries, as the sidebar quotes + * it. Scoped to root rows for the same reason the assistant line is: a provider + * that journals a subagent's own prompt would otherwise requote it as the + * session's. */ +export function latestStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): string { + const body = latestStructuredAgentSessionUserItem(items)?.body + return body?.kind === 'message' ? messageProse(body.blocks) : '' +} + +export function latestStructuredAgentSessionUserItem( + items: readonly AgentJournalRenderItem[] +): AgentJournalRenderItem | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if ( + item?.body.kind === 'message' && + item.body.role === 'user' && + isRootAgentJournalItem(item) && + !isStructuredAgentSessionCommandEntry(item.body) + ) { + return item + } + } + return null +} + +/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a + * subagent's, whose rows share this journal and are usually the newer ones while + * a child runs. Tool-only assistant items are skipped; the user boundary clears + * prose from the preceding turn. */ +export function latestStructuredAgentSessionAssistantMessage( + items: readonly AgentJournalRenderItem[] +): string { + const commandTurns = structuredAgentSessionCommandTurnItemIds(items) + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + const body = item?.body + // A command and what its turn produced are not the conversation's latest answer. + if (!isRootAgentJournalItem(item) || isStructuredAgentSessionCommandRow(item, commandTurns)) { + continue + } + if (body?.kind === 'message' && body.role === 'user') { + return '' + } + if (body?.kind === 'message' && body.role === 'assistant') { + const prose = messageProse(body.blocks) + if (prose.trim()) { + return prose + } + } + } + return '' +} diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 2f5a0747667..d4cba70f616 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -17,9 +17,11 @@ // (none). So a child-linked row can never be what terminates one of these // scans. Re-check that before giving any of those sites a producer. -import type { - AgentJournalRenderItem, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnLifecycle, + type AgentJournalTurnScope } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn } from './agent-session-turn-record' @@ -64,6 +66,22 @@ export function newestStructuredAgentSessionTurnBySequence( return newest } +/** The scope a row written now joins: the running turn, or the conversation when none runs. + * By sequence, for items held unordered. */ +export function liveStructuredAgentSessionTurnScope( + items: Iterable +): AgentJournalTurnScope { + let newest: AgentJournalRenderItem | null = null + for (const item of items) { + if ((newest === null || item.sequence >= newest.sequence) && readAgentJournalTurn(item.body)) { + newest = item + } + } + return newest && readAgentJournalTurn(newest.body)?.state === 'running' + ? { kind: 'turn', turnItemId: newest.itemId } + : AGENT_JOURNAL_THREAD_SCOPE +} + /** Whether that newest turn is still running, which is all most callers want. */ export function activeStructuredAgentSessionTurnIdBySequence( items: Iterable diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 334cde7cfe8..da8017a2d50 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,6 +1,7 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition } from './agent-session-journal-position' +import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import type { NativeChatMessage } from './native-chat-types' import { reconcileStructuredAgentSessionOutbox, @@ -31,8 +32,25 @@ export function projectStructuredAgentSessionMessages( } } const journalled = new Set(visibleItems.map((item) => item.itemId)) + // Not delivered yet, so nothing the agent does meanwhile — a command it waits behind — comes + // after it. Its handover places it in the conversation. + const queued = new Set( + submissions + .filter(isQueuedAgentJournalSubmission) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) + const delivered: NativeChatMessage[] = [] + const held: NativeChatMessage[] = [] + for (const message of projectItems(visibleItems)) { + if (queued.has(message.id)) { + held.push({ ...message, queued: true }) + } else { + delivered.push(message) + } + } return [ - ...projectItems(visibleItems), + ...delivered, + ...held, ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) .map((entry): NativeChatMessage => { diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 691872c3587..8570b0c3374 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -8,13 +8,13 @@ import { hasUnansweredStructuredAgentSessionDispatch, projectStructuredItemToNativeChat, projectStructuredItemsToNativeChat, - latestStructuredAgentSessionAssistantMessage, projectStructuredAgentSessionStatus, projectStructuredAgentSessionStatusState, projectStructuredAgentSessionStatusSummary, structuredAgentSessionPaneKey } from './structured-agent-session-projection' import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn' +import { latestStructuredAgentSessionAssistantMessage } from './structured-agent-session-latest-request' function item( itemId: string, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index f35af81b932..0adac0f6d2e 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -10,7 +10,7 @@ import { type AgentJournalSubmission, type AgentJournalTurnOutcome } from './agent-session-journal-types' -import { agentJournalLinkageFields, isRootAgentJournalItem } from './agent-session-journal-producer' +import { agentJournalLinkageFields } from './agent-session-journal-producer' import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' import { agentJournalItemRowOrigin } from './agent-session-journal-position' import { @@ -21,6 +21,8 @@ import { describeToolInput } from './native-chat-tool-summary' import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn' import { hasStructuredAgentSessionRequest, + latestStructuredAgentSessionAssistantMessage, + latestStructuredAgentSessionPrompt, latestStructuredAgentSessionRequest, type StructuredAgentSessionLatestRequest } from './structured-agent-session-latest-request' @@ -196,63 +198,6 @@ export function projectStructuredAgentSessionStatus( return owesStructuredAgentSessionWork(items, submissions, currentFence) ? 'working' : 'idle' } -function messageProse(blocks: readonly NativeChatBlock[]): string { - return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') -} - -/** The newest prompt the session's own user turn carries, as the sidebar quotes - * it. Scoped to root rows for the same reason the assistant line is: a provider - * that journals a subagent's own prompt would otherwise requote it as the - * session's. */ -export function latestStructuredAgentSessionPrompt( - items: readonly AgentJournalRenderItem[] -): string { - const body = latestStructuredAgentSessionUserItem(items)?.body - return body?.kind === 'message' ? messageProse(body.blocks) : '' -} - -export function latestStructuredAgentSessionUserItem( - items: readonly AgentJournalRenderItem[] -): AgentJournalRenderItem | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - if ( - item?.body.kind === 'message' && - item.body.role === 'user' && - isRootAgentJournalItem(item) - ) { - return item - } - } - return null -} - -/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a - * subagent's, whose rows share this journal and are usually the newer ones while - * a child runs. Tool-only assistant items are skipped; the user boundary clears - * prose from the preceding turn. */ -export function latestStructuredAgentSessionAssistantMessage( - items: readonly AgentJournalRenderItem[] -): string { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - const body = item?.body - if (!isRootAgentJournalItem(item)) { - continue - } - if (body?.kind === 'message' && body.role === 'user') { - return '' - } - if (body?.kind === 'message' && body.role === 'assistant') { - const prose = messageProse(body.blocks) - if (prose.trim()) { - return prose - } - } - } - return '' -} - /** The activity fields a sidebar row shows beside the prompt, named as the agent-status * entry names them so the client can hand them straight to a row. */ export type StructuredAgentSessionStatusProjection = { diff --git a/src/shared/structured-agent-session-turn-bars.test.ts b/src/shared/structured-agent-session-turn-bars.test.ts index 219939d00e6..30ca3af6a38 100644 --- a/src/shared/structured-agent-session-turn-bars.test.ts +++ b/src/shared/structured-agent-session-turn-bars.test.ts @@ -5,9 +5,9 @@ import type { AgentJournalTurnLifecycle } from './agent-session-journal-types' import type { NativeChatMessage } from './native-chat-types' +import { nativeChatTurnMembership } from './native-chat-turn-membership' import { reduceNativeChatTurnTiming, - selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatTurnTimingByTurn } from './native-chat-turn-status' @@ -150,6 +150,42 @@ function message(id: string, role: NativeChatMessage['role'] = 'user'): NativeCh return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' } } +/** The row whose bar carries the running clock, as a surface places it. These journals are from a + * host that states no row's turn, so the bar follows the running record's named opener. */ +function barOf( + transcript: readonly NativeChatMessage[], + snapshot: { + items: readonly AgentJournalRenderItem[] + submissions: readonly AgentJournalSubmission[] + } +): string | undefined { + return nativeChatTurnMembership(transcript, snapshot).liveTurnKey +} + +/** The same journal from a host that states each row's turn: a user row belongs to no turn, a + * steer to the turn `steers` names, and any other row to the root turn record before it. */ +function scoped( + items: readonly AgentJournalRenderItem[], + steers: ReadonlyMap = new Map() +): AgentJournalRenderItem[] { + let open: string | null = null + return items.map((item) => { + const steered = steers.get(item.itemId) + if (item.body.kind === 'turn') { + open = item.itemId + } + const turnItemId = + steered ?? + (item.body.kind === 'turn' || (item.body.kind === 'message' && item.body.role === 'user') + ? null + : open) + return { + ...item, + turnScope: turnItemId ? { kind: 'turn' as const, turnItemId } : { kind: 'thread' as const } + } + }) +} + describe('a send queued behind a running Claude turn', () => { it('leaves the live bar with the prompt that opened the running turn', () => { const bars = selectStructuredAgentTurnBars( @@ -157,9 +193,8 @@ describe('a send queued behind a running Claude turn', () => { whileQueued.submissions, whileQueued.turnId ) - expect(bars.activeTurnOpenedBy).toBe('orca:A') const transcript = [message('orca:prior'), message('orca:A'), message('orca:B')] - expect(selectNativeChatActiveTurnKey(transcript, bars.activeTurnOpenedBy)).toBe('orca:A') + expect(barOf(transcript, whileQueued)).toBe('orca:A') // A was sent into an idle session: it counts from its own send, as before. expect(bars.runningTiming).toEqual({ state: 'running', @@ -172,7 +207,8 @@ describe('a send queued behind a running Claude turn', () => { it("moves the bar to B when its own turn opens, counting from A's end", () => { const bars = selectStructuredAgentTurnBars(whileB.items, whileB.submissions, whileB.turnId) - expect(bars.activeTurnOpenedBy).toBe('orca:B') + const transcript = [message('orca:prior'), message('orca:A'), message('orca:B')] + expect(barOf(transcript, whileB)).toBe('orca:B') expect(bars.settledTurns.get('orca:A')).toEqual({ startedAt: A.started, workedSeconds: 17 }) expect(bars.runningTiming && structuredAgentTurnOrigin(bars.runningTiming)).toBe(A.ended) // A client attaching 3 s into turn 2 counts 3 s, not the 13 s B waited behind turn 1. @@ -195,7 +231,6 @@ describe('a send queued behind a running Claude turn', () => { expect(Math.floor((B.ended - liveOriginB) / 1000)).toBe(51) expect(A.ended - A.sent + (B.ended - liveOriginB)).toBe(B.ended - A.sent) expect(selectStructuredAgentTurnBars(settled.items, settled.submissions, null)).toMatchObject({ - activeTurnOpenedBy: null, runningTiming: null }) }) @@ -210,7 +245,7 @@ describe('a send queued behind a running Claude turn', () => { now: number ) => { const bars = selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, turnId) - const activeTurnKey = selectNativeChatActiveTurnKey(transcript, bars.activeTurnOpenedBy) + const activeTurnKey = barOf(transcript, snapshot)! const workingStartedAt = structuredAgentTurnLocalStartedAt(bars.runningTiming!, now, now) timing = reduceNativeChatTurnTiming(timing, { activeTurnKey, @@ -281,7 +316,7 @@ describe('a send Codex folds into the running turn', () => { it('keeps exactly one bar, under the opening prompt, live and settled', () => { const live = selectStructuredAgentTurnBars(items, submissions, 't1') - expect(selectNativeChatActiveTurnKey(transcript, live.activeTurnOpenedBy)).toBe('orca:first') + expect(barOf(transcript, { items, submissions })).toBe('orca:first') expect(live.runningTiming && structuredAgentTurnOrigin(live.runningTiming)).toBe(1_000) let timing: NativeChatTurnTimingByTurn = {} @@ -296,7 +331,7 @@ describe('a send Codex folds into the running turn', () => { const after = selectStructuredAgentTurnBars(ended, submissions, null) // The turn is over: nothing names an owner, so the key falls back to the newest prompt, // which never ran a turn of its own and so has nothing to settle. - const activeTurnKey = selectNativeChatActiveTurnKey(transcript, after.activeTurnOpenedBy) + const activeTurnKey = barOf(transcript, { items: ended, submissions })! expect(activeTurnKey).toBe('orca:second') timing = reduceNativeChatTurnTiming(timing, { activeTurnKey, @@ -324,9 +359,7 @@ describe('turns whose host names no opener', () => { turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }), user(3, 'u2', 2_000) ] - const bars = selectStructuredAgentTurnBars(items, [], 't1') - expect(bars.activeTurnOpenedBy).toBeNull() - expect(selectNativeChatActiveTurnKey([message('orca:u1'), message('orca:u2')], null)).toBe( + expect(barOf([message('orca:u1'), message('orca:u2')], { items, submissions: [] })).toBe( 'orca:u2' ) // Settled timing keeps its journal-order attribution. @@ -344,7 +377,9 @@ describe('turns whose host names no opener', () => { userItemId: self }) ] - expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBe(self) + expect(barOf([message('orca:u1')], { items, submissions: [] })).toBe(self) + // A host that states each row's turn anchors it the same way. + expect(barOf([message('orca:u1')], { items: scoped(items), submissions: [] })).toBe(self) }) it('keeps the running bar on the send Codex opened a turn for before it echoes it', () => { @@ -364,21 +399,22 @@ describe('turns whose host names no opener', () => { user(4, 'second', 3_000), turn(5, 3_100, { turnId: 't2', state: 'running', startedAt: 3_100, userItemId: key }) ] + const transcript = [message('orca:first'), message('orca:second')] const submissions = [accepted('first', 'claude:first'), accepted('second', null)] - const bars = selectStructuredAgentTurnBars(items, submissions, 't2') - expect(bars.activeTurnOpenedBy).toBe('orca:second') - expect(bars.turnKeysByItemId.get('orca:second')).toBe('orca:second') - // A turn with no send in flight still anchors to its own record. - const selfOpened = selectStructuredAgentTurnBars( - items, - [accepted('first', 'claude:first'), accepted('second', 'claude:second')], - 't2' - ) - expect(selfOpened.activeTurnOpenedBy).toBe('legacy:claude:55368cfb:turn-lifecycle%3At2') + for (const journal of [items, scoped(items)]) { + const membership = nativeChatTurnMembership(transcript, { items: journal, submissions }) + expect(membership.liveTurnKey).toBe('orca:second') + expect(membership.turnKeys[1]).toBe('orca:second') + // A turn with no send in flight still anchors to its own record. + const selfOpened = [accepted('first', 'claude:first'), accepted('second', 'claude:second')] + expect(barOf(transcript, { items: journal, submissions: selfOpened })).toBe( + 'legacy:claude:55368cfb:turn-lifecycle%3At2' + ) + } }) it('names nothing for the unanchored transcript', () => { - expect(selectNativeChatActiveTurnKey([message('a', 'assistant')], null)).toBe('__unanchored__') + expect(nativeChatTurnMembership([message('a', 'assistant')], null).liveTurnKey).toBeUndefined() }) }) @@ -431,45 +467,57 @@ describe('the previous turn has no recorded end', () => { }) describe('which turn owns each transcript row', () => { + /** Each drawn row's turn, by id, as a surface places it. */ const keysOf = (snapshot: { - turnId: string | null items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] - }) => - selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, snapshot.turnId) - .turnKeysByItemId + }): ReadonlyMap => { + const transcript = snapshot.items.flatMap((item) => + item.body.kind === 'turn' + ? [] + : [ + message( + item.itemId, + item.body.kind === 'message' && item.body.role === 'user' ? 'user' : 'tool' + ) + ] + ) + const { turnKeys } = nativeChatTurnMembership(transcript, snapshot) + return new Map(transcript.map((row, index) => [row.id, turnKeys[index]])) + } + const turn1Record = `legacy:claude:55368cfb:turn-lifecycle%3A${turn1.turnId}` it('keeps the rows Claude produces after a mid-turn send with the turn that ran them', () => { // The #23621 shape: B lands mid-turn and three tool calls follow, one turn. - const midTurn = { - turnId: turn1.turnId, - items: [ - user(54, 'A', A.sent), - turn(57, T + 77_224, { - ...turn1, - state: 'running', - startedAt: A.started, - requestedAt: A.sent - }), - tool(58, T + 77_300), - user(60, 'B', B.sent), - tool(61, T + 80_100), - tool(62, T + 80_200), - tool(63, T + 80_300) - ], - submissions: [accepted('A', turn1.userItemId), accepted('B', null)] - } - const keys = keysOf(midTurn) - expect(keys.get('orca:A')).toBe('orca:A') - expect(keys.get('orca:B')).toBe('orca:A') - for (const sequence of [58, 61, 62, 63]) { - expect(keys.get(`orca:claude-tool%3A${sequence}`)).toBe('orca:A') + const items = [ + user(54, 'A', A.sent), + turn(57, T + 77_224, { + ...turn1, + state: 'running', + startedAt: A.started, + requestedAt: A.sent + }), + tool(58, T + 77_300), + user(60, 'B', B.sent), + tool(61, T + 80_100), + tool(62, T + 80_200), + tool(63, T + 80_300) + ] + const submissions = [accepted('A', turn1.userItemId), accepted('B', null)] + const steer = new Map([['orca:B', turn1Record]]) + for (const journal of [items, scoped(items, steer)]) { + const keys = keysOf({ items: journal, submissions }) + expect(keys.get('orca:A')).toBe('orca:A') + expect(keys.get('orca:B')).toBe('orca:A') + for (const sequence of [58, 61, 62, 63]) { + expect(keys.get(`orca:claude-tool%3A${sequence}`)).toBe('orca:A') + } } }) - it('leaves a fresh tail send unowned until the turn proves it continued past it', () => { + it('leaves a fresh tail send in its own group until the turn proves it continued past it', () => { // B is the newest row: nothing after it says the running turn absorbed it. - expect(keysOf(whileQueued).has('orca:B')).toBe(false) + expect(keysOf(whileQueued).get('orca:B')).toBe('orca:B') // B's own turn opened: B is an opener and keys itself. expect(keysOf(whileB).get('orca:B')).toBe('orca:B') expect(keysOf(whileB).get(`orca:claude-tool%3A58`)).toBe('orca:A') @@ -477,10 +525,11 @@ describe('which turn owns each transcript row', () => { it('folds a Codex-coalesced send into the turn its provider key names', () => { const key = 'codex:thread:t1:0' + const record = 'legacy:codex:s:turn-lifecycle%3At1' const items: AgentJournalRenderItem[] = [ user(1, 'first', 1_000), { - itemId: 'legacy:codex:s:turn-lifecycle%3At1', + itemId: record, revision: 1, sequence: 2, observedAt: 1_200, @@ -497,10 +546,12 @@ describe('which turn owns each transcript row', () => { tool(4, 5_000) ] const submissions = [accepted('first', key), accepted('second', key)] - const keys = keysOf({ turnId: 't1', items, submissions }) - expect(keys.get('orca:first')).toBe('orca:first') - expect(keys.get('orca:second')).toBe('orca:first') - expect(keys.get('orca:claude-tool%3A4')).toBe('orca:first') + for (const journal of [items, scoped(items, new Map([['orca:second', record]]))]) { + const keys = keysOf({ items: journal, submissions }) + expect(keys.get('orca:first')).toBe('orca:first') + expect(keys.get('orca:second')).toBe('orca:first') + expect(keys.get('orca:claude-tool%3A4')).toBe('orca:first') + } }) it('keys a provider-opened turn and its rows to the turn record itself', () => { @@ -515,18 +566,27 @@ describe('which turn owns each transcript row', () => { }), tool(3, 6_000) ] - const keys = keysOf({ turnId: 'resumed', items, submissions: [] }) - // u1 predates the wake turn and never opened one: positional grouping keeps it. - expect(keys.has('orca:u1')).toBe(false) - expect(keys.get('orca:claude-tool%3A3')).toBe(self) + for (const journal of [items, scoped(items)]) { + const keys = keysOf({ items: journal, submissions: [] }) + // u1 predates the wake turn and never opened one: it keeps its own group. + expect(keys.get('orca:u1')).toBe('orca:u1') + expect(keys.get('orca:claude-tool%3A3')).toBe(self) + } }) - it('attributes nothing for an older host that names no opener', () => { + it('groups by position for an older host that names no opener', () => { const items = [ user(1, 'u1', 1_000), turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }), - tool(3, 1_200) + tool(3, 1_200), + user(4, 'u2', 1_300), + tool(5, 1_400) ] - expect(keysOf({ turnId: 't1', items, submissions: [] }).size).toBe(0) + expect([...keysOf({ items, submissions: [] }).values()]).toEqual([ + 'orca:u1', + 'orca:u1', + 'orca:u2', + 'orca:u2' + ]) }) }) diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index e760796618c..3f3607638b9 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -10,6 +10,7 @@ import type { AgentJournalTurnLifecycleState } from './agent-session-journal-types' import { readAgentJournalTurn } from './agent-session-turn-record' +import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatSettledTurn, NativeChatSettledTurns } from './native-chat-turn-status' export type StructuredAgentTurnTiming = { @@ -72,144 +73,44 @@ function readTiming( } } -/** One turn record, read in journal order. */ -type StructuredAgentJournalTurn = { - timing: StructuredAgentTurnTiming | null - /** The transcript key that anchors this turn's bar and owns its rows: the - * opener user item when the host names one it can resolve (or the send still - * in flight ahead of the record), else the turn record's own item (a turn the - * provider opened, or an opener outside the loaded window). Null only for an - * older host that names nothing. */ - key: string | null -} - +/** Every root turn record in one pass over the journal, keyed by its anchor (see + * `structuredAgentTurnAnchors`: the user message that opened it, or the record itself for a turn + * no message opened) and by provider turn id. Untimed rows are skipped from the anchor map unless + * explicitly unverifiable (null). */ type StructuredAgentJournalTurns = { - /** Timing keyed by the user message that opened each turn. */ - byUserItem: ReadonlyMap - byTurnId: ReadonlyMap + byAnchor: ReadonlyMap + byTurnId: ReadonlyMap } -/** Every turn record in one pass over the journal. A row can name its user - * message directly or by a provider key that resolves through its alias. - * Rows from older hosts carry no key and fall back, for timing only, - * to the nearest user message before them in journal order — the submission - * row is written ahead of dispatch, so it always precedes the provider's - * turn-start. Untimed rows are skipped unless explicitly unverifiable (null). */ function readStructuredAgentJournalTurns( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] ): StructuredAgentJournalTurns { - const itemIds = new Set(items.map((item) => item.itemId)) - const aliases = new Map() - // Codex folds a send issued mid-turn into the running turn under the SAME provider - // key, so the earliest submission that names a key is the prompt that opened the turn. - for (const submission of submissions) { - if (submission.providerItemId && !aliases.has(submission.providerItemId)) { - aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) - } - } - // Sends not yet matched to a provider item. Codex reports a turn open before it - // echoes the send, so for that gap the turn names a key no alias resolves yet. - const inFlight = new Set( - submissions - .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) - .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) - ) - const byUserItem = new Map() - const byTurnId = new Map() - let precedingUserItemId: string | null = null - let inFlightSinceLastTurn: string | null = null + const anchors = structuredAgentTurnAnchors(items, submissions) + const byAnchor = new Map() + const byTurnId = new Map() let precedingTurnEndedAt: number | undefined for (const item of items) { - if (item.body.kind === 'message' && item.body.role === 'user') { - precedingUserItemId = item.itemId - if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { - inFlightSinceLastTurn = item.itemId - } - continue - } - const turn = readAgentJournalTurn(item.body) - if (!turn) { + const anchor = anchors.get(item.itemId) + const turn = anchor === undefined ? null : readAgentJournalTurn(item.body) + if (anchor === undefined || !turn) { continue } const timing = readTiming(item, precedingTurnEndedAt) precedingTurnEndedAt = timing?.completedAt ?? item.observedAt - const key = turn.userItemId - const named = key === undefined ? null : itemIds.has(key) ? key : (aliases.get(key) ?? null) - // An unresolved opener is the send still in flight ahead of this record; with none, - // it is outside the window, and the turn anchors to its own record like a - // provider-opened one — never to a preceding prompt that did not open it. - const turnKey = key === undefined ? null : (named ?? inFlightSinceLastTurn ?? item.itemId) - inFlightSinceLastTurn = null - byTurnId.set(turn.turnId, { timing, key: turnKey }) - if (!timing && turn.state !== 'unverifiable') { - continue - } - const userItemId = key === undefined ? precedingUserItemId : turnKey - if (userItemId !== null) { - byUserItem.set(userItemId, timing) + byTurnId.set(turn.turnId, timing) + if (timing || turn.state === 'unverifiable') { + byAnchor.set(anchor, timing) } } - return { byUserItem, byTurnId } -} - -/** - * Which turn owns each journal item, as the transcript groups rows: every item - * between a turn record and the next belongs to that record's turn (the record - * is appended when the turn opens and revised in place, and an opener's user - * item is written ahead of dispatch, so journal order is turn order). A user - * item that opened any turn — even a later one it queued for — keys itself; one - * the provider folded into a running turn (a steer) takes that turn's key, but - * only once the turn produces more rows after it, so a fresh tail send is not - * pulled into the turn it is merely waiting behind. Items before the first turn - * record stay absent, and the surface keeps its positional grouping for them. - */ -function turnKeysByItemIdOf( - items: readonly AgentJournalRenderItem[], - byTurnId: ReadonlyMap -): ReadonlyMap { - const openers = new Set() - for (const turn of byTurnId.values()) { - if (turn.key !== null) { - openers.add(turn.key) - } - } - const keys = new Map() - let currentKey: string | null = null - // User items folded into the current turn, held until a later row proves the - // turn continued past them. - let pendingUserItemIds: string[] = [] - for (const item of items) { - const turn = readAgentJournalTurn(item.body) - if (turn) { - currentKey = byTurnId.get(turn.turnId)?.key ?? null - pendingUserItemIds = [] - continue - } - if (item.body.kind === 'message' && item.body.role === 'user') { - if (openers.has(item.itemId)) { - keys.set(item.itemId, item.itemId) - } else if (currentKey !== null) { - pendingUserItemIds.push(item.itemId) - } - continue - } - if (currentKey !== null) { - for (const userItemId of pendingUserItemIds) { - keys.set(userItemId, currentKey) - } - pendingUserItemIds = [] - keys.set(item.itemId, currentKey) - } - } - return keys + return { byAnchor, byTurnId } } export function selectStructuredAgentTurnTimings( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] ): ReadonlyMap { - return readStructuredAgentJournalTurns(items, submissions).byUserItem + return readStructuredAgentJournalTurns(items, submissions).byAnchor } /** The live turn's lifecycle timing, or null when its row carries no host start @@ -218,7 +119,7 @@ export function selectStructuredAgentRunningTurnTiming( items: readonly AgentJournalRenderItem[], turnId: string ): StructuredAgentTurnTiming | null { - return readStructuredAgentJournalTurns(items).byTurnId.get(turnId)?.timing ?? null + return readStructuredAgentJournalTurns(items).byTurnId.get(turnId) ?? null } /** The single instant every reading of a turn's elapsed time counts from: the @@ -286,7 +187,7 @@ function settledTurnsOf( submissions: readonly AgentJournalSubmission[] ): NativeChatSettledTurns { const settled = new Map() - for (const [userItemId, timing] of turns.byUserItem) { + for (const [userItemId, timing] of turns.byAnchor) { const workedSeconds = completedStructuredAgentTurnSeconds(timing) settled.set( userItemId, @@ -305,9 +206,8 @@ function settledTurnsOf( } /** Everything a chat surface reads off the journal for its turn bars, from one - * pass: settled durations, the running turn's timing, the item that anchors its - * bar — which a message sent while the turn runs is not — and which turn owns - * each row of the transcript. */ + * pass: settled durations and the running turn's timing. Which bar carries the + * running clock is the turn membership's answer (`nativeChatTurnMembership`). */ export function selectStructuredAgentTurnBars( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], @@ -315,18 +215,10 @@ export function selectStructuredAgentTurnBars( ): { settledTurns: NativeChatSettledTurns runningTiming: StructuredAgentTurnTiming | null - /** The transcript key the running turn's bar anchors to: its opening user - * message, or the turn record itself when the provider opened the turn. - * Null on an older host, where the newest user message stays the anchor. */ - activeTurnOpenedBy: string | null - turnKeysByItemId: ReadonlyMap } { const turns = readStructuredAgentJournalTurns(items, submissions) - const running = turnId === null ? undefined : turns.byTurnId.get(turnId) return { settledTurns: settledTurnsOf(turns, submissions), - runningTiming: running?.timing ?? null, - activeTurnOpenedBy: running?.key ?? null, - turnKeysByItemId: turnKeysByItemIdOf(items, turns.byTurnId) + runningTiming: turnId === null ? null : (turns.byTurnId.get(turnId) ?? null) } } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 7af9283f328..320bb7b4925 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -75,6 +75,7 @@ export function structuredHostStub( modelCatalog: vi.fn(() => ({ origin: 'unknown' as const })), readCommands: vi.fn(() => ({ commands: [{ name: 'clear', kind: 'command' as const }] })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), + sessionAgent: vi.fn(() => null), journalSnapshot: vi.fn(() => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 },