mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
fix(native-chat): a request that failed reads as failed (#22944)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 (<stage>)." 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 <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * 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. * 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 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): 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. * 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. * docs(native-chat): the status-store listing rule names provider-journaled user messages * 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(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 * 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(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(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. * docs(agent-status): a departed agent's failure ranks below live work on the worktree card * 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. * 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 * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * 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): 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): 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. * 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -784,6 +784,7 @@ jobs:
|
||||
tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
|
||||
tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
|
||||
@@ -159,6 +159,9 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'src/main/runtime/rpc/methods/session-tabs.ts',
|
||||
'src/main/runtime/rpc/methods/structured-agent-session',
|
||||
'src/main/runtime/rpc/methods/terminal',
|
||||
'src/main/runtime/runtime-worktree-agent-',
|
||||
'src/main/runtime/runtime-worktree-pty-agent-sources',
|
||||
'src/shared/runtime-worktree-contracts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
|
||||
]
|
||||
|
||||
|
||||
@@ -350,6 +350,9 @@ describe('per-job path classification', () => {
|
||||
'src/main/runtime/rpc/methods/structured-agent-session-hold.ts',
|
||||
'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts',
|
||||
'src/main/runtime/rpc/methods/terminal.ts',
|
||||
'src/main/runtime/runtime-worktree-agent-rows.ts',
|
||||
'src/main/runtime/runtime-worktree-pty-agent-sources.ts',
|
||||
'src/shared/runtime-worktree-contracts.ts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
|
||||
@@ -105,8 +105,15 @@ ingests the summary into the hook server as a status row:
|
||||
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
|
||||
| prompt, tool, last message, model, provider session | the summary's fields |
|
||||
|
||||
Sessions with no persisted turn (`status === null`) produce no row, matching
|
||||
what the chat shows. When the host revokes live ownership the row is re-set
|
||||
Sessions with no request (`status === null`) produce no row. A request is a
|
||||
turn record, an assistant message, a user message the provider journaled itself
|
||||
(history, an older host), an accepted or unanswered send, or 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.
|
||||
`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or
|
||||
`failure` for a send the agent or its start refused (a send that joined a running
|
||||
turn is answered by that turn). The row also publishes `interrupted` from
|
||||
`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set
|
||||
without the flag; when the host closes or evicts the session the row is
|
||||
dropped. Both already exist as feed events (`revokeLive` and the roster
|
||||
filter in `liveSessionSummaries`); PR 1 turns them into store writes.
|
||||
@@ -218,6 +225,32 @@ sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a
|
||||
turn boundary remains a secondary source for builds that send it, and
|
||||
`StopFailure` maps to `failure`.
|
||||
|
||||
Readers decode the verdict through one accessor, `agentMainAgentVerdict`, which
|
||||
reads the main agent's own state, not the combined row's: `mainAgent.outcome`
|
||||
while `mainAgent.state` is `done`, then the legacy `interrupted` flag as a
|
||||
cancellation, which alone needs the combined `done`. So a main agent that
|
||||
failed while its subagents still run has a verdict on a `working` row. Every
|
||||
copy of a row (state-history entries, sleep records, `worktree ps` rows) takes
|
||||
the verdict through `agentVerdictFields`, which carries `interrupted` and the
|
||||
whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees
|
||||
with the row and can date a failure by `mainAgent.stateStartedAt`.
|
||||
|
||||
Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the
|
||||
agent failed whatever the combined state, because it is news the user must see
|
||||
even while subagents run; a stop marks it interrupted only on a `done` row, so
|
||||
a stopped or finished main agent with live child work still reads working.
|
||||
Each subagent keeps its own row and state. Container rollups (worktree card,
|
||||
terminal tab, Cmd+J) rank a pending question first, then a failure, then live
|
||||
work, then a stop, then done. On the worktree card, a failure retained after its
|
||||
agent's pane went away has no expiry, so it ranks below live work and above a
|
||||
stop. Lifecycle waiters keep reading the combined `state`.
|
||||
|
||||
Policy splits the verdict two ways. Clean-finish policy (hibernation, pane
|
||||
ownership, the star-nag value moment) treats a failure like a cancellation
|
||||
(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky
|
||||
retention, Cmd+J Recent) demotes only a turn the user stopped
|
||||
(`agentTurnStoppedByUser`); a failure ranks like a completion.
|
||||
|
||||
Admission is one function, `normalizeAgentStatusPayload`, on the relay wire,
|
||||
IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts
|
||||
send none and readers fall back to `state`. Hook rows persist it inside the
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
|
||||
// Per-agent state indicator, 1:1 with desktop AgentStateDot
|
||||
// (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working',
|
||||
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
|
||||
// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral
|
||||
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
|
||||
// agent vocabulary into the 5-state rollup the sidebar dot uses.
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
|
||||
@@ -13,6 +13,7 @@ const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, strin
|
||||
blocked: '#ef4444',
|
||||
waiting: '#ef4444',
|
||||
interrupted: '#ef4444',
|
||||
failed: '#ef4444',
|
||||
idle: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
const WORKING_COLOR = '#eab308'
|
||||
|
||||
@@ -2,7 +2,12 @@ import { memo } from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { agentDisplayLabel, agentDotState, formatTimeAgo } from '../worktree/agent-row-display'
|
||||
import {
|
||||
agentDisplayLabel,
|
||||
agentDotState,
|
||||
agentRowTimeAt,
|
||||
formatTimeAgo
|
||||
} from '../worktree/agent-row-display'
|
||||
import { AgentStateDot } from './AgentStateDot'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
|
||||
@@ -22,7 +27,7 @@ type Props = {
|
||||
function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) {
|
||||
const dotState = agentDotState(agent, now)
|
||||
const label = agentDisplayLabel(agent, now)
|
||||
const ts = formatTimeAgo(agent.stateStartedAt, now)
|
||||
const ts = formatTimeAgo(agentRowTimeAt(agent), now)
|
||||
|
||||
return (
|
||||
<View style={[styles.row, { paddingLeft: depth * INDENT_PER_DEPTH }]}>
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import {
|
||||
agentMainAgentVerdict,
|
||||
agentVerdictDisplayMark
|
||||
} from '../../../src/shared/agent-main-agent-verdict'
|
||||
import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
agentDisplayLabel,
|
||||
agentDotState,
|
||||
agentIdentityLabel,
|
||||
agentRowTimeAt,
|
||||
agentRowVerdict,
|
||||
agentRowVerdictMark,
|
||||
formatTimeAgo
|
||||
} from './agent-row-display'
|
||||
|
||||
type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number]
|
||||
const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({
|
||||
mainAgent: { state: 'done' as const, outcome, stateStartedAt }
|
||||
})
|
||||
|
||||
function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
|
||||
return {
|
||||
paneKey: 'p',
|
||||
@@ -37,8 +50,54 @@ describe('agentDotState', () => {
|
||||
expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle')
|
||||
})
|
||||
|
||||
it('reports interrupted regardless of state', () => {
|
||||
it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => {
|
||||
expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(
|
||||
agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0)
|
||||
).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done')
|
||||
})
|
||||
|
||||
it('shows a main agent that failed while its subagents still run as failed', () => {
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
// Only a failure outranks live work; a success or a stop with live subagents reads working.
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working')
|
||||
expect(
|
||||
agentDotState(
|
||||
row({ state: 'working', ...mainAgentDone('cancellation'), interrupted: true }),
|
||||
0
|
||||
)
|
||||
).toBe('working')
|
||||
})
|
||||
|
||||
// The shared accessor cannot be imported by app code here, so this mirror must not drift from it.
|
||||
it('agrees with the desktop verdict accessor on every row', () => {
|
||||
const states = ['working', 'blocked', 'waiting', 'done'] as const
|
||||
const mainAgents = [
|
||||
undefined,
|
||||
...states.flatMap((state) =>
|
||||
[undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({
|
||||
state,
|
||||
...(outcome ? { outcome } : {}),
|
||||
stateStartedAt: 0
|
||||
}))
|
||||
)
|
||||
]
|
||||
for (const state of states) {
|
||||
for (const mainAgent of mainAgents) {
|
||||
for (const interrupted of [false, true]) {
|
||||
const agentRow = { state, interrupted, ...(mainAgent ? { mainAgent } : {}) }
|
||||
expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe(
|
||||
agentMainAgentVerdict(agentRow)
|
||||
)
|
||||
expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe(
|
||||
agentVerdictDisplayMark(agentRow)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('decays a stale active state to idle, matching desktop', () => {
|
||||
@@ -51,14 +110,36 @@ describe('agentDotState', () => {
|
||||
expect(
|
||||
agentDotState(row({ state: 'working', updatedAt: 0 }), AGENT_STATUS_STALE_AFTER_MS)
|
||||
).toBe('working')
|
||||
// 'done' never decays; interrupted still wins.
|
||||
// 'done' never decays, and neither does its verdict.
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done')
|
||||
expect(agentDotState(row({ state: 'working', updatedAt: 0, interrupted: true }), stale)).toBe(
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe(
|
||||
'interrupted'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentRowTimeAt', () => {
|
||||
it('dates a main agent that failed while its subagents run by its own failure', () => {
|
||||
expect(
|
||||
agentRowTimeAt(
|
||||
row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('failure', 900) })
|
||||
)
|
||||
).toBe(900)
|
||||
})
|
||||
|
||||
it('dates every other row by when its state began', () => {
|
||||
expect(
|
||||
agentRowTimeAt(
|
||||
row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('success', 900) })
|
||||
)
|
||||
).toBe(100)
|
||||
expect(
|
||||
agentRowTimeAt(row({ state: 'done', stateStartedAt: 100, ...mainAgentDone('failure', 900) }))
|
||||
).toBe(100)
|
||||
expect(agentRowTimeAt(row({ state: 'working', stateStartedAt: 100 }))).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentDisplayLabel', () => {
|
||||
it('prefers last message, then prompt, then state label', () => {
|
||||
expect(agentDisplayLabel(row({ lastAssistantMessage: 'hello there' }), 0)).toBe('hello there')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome'
|
||||
|
||||
// Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts:
|
||||
// 30 min). Defined locally rather than imported because a runtime-value import
|
||||
@@ -17,13 +18,39 @@ export type AgentDotState =
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'interrupted'
|
||||
| 'failed'
|
||||
|
||||
type AgentRowVerdictSource = Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent'>
|
||||
|
||||
// Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark
|
||||
// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is
|
||||
// the main agent's own status, sent also while subagents hold the row working; an old host sends none.
|
||||
export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null {
|
||||
if (row.mainAgent && row.mainAgent.state !== 'done') {
|
||||
return null
|
||||
}
|
||||
return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null)
|
||||
}
|
||||
|
||||
// A failure outranks every state; a stop marks only a row that is itself done.
|
||||
export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null {
|
||||
const verdict = agentRowVerdict(row)
|
||||
if (verdict === 'failure') {
|
||||
return 'failed'
|
||||
}
|
||||
return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null
|
||||
}
|
||||
|
||||
export function agentDotState(
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
|
||||
row: Pick<
|
||||
RuntimeWorktreeAgentRow,
|
||||
'state' | 'workingMode' | 'interrupted' | 'mainAgent' | 'updatedAt'
|
||||
>,
|
||||
now: number
|
||||
): AgentDotState {
|
||||
if (row.interrupted) {
|
||||
return 'interrupted'
|
||||
const mark = agentRowVerdictMark(row)
|
||||
if (mark) {
|
||||
return mark
|
||||
}
|
||||
switch (row.state) {
|
||||
case 'blocked':
|
||||
@@ -56,6 +83,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
return 'Waiting for input'
|
||||
case 'interrupted':
|
||||
return 'Interrupted'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
case 'done':
|
||||
return 'Done'
|
||||
case 'idle':
|
||||
@@ -99,6 +128,17 @@ export function agentIdentityLabel(agentType: string | null): string {
|
||||
return known[normalized] ?? normalized.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
// When the row's state began, except that a main agent that failed while its subagents run is
|
||||
// dated by its own failure. Mirrors desktop lastEnteredDoneAt (agent-finished-timestamp.ts).
|
||||
export function agentRowTimeAt(
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent' | 'stateStartedAt'>
|
||||
): number {
|
||||
if (row.state !== 'done' && row.mainAgent && agentRowVerdictMark(row) === 'failed') {
|
||||
return row.mainAgent.stateStartedAt
|
||||
}
|
||||
return row.stateStartedAt
|
||||
}
|
||||
|
||||
// Relative time, matching desktop formatTimeAgo thresholds (just now / Xm / Xh / Xd).
|
||||
export function formatTimeAgo(ts: number, now: number): string {
|
||||
const delta = now - ts
|
||||
|
||||
@@ -21,6 +21,13 @@ function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktre
|
||||
}
|
||||
}
|
||||
|
||||
function done(
|
||||
outcome: 'success' | 'failure',
|
||||
stateStartedAt = 1
|
||||
): NonNullable<RuntimeWorktreeAgentRow['mainAgent']> {
|
||||
return { state: 'done', outcome, stateStartedAt }
|
||||
}
|
||||
|
||||
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
const worktreePath = join('/tmp', 'orca', 'worktrees', 'manta')
|
||||
return {
|
||||
@@ -160,6 +167,31 @@ describe('areWorktreeListsEqual', () => {
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects a verdict change that leaves the interrupted flag as it was', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'done', mainAgent: done('success') })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'done', mainAgent: done('failure') })] })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects a main agent failing while its subagents keep the row working', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'working' })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'working', mainAgent: done('failure') })] })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects the main agent clock moving, which dates a failure', () => {
|
||||
const at = (stateStartedAt: number) => [
|
||||
worktree({
|
||||
agents: [agent({ state: 'working', mainAgent: done('failure', stateStartedAt) })]
|
||||
})
|
||||
]
|
||||
|
||||
expect(areWorktreeListsEqual(at(1), at(2))).toBe(false)
|
||||
expect(areWorktreeListsEqual(at(1), at(1))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects monitoring mode changes within working', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'working' })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })]
|
||||
|
||||
@@ -110,6 +110,7 @@ function areAgentRowsEqual(
|
||||
a.toolName !== b.toolName ||
|
||||
a.toolInput !== b.toolInput ||
|
||||
a.interrupted !== b.interrupted ||
|
||||
!areMainAgentsEqual(a.mainAgent, b.mainAgent) ||
|
||||
a.stateStartedAt !== b.stateStartedAt ||
|
||||
a.updatedAt !== b.updatedAt
|
||||
) {
|
||||
@@ -118,3 +119,20 @@ function areAgentRowsEqual(
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function areMainAgentsEqual(
|
||||
left: RuntimeWorktreeAgentRow['mainAgent'],
|
||||
right: RuntimeWorktreeAgentRow['mainAgent']
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
left.state === right.state &&
|
||||
left.outcome === right.outcome &&
|
||||
left.stateStartedAt === right.stateStartedAt
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from '../../../shared/structured-agent-session-projection'
|
||||
import {
|
||||
continueMainAgentStatus,
|
||||
isAgentStatusHeldOpenByChildWork
|
||||
isAgentStatusHeldOpenByChildWork,
|
||||
mainAgentTurnInterrupted
|
||||
} from '../../../shared/agent-lead-status-fold'
|
||||
import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status'
|
||||
import {
|
||||
@@ -73,6 +74,8 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
|
||||
state,
|
||||
...(workingMode ? { workingMode } : {}),
|
||||
mainAgent,
|
||||
// Readers that predate `mainAgent` read a cancellation off this flag, as the hook lanes publish it.
|
||||
interrupted: mainAgentTurnInterrupted(mainAgent),
|
||||
prompt: summary.latestPrompt,
|
||||
agentType: summary.agent,
|
||||
...(summary.model ? { model: summary.model } : {}),
|
||||
|
||||
@@ -76,7 +76,10 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s
|
||||
if (args.agentState === 'working') {
|
||||
return translateMain('notifications.agentStatus.working', 'working')
|
||||
}
|
||||
return args.agentState === 'done' && args.agentInterrupted
|
||||
if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') {
|
||||
return translateMain('notifications.agentStatus.failed', 'failed')
|
||||
}
|
||||
return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation'
|
||||
? translateMain('notifications.agentStatus.stopped', 'stopped')
|
||||
: translateMain('notifications.agentStatus.finished', 'finished')
|
||||
}
|
||||
@@ -104,7 +107,7 @@ function hasAgentNotificationSnapshot(args: NotificationDispatchRequest): boolea
|
||||
args.agentToolName ||
|
||||
args.agentToolInput ||
|
||||
args.agentLastAssistantMessage ||
|
||||
args.agentInterrupted
|
||||
args.agentTurnOutcome !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('registerNotificationHandlers', () => {
|
||||
worktreeLabel: 'feat/notis',
|
||||
agentType: 'claude',
|
||||
agentState: 'done',
|
||||
agentInterrupted: true,
|
||||
agentTurnOutcome: 'cancellation',
|
||||
agentLastAssistantMessage: 'Stopped by user.'
|
||||
}
|
||||
)
|
||||
@@ -316,7 +316,12 @@ describe('registerNotificationHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an interrupted finish as stopped', async () => {
|
||||
it.each([
|
||||
{ agentTurnOutcome: 'cancellation', word: 'stopped' },
|
||||
{ agentTurnOutcome: 'failure', word: 'failed' },
|
||||
{ agentTurnOutcome: 'success', word: 'finished' },
|
||||
{ agentTurnOutcome: undefined, word: 'finished' }
|
||||
] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => {
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
@@ -336,14 +341,40 @@ describe('registerNotificationHandlers', () => {
|
||||
worktreeLabel: 'feat/notis',
|
||||
agentType: 'claude',
|
||||
agentState: 'done',
|
||||
agentInterrupted: true
|
||||
...(agentTurnOutcome ? { agentTurnOutcome } : {})
|
||||
}
|
||||
)
|
||||
|
||||
expect(notificationCtorMock).toHaveBeenCalledWith(
|
||||
expectedNativeNotificationOptions({
|
||||
title: 'feat/notis - Claude stopped',
|
||||
body: 'Claude stopped.'
|
||||
title: `feat/notis - Claude ${word}`,
|
||||
body: `Claude ${word}.`
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a success verdict alone as an agent snapshot', async () => {
|
||||
registerNotificationHandlers({
|
||||
getSettings: () => ({
|
||||
notifications: {
|
||||
enabled: true,
|
||||
agentTaskComplete: true,
|
||||
terminalBell: false,
|
||||
suppressWhenFocused: true
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
|
||||
const handler = getDispatchHandler()
|
||||
await handler(
|
||||
{},
|
||||
{ source: 'agent-task-complete', worktreeLabel: 'feat/notis', agentTurnOutcome: 'success' }
|
||||
)
|
||||
|
||||
expect(notificationCtorMock).toHaveBeenCalledWith(
|
||||
expectedNativeNotificationOptions({
|
||||
title: 'feat/notis - Agent finished',
|
||||
body: 'Agent finished.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
AgentJournalSubmission
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection'
|
||||
|
||||
export type ProviderHistoryItem = {
|
||||
/** The provider's own id for this item. Used to claim it at most once; the
|
||||
@@ -55,7 +56,7 @@ export type SubmissionReconciliation =
|
||||
| { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason }
|
||||
| { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason }
|
||||
|
||||
export type SubmissionRejectionReason = 'not_delivered'
|
||||
export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED
|
||||
|
||||
export type SubmissionUnknownReason =
|
||||
| 'history_boundary_inconsistent'
|
||||
@@ -197,6 +198,6 @@ function resolveOne(
|
||||
return {
|
||||
clientMessageId: submission.clientMessageId,
|
||||
outcome: 'rejected',
|
||||
reason: 'not_delivered'
|
||||
reason: DISPATCH_REJECTED_NOT_DELIVERED
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -8,7 +8,11 @@ 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 { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
|
||||
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type {
|
||||
AgentSessionSubscribeEvent,
|
||||
AgentSessionTurnCompletionEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
DISPATCH_REJECTED_CANCELLED,
|
||||
DISPATCH_REJECTED_HOST_RESTARTED,
|
||||
@@ -318,6 +322,28 @@ describe('a start the chat needed and did not get', () => {
|
||||
expect(errorRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('notifies failed once for the queued messages one start failure refused', async () => {
|
||||
await host.close(SESSION)
|
||||
acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT'))
|
||||
const completions: AgentSessionTurnCompletionEvent[] = []
|
||||
host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) })
|
||||
await accept('first')
|
||||
const second = await accept('second')
|
||||
|
||||
await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected'))
|
||||
await host.flushAllStreamedEvents()
|
||||
expect(completions).toEqual([
|
||||
{
|
||||
type: 'completion',
|
||||
completion: expect.objectContaining({
|
||||
sessionId: SESSION,
|
||||
turnId: agentJournalSubmissionKey(second),
|
||||
outcome: 'failure'
|
||||
})
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'eligibility',
|
||||
|
||||
+14
@@ -8,6 +8,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection'
|
||||
import { digestPayload } from '../agent-session-journal/journal-payload-bounds'
|
||||
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
@@ -117,6 +118,19 @@ describe('attachJournal restart reconciliation', () => {
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives a send the provider never received no verdict and no listing', async () => {
|
||||
await crashedJournal()
|
||||
const { adapter } = adapterWith(async () => window())
|
||||
|
||||
const attached = await attach(adapter)
|
||||
|
||||
// Nobody failed: the crash stranded it, so the chat must not read Failed or be listed by it.
|
||||
const { items, submissions } = attached.journal.snapshot()
|
||||
expect(
|
||||
projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence)
|
||||
).toMatchObject({ summary: { status: null }, latestRequest: null })
|
||||
})
|
||||
|
||||
it('still reports a submission unconfirmed when the window cannot decide it', async () => {
|
||||
await crashedJournal()
|
||||
const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true }))
|
||||
|
||||
@@ -31,7 +31,11 @@ export class StructuredAgentSessionClientDelivery {
|
||||
private readonly onJournalActivity?: (sessionId: string) => void
|
||||
) {
|
||||
this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps })
|
||||
this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now })
|
||||
this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions,
|
||||
now,
|
||||
readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal)
|
||||
})
|
||||
this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) =>
|
||||
this.requireJournal(sessionId)
|
||||
)
|
||||
@@ -81,7 +85,8 @@ export class StructuredAgentSessionClientDelivery {
|
||||
this.statusFeed.publish(sessionId, journal)
|
||||
this.sendSettlement.publish(sessionId, journal)
|
||||
// Derived here rather than per-subscriber: this edge runs whether or not anyone is
|
||||
// subscribed, which is the whole reason a backgrounded chat can complete at all.
|
||||
// subscribed, which is the whole reason a backgrounded chat can complete at all. After the
|
||||
// status publish, so it reads the projection that publish cached.
|
||||
this.turnCompletionFeed.observe(sessionId, journal)
|
||||
this.onJournalActivity?.(sessionId)
|
||||
}
|
||||
|
||||
+5
-1
@@ -88,7 +88,11 @@ async function sessionWithRunningTurn() {
|
||||
}
|
||||
}
|
||||
})
|
||||
const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock })
|
||||
const completions = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions,
|
||||
now: () => clock,
|
||||
readStatusState: (sessionId, source) => feed.statusState(sessionId, source)
|
||||
})
|
||||
const completionEvents: AgentSessionTurnCompletionEvent[] = []
|
||||
completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) })
|
||||
// Both feeds have seen the turn running, so its settlement is a transition they must judge.
|
||||
|
||||
+6
-1
@@ -95,7 +95,12 @@ async function restartWithPersistedTurn(): Promise<StructuredAgentSessionHost> {
|
||||
const host = createHost(store)
|
||||
expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true })
|
||||
const body = hostTestMessage('persisted conversation')
|
||||
await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body })
|
||||
const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body })
|
||||
if (!sent.ok) {
|
||||
throw new Error('send was refused')
|
||||
}
|
||||
// Delivered, not just accepted: a message still queued at the restart was never a request.
|
||||
await host.waitForSendSettlement(SESSION, sent.value.clientMessageId)
|
||||
await host.flushAllStreamedEvents()
|
||||
return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' }))
|
||||
}
|
||||
|
||||
+2
-1
@@ -191,7 +191,8 @@ describe('StructuredAgentSessionStatusFeed', () => {
|
||||
expect(events.at(-1)).toMatchObject({ session: { status: 'working' } })
|
||||
record.lease.runtimeFence = 2
|
||||
feed.publish(SESSION)
|
||||
expect(events.at(-1)).toMatchObject({ session: { status: 'idle' } })
|
||||
// Its only send outlived the host that sent it and became no turn: nothing left to list.
|
||||
expect(events.at(-1)).toMatchObject({ session: { status: null } })
|
||||
})
|
||||
|
||||
it('publishes working from the pending submission, before the provider replays the turn', async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type AgentSessionStatusSummary
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence'
|
||||
import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection'
|
||||
import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection'
|
||||
import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter'
|
||||
@@ -34,6 +34,10 @@ import {
|
||||
|
||||
export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership'
|
||||
|
||||
export type StructuredAgentSessionStatusState = ReturnType<
|
||||
typeof projectStructuredAgentSessionStatusState
|
||||
>
|
||||
|
||||
export type StructuredAgentSessionStatusSubscriber = {
|
||||
id: string
|
||||
emit: (event: AgentSessionStatusEvent) => void
|
||||
@@ -142,7 +146,7 @@ export class StructuredAgentSessionStatusFeed {
|
||||
sequence: number
|
||||
readOnly: boolean
|
||||
fence: number | undefined
|
||||
summary: ReturnType<typeof projectStructuredAgentSessionStatusSummary>
|
||||
state: StructuredAgentSessionStatusState
|
||||
}
|
||||
>()
|
||||
|
||||
@@ -204,6 +208,17 @@ export class StructuredAgentSessionStatusFeed {
|
||||
})
|
||||
}
|
||||
|
||||
/** The projection behind the session's row and the latest request it read, cached per commit,
|
||||
* so the completion feed follows the same request without snapshotting the journal again. */
|
||||
statusState(
|
||||
sessionId: string,
|
||||
journal?: AgentSessionJournal
|
||||
): StructuredAgentSessionStatusState | null {
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
const source = journal ?? session?.journal
|
||||
return source ? this.projectionFor(source, this.deps.getRecord(sessionId)) : null
|
||||
}
|
||||
|
||||
/** Re-projects one session after its journal changed; equal projections are not re-sent. */
|
||||
publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void {
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
@@ -234,35 +249,8 @@ export class StructuredAgentSessionStatusFeed {
|
||||
session: StatusFeedSession,
|
||||
journal: AgentSessionJournal
|
||||
): AgentSessionStatusSummary {
|
||||
// An unreadable journal projects as "no turn": the chat itself shows the reset.
|
||||
const cursor = journal.cursor()
|
||||
const readOnly = journal.isReadOnly
|
||||
const record = this.deps.getRecord(sessionId)
|
||||
// The conversation's fence, which a child's end moves: its unanswered sends stop counting.
|
||||
const fence = record?.lease.runtimeFence
|
||||
let projection = this.journalProjections.get(journal)
|
||||
if (
|
||||
!projection ||
|
||||
projection.epoch !== cursor.epoch ||
|
||||
projection.sequence !== cursor.sequence ||
|
||||
projection.readOnly !== readOnly ||
|
||||
projection.fence !== fence
|
||||
) {
|
||||
// A journalled submission bumps `lastSequence`, so the send-time working
|
||||
// signal reaches the cache; the lease fence does not, hence the extra key.
|
||||
const snapshot = readOnly ? null : journal.snapshot()
|
||||
projection = {
|
||||
...cursor,
|
||||
readOnly,
|
||||
fence,
|
||||
summary: projectStructuredAgentSessionStatusSummary(
|
||||
snapshot?.items ?? [],
|
||||
snapshot?.submissions ?? [],
|
||||
fence
|
||||
)
|
||||
}
|
||||
this.journalProjections.set(journal, projection)
|
||||
}
|
||||
const { summary: projected } = this.projectionFor(journal, record)
|
||||
const providerSession = structuredAgentSessionProviderSessionMetadata(record)
|
||||
// The journal has no model: the record's acknowledged options are where a mid-session
|
||||
// switch lands, so the row follows whichever is in force.
|
||||
@@ -280,7 +268,7 @@ export class StructuredAgentSessionStatusFeed {
|
||||
...(session.child
|
||||
? { hostExecutionOwned: true as const, hostExecutionPhase: session.child.phase }
|
||||
: {}),
|
||||
...projection.summary,
|
||||
...projected,
|
||||
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
|
||||
? { rewindBlockedReason: 'outcome-unknown' as const }
|
||||
: {}),
|
||||
@@ -304,6 +292,41 @@ export class StructuredAgentSessionStatusFeed {
|
||||
}
|
||||
}
|
||||
|
||||
private projectionFor(
|
||||
journal: AgentSessionJournal,
|
||||
record: AgentSessionRecord | null
|
||||
): StructuredAgentSessionStatusState {
|
||||
// An unreadable journal projects as "no turn": the chat itself shows the reset.
|
||||
const cursor = journal.cursor()
|
||||
const readOnly = journal.isReadOnly
|
||||
// The conversation's fence, which a child's end moves: its unanswered sends stop counting.
|
||||
const fence = record?.lease.runtimeFence
|
||||
let projection = this.journalProjections.get(journal)
|
||||
if (
|
||||
!projection ||
|
||||
projection.epoch !== cursor.epoch ||
|
||||
projection.sequence !== cursor.sequence ||
|
||||
projection.readOnly !== readOnly ||
|
||||
projection.fence !== fence
|
||||
) {
|
||||
// A journalled submission bumps `lastSequence`, so the send-time working
|
||||
// signal reaches the cache; the lease fence does not, hence the extra key.
|
||||
const snapshot = readOnly ? null : journal.snapshot()
|
||||
projection = {
|
||||
...cursor,
|
||||
readOnly,
|
||||
fence,
|
||||
state: projectStructuredAgentSessionStatusState(
|
||||
snapshot?.items ?? [],
|
||||
snapshot?.submissions ?? [],
|
||||
fence
|
||||
)
|
||||
}
|
||||
this.journalProjections.set(journal, projection)
|
||||
}
|
||||
return projection.state
|
||||
}
|
||||
|
||||
/** A failing sink must never cost the subscribers their status event. */
|
||||
private sink(
|
||||
summary: AgentSessionStatusSummary,
|
||||
|
||||
+408
-9
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission,
|
||||
AgentJournalTurnLifecycle
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
DISPATCH_REJECTED_CANCELLED,
|
||||
DISPATCH_REJECTED_HOST_RESTARTED,
|
||||
DISPATCH_REJECTED_NOT_DELIVERED,
|
||||
DISPATCH_REJECTED_PROVIDER_CLOSED
|
||||
} from '../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection'
|
||||
import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed'
|
||||
|
||||
const LOCATION = {
|
||||
@@ -10,6 +22,8 @@ const LOCATION = {
|
||||
workspaceKind: 'git-worktree'
|
||||
} as const
|
||||
|
||||
const START_FAILURE = 'Claude is not signed in.'
|
||||
|
||||
function turn(
|
||||
turnId: string,
|
||||
state: AgentJournalTurnLifecycle['state'],
|
||||
@@ -18,33 +32,102 @@ function turn(
|
||||
return { turnId, state, ...(outcome ? { outcome } : {}) }
|
||||
}
|
||||
|
||||
function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `codex:turn:${lifecycle.turnId}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'turn', ...lifecycle }
|
||||
}
|
||||
}
|
||||
|
||||
function userEntry(clientMessageId: string, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: agentJournalSubmissionKey(clientMessageId),
|
||||
revision: 0,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }
|
||||
}
|
||||
}
|
||||
|
||||
function sent(
|
||||
clientMessageId: string,
|
||||
fields: Partial<AgentJournalSubmission> & Pick<AgentJournalSubmission, 'dispatchState'>
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: clientMessageId,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 10,
|
||||
resolvedAt: 20,
|
||||
handoverRecorded: true,
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
const pending = (clientMessageId: string, fence = 1) =>
|
||||
sent(clientMessageId, { dispatchState: 'pending', fence, handedOverAt: 11, resolvedAt: null })
|
||||
const refused = (clientMessageId: string, reason = START_FAILURE) =>
|
||||
sent(clientMessageId, { dispatchState: 'rejected', reason })
|
||||
|
||||
function harness(): {
|
||||
feed: StructuredAgentSessionTurnCompletionFeed
|
||||
setTurn: (next: AgentJournalTurnLifecycle | null) => void
|
||||
setJournal: (
|
||||
items: AgentJournalRenderItem[],
|
||||
submissions: AgentJournalSubmission[],
|
||||
fence?: number
|
||||
) => void
|
||||
setCursor: (next: { epoch: string; sequence: number }) => void
|
||||
observe: () => void
|
||||
events: AgentSessionTurnCompletionEvent[]
|
||||
outcomes: () => [string, string][]
|
||||
/** Whether each completion said the user is being asked something. */
|
||||
awaitingUser: () => boolean[]
|
||||
listen: () => () => void
|
||||
} {
|
||||
let current: AgentJournalTurnLifecycle | null = null
|
||||
let items: AgentJournalRenderItem[] = []
|
||||
let submissions: AgentJournalSubmission[] = []
|
||||
let fence: number | undefined
|
||||
let cursor = { epoch: 'epoch-1', sequence: 0 }
|
||||
const journal = {
|
||||
newestTurn: () => current,
|
||||
cursor: () => cursor
|
||||
}
|
||||
const journal = { cursor: () => cursor }
|
||||
const sessions = new Map([['session-1', { journal, params: { location: LOCATION } }]])
|
||||
const feed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => 1_700 })
|
||||
const feed = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions,
|
||||
now: () => 1_700,
|
||||
// The status feed's projection, computed as it computes it.
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence)
|
||||
})
|
||||
const events: AgentSessionTurnCompletionEvent[] = []
|
||||
return {
|
||||
feed,
|
||||
setTurn: (next) => {
|
||||
current = next
|
||||
items = next ? [turnItem(next, 1)] : []
|
||||
submissions = []
|
||||
},
|
||||
setJournal: (nextItems, nextSubmissions, nextFence) => {
|
||||
items = nextItems
|
||||
submissions = nextSubmissions
|
||||
fence = nextFence
|
||||
cursor = { ...cursor, sequence: cursor.sequence + 1 }
|
||||
},
|
||||
setCursor: (next) => {
|
||||
cursor = next
|
||||
},
|
||||
observe: () => feed.observe('session-1'),
|
||||
events,
|
||||
outcomes: () =>
|
||||
events.flatMap((event): [string, string][] =>
|
||||
event.type === 'completion' ? [[event.completion.turnId, event.completion.outcome]] : []
|
||||
),
|
||||
awaitingUser: () =>
|
||||
events.flatMap((event) =>
|
||||
event.type === 'completion' ? [event.completion.awaitingUser === true] : []
|
||||
),
|
||||
listen: () => feed.subscribe({ id: 'sub', emit: (event) => events.push(event) })
|
||||
}
|
||||
}
|
||||
@@ -59,7 +142,8 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => {
|
||||
h.setTurn(turn('turn-1', 'completed', 'success'))
|
||||
h.setCursor({ epoch: 'epoch-1', sequence: 2 })
|
||||
h.observe()
|
||||
expect(h.events).toEqual([
|
||||
// Strict: an idle settle omits `awaitingUser` rather than sending it undefined.
|
||||
expect(h.events).toStrictEqual([
|
||||
{
|
||||
type: 'completion',
|
||||
completion: {
|
||||
@@ -261,3 +345,318 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => {
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a request the agent or its start refused', () => {
|
||||
const M1 = agentJournalSubmissionKey('m1')
|
||||
const M2 = agentJournalSubmissionKey('m2')
|
||||
const M3 = agentJournalSubmissionKey('m3')
|
||||
const settledTurn = turnItem(turn('t1', 'completed', 'success'), 2)
|
||||
|
||||
/** A session whose first turn succeeded, as the feed saw it happen. */
|
||||
function afterSuccessfulTurn() {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
h.setJournal([userEntry('m1', 1), turnItem(turn('t1', 'running'), 2)], [])
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1), settledTurn], [sent('m1', { dispatchState: 'accepted' })])
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([['t1', 'success']])
|
||||
return h
|
||||
}
|
||||
|
||||
it('notifies failed once when the only send fails to start, named by its item key', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1)], [pending('m1')])
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1)], [refused('m1')])
|
||||
h.observe()
|
||||
h.observe()
|
||||
expect(h.events).toEqual([
|
||||
{
|
||||
type: 'completion',
|
||||
completion: {
|
||||
scope: LOCATION,
|
||||
sessionId: 'session-1',
|
||||
turnId: M1,
|
||||
outcome: 'failure',
|
||||
completedAt: 1_700
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('stays silent on a first observation of a send that had already failed', () => {
|
||||
// A restart, reopen or re-attach: the failure is history, not news.
|
||||
const h = harness()
|
||||
h.listen()
|
||||
h.setJournal([userEntry('m1', 1)], [refused('m1')])
|
||||
h.observe()
|
||||
h.observe()
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
|
||||
it('re-baselines an epoch replacement that surfaces an older failure', () => {
|
||||
const h = afterSuccessfulTurn()
|
||||
h.setJournal([userEntry('m1', 1)], [refused('m1')])
|
||||
h.setCursor({ epoch: 'epoch-2', sequence: 1 })
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([['t1', 'success']])
|
||||
})
|
||||
|
||||
it.each([
|
||||
DISPATCH_REJECTED_CANCELLED,
|
||||
DISPATCH_REJECTED_HOST_RESTARTED,
|
||||
DISPATCH_REJECTED_PROVIDER_CLOSED,
|
||||
DISPATCH_REJECTED_NOT_DELIVERED
|
||||
])('never notifies a send %s, alone or after a turn', (reason) => {
|
||||
const alone = harness()
|
||||
alone.listen()
|
||||
alone.observe()
|
||||
alone.setJournal([userEntry('m1', 1)], [pending('m1')])
|
||||
alone.observe()
|
||||
alone.setJournal([userEntry('m1', 1)], [refused('m1', reason)])
|
||||
alone.observe()
|
||||
expect(alone.events).toEqual([])
|
||||
|
||||
// The latest request falls back to the turn already announced, which must not announce again.
|
||||
const h = afterSuccessfulTurn()
|
||||
const accepted = sent('m1', { dispatchState: 'accepted' })
|
||||
h.setJournal([userEntry('m1', 1), settledTurn, userEntry('m2', 3)], [accepted, pending('m2')])
|
||||
h.observe()
|
||||
h.setJournal(
|
||||
[userEntry('m1', 1), settledTurn, userEntry('m2', 3)],
|
||||
[accepted, refused('m2', reason)]
|
||||
)
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([['t1', 'success']])
|
||||
})
|
||||
|
||||
it('never notifies a crash-stranded send that restart reconciliation finds undelivered', () => {
|
||||
const h = afterSuccessfulTurn()
|
||||
const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3)]
|
||||
const accepted = sent('m1', { dispatchState: 'accepted' })
|
||||
h.setJournal(items, [accepted, sent('m2', { dispatchState: 'unknown', recovered: true })], 2)
|
||||
h.observe()
|
||||
h.setJournal(
|
||||
items,
|
||||
[
|
||||
accepted,
|
||||
sent('m2', {
|
||||
dispatchState: 'rejected',
|
||||
reason: DISPATCH_REJECTED_NOT_DELIVERED,
|
||||
fence: 2,
|
||||
recovered: true
|
||||
})
|
||||
],
|
||||
2
|
||||
)
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([['t1', 'success']])
|
||||
})
|
||||
|
||||
it('notifies failed, then success, when a failed start is retried and the retry succeeds', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1)], [refused('m1')])
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')])
|
||||
h.observe()
|
||||
const accepted = sent('m2', { dispatchState: 'accepted' })
|
||||
h.setJournal(
|
||||
[userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'running'), 3)],
|
||||
[refused('m1'), accepted]
|
||||
)
|
||||
h.observe()
|
||||
h.setJournal(
|
||||
[userEntry('m1', 1), userEntry('m2', 2), turnItem(turn('t2', 'completed', 'success'), 3)],
|
||||
[refused('m1'), accepted]
|
||||
)
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([
|
||||
[M1, 'failure'],
|
||||
['t2', 'success']
|
||||
])
|
||||
})
|
||||
|
||||
it('notifies each failed start that follows another', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1)], [refused('m1')])
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), pending('m2')])
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [refused('m1'), refused('m2')])
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([
|
||||
[M1, 'failure'],
|
||||
[M2, 'failure']
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['in one commit', [['m2', 'm3']]],
|
||||
['oldest first, across commits', [['m2'], ['m3']]],
|
||||
['newest first, across commits', [['m3'], ['m2']]]
|
||||
])('notifies once for queued sends one start failure refused %s', (_name, batches) => {
|
||||
const h = afterSuccessfulTurn()
|
||||
const items = [userEntry('m1', 1), settledTurn, userEntry('m2', 3), userEntry('m3', 4)]
|
||||
const accepted = sent('m1', { dispatchState: 'accepted' })
|
||||
const queued = (id: string) => sent(id, { dispatchState: 'pending', resolvedAt: null })
|
||||
const answered = new Set<string>()
|
||||
const submissions = () => [
|
||||
accepted,
|
||||
...['m2', 'm3'].map((id) => (answered.has(id) ? refused(id) : queued(id)))
|
||||
]
|
||||
h.setJournal(items, submissions())
|
||||
h.observe()
|
||||
for (const batch of batches) {
|
||||
batch.forEach((id) => answered.add(id))
|
||||
h.setJournal(items, submissions())
|
||||
h.observe()
|
||||
}
|
||||
expect(h.outcomes()).toEqual([
|
||||
['t1', 'success'],
|
||||
[M3, 'failure']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not wait on a send left pending at an older fence', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), pending('m2', 2)], 2)
|
||||
h.observe()
|
||||
h.setJournal([userEntry('m1', 1), userEntry('m2', 2)], [pending('m1', 1), refused('m2')], 2)
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([[M2, 'failure']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a request that settles while the user is asked something', () => {
|
||||
const M1 = agentJournalSubmissionKey('m1')
|
||||
|
||||
/** An approval the user has not answered; `agentId` makes it a subagent's. */
|
||||
function approval(
|
||||
itemId: string,
|
||||
sequence: number,
|
||||
state: 'pending' | 'resolved',
|
||||
agentId?: string
|
||||
): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
revision: state === 'pending' ? 1 : 2,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
...(agentId ? { agentId } : {}),
|
||||
body: {
|
||||
kind: 'approval',
|
||||
title: 'Run command?',
|
||||
detail: null,
|
||||
options: [{ id: 'yes', label: 'Allow' }],
|
||||
resolution: { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('notifies once when the main turn settles while a subagent waits on an approval', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
const user = userEntry('m1', 1)
|
||||
const accepted = [sent('m1', { dispatchState: 'accepted' })]
|
||||
h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted)
|
||||
h.observe()
|
||||
h.setJournal(
|
||||
[user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending', 'child-1')],
|
||||
accepted
|
||||
)
|
||||
h.observe()
|
||||
h.setJournal(
|
||||
[
|
||||
user,
|
||||
turnItem(turn('t1', 'completed', 'success'), 2),
|
||||
approval('a1', 3, 'pending', 'child-1')
|
||||
],
|
||||
accepted
|
||||
)
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([['t1', 'success']])
|
||||
expect(h.awaitingUser()).toEqual([true])
|
||||
|
||||
// Answering the prompt settles the session idle on the request already announced.
|
||||
h.setJournal(
|
||||
[
|
||||
user,
|
||||
turnItem(turn('t1', 'completed', 'success'), 2),
|
||||
approval('a1', 3, 'resolved', 'child-1')
|
||||
],
|
||||
accepted
|
||||
)
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([['t1', 'success']])
|
||||
})
|
||||
|
||||
it('notifies a refused send once while a prompt is pending', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
const prompt = approval('a1', 1, 'pending', 'child-1')
|
||||
h.setJournal([prompt, userEntry('m1', 2)], [pending('m1')])
|
||||
h.observe()
|
||||
h.setJournal([prompt, userEntry('m1', 2)], [refused('m1')])
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([[M1, 'failure']])
|
||||
expect(h.awaitingUser()).toEqual([true])
|
||||
h.setJournal([approval('a1', 1, 'resolved', 'child-1'), userEntry('m1', 2)], [refused('m1')])
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([[M1, 'failure']])
|
||||
})
|
||||
|
||||
it('sends nothing while the main turn asks for permission, and one event when it settles', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
const user = userEntry('m1', 1)
|
||||
const accepted = [sent('m1', { dispatchState: 'accepted' })]
|
||||
h.setJournal([user, turnItem(turn('t1', 'running'), 2)], accepted)
|
||||
h.observe()
|
||||
h.setJournal([user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'pending')], accepted)
|
||||
h.observe()
|
||||
expect(h.events).toEqual([])
|
||||
h.setJournal(
|
||||
[user, turnItem(turn('t1', 'running'), 2), approval('a1', 3, 'resolved')],
|
||||
accepted
|
||||
)
|
||||
h.observe()
|
||||
h.setJournal(
|
||||
[user, turnItem(turn('t1', 'completed', 'success'), 2), approval('a1', 3, 'resolved')],
|
||||
accepted
|
||||
)
|
||||
h.observe()
|
||||
expect(h.outcomes()).toEqual([['t1', 'success']])
|
||||
// Idle when it settles: the prompt was already answered.
|
||||
expect(h.awaitingUser()).toEqual([false])
|
||||
})
|
||||
|
||||
it('still waits on a queued send the prompt hides, so the queue notifies once', () => {
|
||||
const h = harness()
|
||||
h.listen()
|
||||
const prompt = approval('a1', 3, 'pending', 'child-1')
|
||||
const items = [userEntry('m1', 1), turnItem(turn('t1', 'running'), 2), prompt]
|
||||
const queued = sent('m2', { dispatchState: 'pending', resolvedAt: null })
|
||||
const accepted = sent('m1', { dispatchState: 'accepted' })
|
||||
h.setJournal([...items, userEntry('m2', 4)], [accepted, queued])
|
||||
h.observe()
|
||||
h.setJournal(
|
||||
[
|
||||
userEntry('m1', 1),
|
||||
turnItem(turn('t1', 'completed', 'success'), 2),
|
||||
prompt,
|
||||
userEntry('m2', 4)
|
||||
],
|
||||
[accepted, queued]
|
||||
)
|
||||
h.observe()
|
||||
expect(h.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
+45
-36
@@ -1,4 +1,5 @@
|
||||
// The host's answer to "a turn just finished", derived once per journal commit.
|
||||
// The host's answer to "a request just finished", derived once per journal commit. A request is
|
||||
// the one the status row reports: a turn, or a send the agent or its start refused.
|
||||
//
|
||||
// WHY THE HOST DERIVES IT: a structured session runs on the execution host and keeps journalling
|
||||
// whether or not any renderer has a reader mounted. A client that derived completions itself would
|
||||
@@ -10,41 +11,45 @@
|
||||
// needs, a completion is an edge that has already passed. Keeping a queue would create a durable
|
||||
// obligation with nothing to retire it.
|
||||
|
||||
import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { readAgentJournalTurnOutcome } from '../../../shared/agent-session-turn-record'
|
||||
import type {
|
||||
AgentSessionTurnCompletion,
|
||||
AgentSessionTurnCompletionEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionLatestRequest } from '../../../shared/structured-agent-session-latest-request'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed'
|
||||
|
||||
export type StructuredAgentSessionTurnCompletionSubscriber = {
|
||||
id: string
|
||||
emit: (event: AgentSessionTurnCompletionEvent) => void
|
||||
}
|
||||
|
||||
/** Only the newest-turn reader and cursor are needed here; asking for the whole journal would overstate it. */
|
||||
type CompletionFeedCursor = { epoch: string; sequence: number }
|
||||
|
||||
type CompletionFeedJournal = Pick<AgentSessionJournal, 'newestTurn' | 'cursor'>
|
||||
|
||||
type CompletionFeedSession = {
|
||||
journal: CompletionFeedJournal
|
||||
journal: Pick<AgentSessionJournal, 'cursor'>
|
||||
params: { location: AgentSessionRecord['location'] }
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionTurnCompletionFeedDeps = {
|
||||
sessions: ReadonlyMap<string, CompletionFeedSession>
|
||||
now: () => number
|
||||
/** The status feed's projection for this commit, so the event follows the request its row reports. */
|
||||
readStatusState: (
|
||||
sessionId: string,
|
||||
journal?: AgentSessionJournal
|
||||
) => StructuredAgentSessionStatusState | null
|
||||
}
|
||||
|
||||
/** Per-session baseline. `settledTurnId` is the last settled turn this feed has accounted for;
|
||||
* absence of the whole entry — not a null field — is what makes the first observation silent. */
|
||||
type SessionBaseline = CompletionFeedCursor & { settledTurnId: string | null }
|
||||
type RequestMark = Pick<StructuredAgentSessionLatestRequest, 'kind' | 'id'>
|
||||
|
||||
function isSettled(turn: AgentJournalTurnLifecycle | null): turn is AgentJournalTurnLifecycle {
|
||||
return turn !== null && turn.state !== 'running'
|
||||
/** Per-session baseline. `settled` is the last settled request this feed has accounted for;
|
||||
* absence of the whole entry — not a null field — is what makes the first observation silent. */
|
||||
type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null }
|
||||
|
||||
function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null {
|
||||
return request && !request.running ? { kind: request.kind, id: request.id } : null
|
||||
}
|
||||
|
||||
export class StructuredAgentSessionTurnCompletionFeed {
|
||||
@@ -80,29 +85,24 @@ export class StructuredAgentSessionTurnCompletionFeed {
|
||||
|
||||
/**
|
||||
* One journal publication. Emits at most one completion, and only on the transition into a
|
||||
* settled turn this feed has not already accounted for.
|
||||
* settled request this feed has not already accounted for.
|
||||
*
|
||||
* The first observation of a session only records where it is, so restore, restart, rewind and
|
||||
* a re-read of history all pass through silently. An already-settled turn republished by an
|
||||
* in-place revision carries the same turn id and so cannot fire twice.
|
||||
* a re-read of history all pass through silently. An already-settled request republished by an
|
||||
* in-place revision carries the same identity and so cannot fire twice.
|
||||
*/
|
||||
observe(sessionId: string, journal?: CompletionFeedJournal): void {
|
||||
observe(sessionId: string, journal?: AgentSessionJournal): void {
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
const state = session ? this.deps.readStatusState(sessionId, journal) : null
|
||||
if (!session || !state) {
|
||||
return
|
||||
}
|
||||
const source = journal ?? session.journal
|
||||
const cursor = source.cursor()
|
||||
const turn = source.newestTurn()
|
||||
const settled = isSettled(turn) ? turn : null
|
||||
const cursor = (journal ?? session.journal).cursor()
|
||||
const request = state.latestRequest
|
||||
const baseline = this.baselines.get(sessionId)
|
||||
if (!baseline) {
|
||||
// Baseline only. Whatever the session was already holding is history, not news.
|
||||
this.baselines.set(sessionId, {
|
||||
epoch: cursor.epoch,
|
||||
sequence: cursor.sequence,
|
||||
settledTurnId: settled?.turnId ?? null
|
||||
})
|
||||
this.baselines.set(sessionId, { ...cursor, settled: settledMark(request) })
|
||||
return
|
||||
}
|
||||
if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) {
|
||||
@@ -111,24 +111,31 @@ export class StructuredAgentSessionTurnCompletionFeed {
|
||||
// newest settled row as a fresh completion.
|
||||
baseline.epoch = cursor.epoch
|
||||
baseline.sequence = cursor.sequence
|
||||
baseline.settledTurnId = settled?.turnId ?? null
|
||||
baseline.settled = settledMark(request)
|
||||
return
|
||||
}
|
||||
baseline.sequence = cursor.sequence
|
||||
if (!settled) {
|
||||
if (request?.running) {
|
||||
// A running turn clears the mark, so this detector fires on each running → settled
|
||||
// transition rather than on an id it happens not to have seen.
|
||||
baseline.settledTurnId = null
|
||||
baseline.settled = null
|
||||
return
|
||||
}
|
||||
if (baseline.settledTurnId === settled.turnId) {
|
||||
// Owed work waits, so sends refused one commit at a time announce once, when the last is
|
||||
// answered. A pending prompt does not wait (structured chat has no other attention producer):
|
||||
// the event says so itself, and answering it keeps the same identity.
|
||||
// A withdrawn send leaves the older request latest.
|
||||
if (
|
||||
state.owesWork ||
|
||||
!request ||
|
||||
(baseline.settled?.kind === request.kind && baseline.settled.id === request.id)
|
||||
) {
|
||||
return
|
||||
}
|
||||
baseline.settledTurnId = settled.turnId
|
||||
baseline.settled = settledMark(request)
|
||||
// ABSENT OUTCOME IS UNKNOWN: a turn the host only saw stop carries no verdict and gets no
|
||||
// event. Inferring success here is the one mistake that would light the dot on a failure.
|
||||
const outcome = readAgentJournalTurnOutcome(settled)
|
||||
if (!outcome) {
|
||||
if (!request.outcome) {
|
||||
return
|
||||
}
|
||||
this.broadcast({
|
||||
@@ -136,9 +143,11 @@ export class StructuredAgentSessionTurnCompletionFeed {
|
||||
completion: {
|
||||
scope: session.params.location,
|
||||
sessionId,
|
||||
turnId: settled.turnId,
|
||||
outcome,
|
||||
completedAt: this.deps.now()
|
||||
turnId: request.id,
|
||||
outcome: request.outcome,
|
||||
completedAt: this.deps.now(),
|
||||
// Stated here, not joined from the status stream: remote clients receive the two unordered.
|
||||
...(state.summary.status === 'attention' ? { awaitingUser: true } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
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 { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import type {
|
||||
AgentSessionStatusEvent,
|
||||
AgentSessionStatusSummary
|
||||
} from '../../shared/agent-session-wire'
|
||||
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import { AgentHookServer, _internals } from '../agent-hooks/server'
|
||||
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open'
|
||||
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
|
||||
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
||||
import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session'
|
||||
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
|
||||
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
|
||||
}))
|
||||
|
||||
// A request that failed reads as failed on every surface the host feeds: the journal's verdict
|
||||
// travels the real feed, the status-store ingest and `worktree ps`, never just the projection.
|
||||
const SESSION = 'verdict-session'
|
||||
const WORKSPACE_ID = 'workspace-1'
|
||||
const SUBJECT = makeStructuredAgentStatusSubject(
|
||||
{
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
SESSION
|
||||
)
|
||||
const TURN_IDENTITY = {
|
||||
provider: 'codex',
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
ordinal: 0
|
||||
} as const
|
||||
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
beforeEach(async () => {
|
||||
_internals.resetCachesForTests()
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-verdict-rows-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function openJournal(): Promise<AgentSessionJournal> {
|
||||
return journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
},
|
||||
journalDir: join(root, SESSION)
|
||||
})
|
||||
}
|
||||
|
||||
/** What the host's real feed publishes for this journal. */
|
||||
function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSummary {
|
||||
const session = indexedStatusFeedSession({ journal })
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
sessions: new Map([[SESSION, session]]),
|
||||
getRecord: () => agentSessionRecordFixture(),
|
||||
now: () => 1_000
|
||||
})
|
||||
const events: AgentSessionStatusEvent[] = []
|
||||
feed.subscribe({ id: 'list', emit: (event) => events.push(event) })
|
||||
const snapshot = events.find((event) => event.type === 'snapshot')
|
||||
const summary = snapshot?.type === 'snapshot' ? snapshot.sessions[0] : undefined
|
||||
if (!summary) {
|
||||
throw new Error('the feed published no session')
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
function ingest(summary: AgentSessionStatusSummary) {
|
||||
const store = new AgentHookServer()
|
||||
store.ingestStructuredStatus(summary, SUBJECT)
|
||||
const hookSnapshots = store.getStatusSnapshot()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attaching agent rows reads and writes only `worktreeId`, `status`, `hasHostSidebarActivity` and `agents`.
|
||||
const row = {
|
||||
worktreeId: WORKSPACE_ID,
|
||||
status: 'inactive',
|
||||
hasHostSidebarActivity: false,
|
||||
agents: []
|
||||
} as unknown as RuntimeWorktreePsSummary
|
||||
attachRuntimeWorktreeAgentRows({
|
||||
summaries: new Map([[WORKSPACE_ID, row]]),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `getSummary` below resolves every row by id, so the path index is never read.
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
|
||||
missingWorktreeIds: new Set(),
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId: new Map(),
|
||||
connectedPtyEvidence: {
|
||||
tabIds: new Set(),
|
||||
paneKeys: new Set(),
|
||||
ptyIdByTerminalHandle: new Map()
|
||||
},
|
||||
hookSnapshots
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map, _p, _m, id) => map.get(id) ?? null
|
||||
})
|
||||
return { status: hookSnapshots[0], ps: row.agents[0] }
|
||||
}
|
||||
|
||||
describe('a request that failed reads as failed through the feed, the ingest and worktree ps', () => {
|
||||
it('reads a chat whose only send the agent start refused as failed, not interrupted', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'first',
|
||||
payloadFingerprint: 'fp',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
fence: 1,
|
||||
handoverRecorded: true
|
||||
})
|
||||
await journal.rejectQueuedSubmissions(1, 'Claude is not signed in.')
|
||||
|
||||
const summary = publishedSummary(journal)
|
||||
expect(summary).toMatchObject({ status: 'idle', turnOutcome: 'failure', latestPrompt: 'hello' })
|
||||
const { status, ps } = ingest(summary)
|
||||
expect(status).toMatchObject({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure' }
|
||||
})
|
||||
expect(status?.interrupted).not.toBe(true)
|
||||
expect(ps).toMatchObject({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure' },
|
||||
interrupted: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a cancelled structured turn as interrupted for readers that predate the verdict', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendItem(
|
||||
TURN_IDENTITY,
|
||||
{
|
||||
kind: 'turn',
|
||||
turnId: 'turn-1',
|
||||
state: 'interrupted',
|
||||
outcome: 'cancellation',
|
||||
completedAt: 5
|
||||
},
|
||||
{ fence: 1 }
|
||||
)
|
||||
|
||||
const { status, ps } = ingest(publishedSummary(journal))
|
||||
expect(status).toMatchObject({ state: 'done', interrupted: true })
|
||||
expect(ps).toMatchObject({
|
||||
mainAgent: { state: 'done', outcome: 'cancellation' },
|
||||
interrupted: true
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes a main agent that failed while its subagent runs, on the row that still works', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendItem(
|
||||
TURN_IDENTITY,
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 },
|
||||
{ fence: 1 }
|
||||
)
|
||||
const summary: AgentSessionStatusSummary = {
|
||||
...publishedSummary(journal),
|
||||
backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }]
|
||||
}
|
||||
|
||||
const { status, ps } = ingest(summary)
|
||||
expect(status).toMatchObject({
|
||||
state: 'working',
|
||||
mainAgent: { state: 'done', outcome: 'failure' }
|
||||
})
|
||||
// The row carries the main agent's own clock, which dates the failure apart from the working row.
|
||||
expect(ps).toMatchObject({
|
||||
state: 'working',
|
||||
mainAgent: {
|
||||
state: 'done',
|
||||
outcome: 'failure',
|
||||
stateStartedAt: status?.mainAgent?.stateStartedAt
|
||||
},
|
||||
interrupted: false
|
||||
})
|
||||
})
|
||||
|
||||
it('lists nothing for a chat whose only send the user withdrew', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'first',
|
||||
payloadFingerprint: 'fp',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
|
||||
fence: 1,
|
||||
handoverRecorded: true
|
||||
})
|
||||
await journal.rejectQueuedSubmissions(1, 'provider_cancelled_before_start')
|
||||
|
||||
expect(publishedSummary(journal)).toMatchObject({ status: null })
|
||||
expect(ingest(publishedSummary(journal)).ps).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,7 @@ export function attachRuntimeWorktreeAgentRows(args: {
|
||||
toolName: source.toolName,
|
||||
toolInput: source.toolInput,
|
||||
interrupted: source.interrupted,
|
||||
...(source.mainAgent ? { mainAgent: source.mainAgent } : {}),
|
||||
stateStartedAt: source.stateStartedAt,
|
||||
updatedAt: source.updatedAt,
|
||||
...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event'
|
||||
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
import type { AgentMainAgentStatus } from '../../shared/main-agent-status'
|
||||
|
||||
export type RuntimeWorktreeAgentSource = {
|
||||
paneKey: string
|
||||
@@ -15,6 +16,7 @@ export type RuntimeWorktreeAgentSource = {
|
||||
toolName: string | null
|
||||
toolInput: string | null
|
||||
interrupted: boolean
|
||||
mainAgent?: AgentMainAgentStatus
|
||||
stateStartedAt: number
|
||||
updatedAt: number
|
||||
/** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ParsedAgentStatusPayload
|
||||
} from '../../shared/agent-status-types'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { agentVerdictFields } from '../../shared/agent-main-agent-verdict'
|
||||
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
|
||||
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
|
||||
|
||||
@@ -47,7 +48,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interrupted: entry.interrupted ?? false,
|
||||
interrupted: false,
|
||||
...agentVerdictFields(entry),
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
// A replay advances delivery order, not the age of the evidence shown by worktree.ps.
|
||||
updatedAt: entry.evidenceObservedAt ?? entry.receivedAt,
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
isClearableActivityThread,
|
||||
planClearCompletedActivity
|
||||
} from './activity-clear-completed'
|
||||
import { activityThreadStatusId } from './activity-thread-presentation'
|
||||
|
||||
function makeThread(paneKey: string, overrides: Partial<AgentPaneThread> = {}): AgentPaneThread {
|
||||
return {
|
||||
@@ -81,7 +82,7 @@ function makeThread(paneKey: string, overrides: Partial<AgentPaneThread> = {}):
|
||||
}
|
||||
}
|
||||
|
||||
function doneEvent(interrupted: boolean): ActivityEvent {
|
||||
function doneEvent(interrupted: boolean, outcome?: 'failure'): ActivityEvent {
|
||||
return {
|
||||
id: 'evt',
|
||||
state: 'done',
|
||||
@@ -89,7 +90,16 @@ function doneEvent(interrupted: boolean): ActivityEvent {
|
||||
observedAt: 5_000,
|
||||
worktree: makeWorktree(),
|
||||
repo: null,
|
||||
entry: { interrupted } as ActivityEvent['entry'],
|
||||
entry: {
|
||||
paneKey: 'evt-pane',
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: 5_000,
|
||||
stateStartedAt: 5_000,
|
||||
stateHistory: [],
|
||||
interrupted,
|
||||
...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 5_000 } } : {})
|
||||
},
|
||||
tab: makeTab(),
|
||||
agentType: 'claude',
|
||||
agentAlive: false,
|
||||
@@ -102,6 +112,7 @@ const blockedThread = makeThread('t-blocked:1', { currentAgentState: 'blocked' }
|
||||
const waitingThread = makeThread('t-waiting:1', { currentAgentState: 'waiting' })
|
||||
const doneThread = makeThread('t-done:1', { latestEvent: doneEvent(false) })
|
||||
const interruptedThread = makeThread('t-interrupted:1', { latestEvent: doneEvent(true) })
|
||||
const failedThread = makeThread('t-failed:1', { latestEvent: doneEvent(false, 'failure') })
|
||||
|
||||
function makeRetained(paneKey: string): RetainedAgentEntry {
|
||||
return {
|
||||
@@ -125,10 +136,34 @@ describe('isClearableActivityThread', () => {
|
||||
it('clears only completed and interrupted threads', () => {
|
||||
expect(isClearableActivityThread(doneThread)).toBe(true)
|
||||
expect(isClearableActivityThread(interruptedThread)).toBe(true)
|
||||
expect(activityThreadStatusId(failedThread)).toBe('failed')
|
||||
expect(isClearableActivityThread(failedThread)).toBe(true)
|
||||
expect(isClearableActivityThread(workingThread)).toBe(false)
|
||||
expect(isClearableActivityThread(blockedThread)).toBe(false)
|
||||
expect(isClearableActivityThread(waitingThread)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a live thread whose main agent failed as failed, but keeps it while subagents run', () => {
|
||||
const heldEntry = {
|
||||
...doneEvent(false).entry,
|
||||
state: 'working' as const,
|
||||
mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5_000 }
|
||||
}
|
||||
const held = makeThread('t-held:1', {
|
||||
currentAgentState: 'working',
|
||||
currentAgentEntry: heldEntry
|
||||
})
|
||||
expect(activityThreadStatusId(held)).toBe('failed')
|
||||
expect(isClearableActivityThread(held)).toBe(false)
|
||||
const succeeded = makeThread('t-ok:1', {
|
||||
currentAgentState: 'working',
|
||||
currentAgentEntry: {
|
||||
...heldEntry,
|
||||
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 5_000 }
|
||||
}
|
||||
})
|
||||
expect(activityThreadStatusId(succeeded)).toBe('working')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearCompletedActivity', () => {
|
||||
|
||||
@@ -18,11 +18,15 @@ export type ClearCompletedActivityPlan = {
|
||||
clearedThreadCount: number
|
||||
}
|
||||
|
||||
/** A thread is clearable when it needs nothing from the user: completed or interrupted,
|
||||
/** A thread is clearable when it needs nothing from the user: completed, failed or interrupted,
|
||||
* with no fresh live working/monitoring/blocked/waiting state. */
|
||||
export function isClearableActivityThread(thread: AgentPaneThread): boolean {
|
||||
const id = activityThreadStatusId(thread)
|
||||
return id === 'done' || id === 'interrupted'
|
||||
// Why: a failed main agent reads failed while its subagents still run; that thread is still live.
|
||||
if (thread.currentAgentState) {
|
||||
return false
|
||||
}
|
||||
return id === 'done' || id === 'failed' || id === 'interrupted'
|
||||
}
|
||||
|
||||
export function planClearCompletedActivity(
|
||||
|
||||
@@ -27,7 +27,9 @@ function historyEntrySnapshot(
|
||||
toolName: undefined,
|
||||
toolInput: undefined,
|
||||
lastAssistantMessage: undefined,
|
||||
interrupted: history.interrupted
|
||||
interrupted: history.interrupted,
|
||||
// The live row's main agent belongs to its current state, not to this snapshot.
|
||||
mainAgent: history.mainAgent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ const ACTIVITY_STATUS_GROUP_RANK: Record<ActivityThreadStatusId, number> = {
|
||||
waiting: 0,
|
||||
blocked: 1,
|
||||
permission: 2,
|
||||
interrupted: 3,
|
||||
working: 4,
|
||||
monitoring: 5,
|
||||
unverifiable: 6,
|
||||
failed: 7,
|
||||
failed: 3,
|
||||
interrupted: 4,
|
||||
working: 5,
|
||||
monitoring: 6,
|
||||
unverifiable: 7,
|
||||
done: 8,
|
||||
idle: 9
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { AgentDotState } from '@/components/AgentStateDot'
|
||||
import { formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
|
||||
import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview'
|
||||
import {
|
||||
agentMainAgentVerdict,
|
||||
agentVerdictDisplayMark
|
||||
} from '../../../../shared/agent-main-agent-verdict'
|
||||
import {
|
||||
getActivityThreadTaskTitle,
|
||||
getActivityThreadWorkspaceTitle,
|
||||
@@ -68,7 +72,12 @@ export function agentTitle(event: ActivityEvent): string {
|
||||
return 'Agent working'
|
||||
}
|
||||
if (event.state === 'done') {
|
||||
return event.entry.interrupted ? 'Agent interrupted' : 'Agent finished'
|
||||
const verdict = agentMainAgentVerdict(event.entry)
|
||||
return verdict === 'failure'
|
||||
? 'Agent failed'
|
||||
: verdict === 'cancellation'
|
||||
? 'Agent interrupted'
|
||||
: 'Agent finished'
|
||||
}
|
||||
return event.state === 'waiting' ? 'Agent waiting for input' : 'Agent needs input'
|
||||
}
|
||||
@@ -91,7 +100,12 @@ export function agentMeta(event: ActivityEvent): string {
|
||||
return `${agent} ${event.state}`
|
||||
}
|
||||
if (event.state === 'done') {
|
||||
return event.entry.interrupted ? `${agent} interrupted` : `${agent} completed`
|
||||
const verdict = agentMainAgentVerdict(event.entry)
|
||||
return verdict === 'failure'
|
||||
? `${agent} failed`
|
||||
: verdict === 'cancellation'
|
||||
? `${agent} interrupted`
|
||||
: `${agent} completed`
|
||||
}
|
||||
return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked`
|
||||
}
|
||||
@@ -120,13 +134,18 @@ export function statusPreviewForEntry(
|
||||
export type ActivityThreadStatusId = AgentDotState
|
||||
|
||||
/** Single classifier behind grouping, labels, and clear-completed; the only place the
|
||||
* interrupted predicate is spelled. */
|
||||
* verdict predicate is spelled. */
|
||||
export function activityThreadStatusId(thread: AgentPaneThread): ActivityThreadStatusId {
|
||||
// Why: a failed main agent outranks the subagent work still holding its row live.
|
||||
if (thread.currentAgentEntry && agentVerdictDisplayMark(thread.currentAgentEntry) === 'failed') {
|
||||
return 'failed'
|
||||
}
|
||||
const paneEntry = paneActivityEntry(thread)
|
||||
const state = threadCurrentState(thread) ?? 'done'
|
||||
const interrupted = paneEntry ? paneEntry.interrupted : thread.latestEvent?.entry.interrupted
|
||||
if (!thread.currentAgentState && state === 'done' && interrupted) {
|
||||
return 'interrupted'
|
||||
const verdictEntry = paneEntry ?? thread.latestEvent?.entry
|
||||
const verdictDot = verdictEntry ? agentVerdictDisplayMark(verdictEntry) : null
|
||||
if (!thread.currentAgentState && state === 'done' && verdictDot) {
|
||||
return verdictDot
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -149,7 +168,7 @@ function threadCurrentState(
|
||||
)
|
||||
}
|
||||
|
||||
// Interrupted rows deliberately keep the done glyph (#2569).
|
||||
// Interrupted rows deliberately keep the done glyph (#2569); a failure is a fault and does not.
|
||||
export function threadAgentState(thread: AgentPaneThread): AgentDotState {
|
||||
const id = activityThreadStatusId(thread)
|
||||
return id === 'interrupted' ? 'done' : id
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
|
||||
import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview'
|
||||
import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state'
|
||||
import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state'
|
||||
import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
|
||||
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
|
||||
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
|
||||
@@ -29,7 +30,7 @@ function stateDotTooltipLabel(
|
||||
dotState: AgentDotState,
|
||||
now: number
|
||||
): string {
|
||||
if (agent.entry.interrupted === true) {
|
||||
if (dotState === 'interrupted') {
|
||||
return 'Interrupted by user'
|
||||
}
|
||||
// Why: report the observation, not a verdict on the agent — the elapsed gap is what
|
||||
@@ -141,7 +142,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : ''
|
||||
const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : ''
|
||||
const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
|
||||
const isInterrupted = agent.entry.interrupted === true
|
||||
const verdictDotState = agentVerdictDisplayMark(agent.entry)
|
||||
const isInterrupted = verdictDotState === 'interrupted'
|
||||
const lineage = agent.lineage
|
||||
const isLineageChild = lineage?.depth === 1
|
||||
const lineageChildCount = lineage?.childCount ?? 0
|
||||
@@ -152,10 +154,10 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
lineageChildCount === 1 ? 'agent' : 'agents'
|
||||
}`
|
||||
: [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ')
|
||||
// Why: interrupted is a terminal outcome, so surface it in the leading state dot.
|
||||
const dotState: AgentDotState = isInterrupted
|
||||
? 'interrupted'
|
||||
: asDotState(agent.state, agent.entry.workingMode)
|
||||
// Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a
|
||||
// failure does so even while subagents still run.
|
||||
const dotState: AgentDotState =
|
||||
verdictDotState ?? asDotState(agent.state, agent.entry.workingMode)
|
||||
const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now)
|
||||
// Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the
|
||||
// row's own timestamp slot rather than hiding in a hover tooltip.
|
||||
|
||||
@@ -51,6 +51,47 @@ describe('lastEnteredDoneAt shares the Smart Sort completion clock', () => {
|
||||
expect(lastEnteredDoneAt(row(entry))).toBe(2_000)
|
||||
expect(agentEntryCompletionAt(entry)).toBeNull()
|
||||
})
|
||||
|
||||
it('dates a failed turn as a completion, and a stopped one only for display', () => {
|
||||
const verdictDone = (outcome: 'failure' | 'cancellation') =>
|
||||
doneEntry({
|
||||
stateStartedAt: 2_000,
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: 2_000 }
|
||||
})
|
||||
expect(agentEntryCompletionAt(verdictDone('failure'))).toBe(2_000)
|
||||
expect(lastEnteredDoneAt(row(verdictDone('failure')))).toBe(2_000)
|
||||
expect(agentEntryCompletionAt(verdictDone('cancellation'))).toBeNull()
|
||||
expect(lastEnteredDoneAt(row(verdictDone('cancellation')))).toBe(2_000)
|
||||
})
|
||||
|
||||
it('dates a main agent that failed while its subagents run by when it failed', () => {
|
||||
const held = (outcome: 'failure' | 'cancellation') =>
|
||||
doneEntry({
|
||||
state: 'working',
|
||||
stateStartedAt: 3_000,
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: 2_500 }
|
||||
})
|
||||
expect(agentEntryCompletionAt(held('failure'))).toBeNull()
|
||||
expect(lastEnteredDoneAt(row(held('failure')))).toBe(2_500)
|
||||
expect(lastEnteredDoneAt(row(held('cancellation')))).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the verdict history carries when a boundary displaced the completion', () => {
|
||||
const history = { state: 'done' as const, prompt: '', startedAt: 1_500 }
|
||||
const boundary = (outcome?: 'failure' | 'cancellation') =>
|
||||
doneEntry({
|
||||
sessionBoundary: true,
|
||||
stateHistory: [
|
||||
{
|
||||
...history,
|
||||
...(outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: 1_500 } } : {})
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(agentEntryCompletionAt(boundary())).toBe(1_500)
|
||||
expect(agentEntryCompletionAt(boundary('failure'))).toBe(1_500)
|
||||
expect(agentEntryCompletionAt(boundary('cancellation'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastEnteredDoneAt subagent rows', () => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
|
||||
import {
|
||||
agentTurnStoppedByUser,
|
||||
agentVerdictDisplayMark
|
||||
} from '../../../../shared/agent-main-agent-verdict'
|
||||
import type { DashboardAgentRow } from './useDashboardData'
|
||||
|
||||
/**
|
||||
@@ -22,10 +26,14 @@ export function lastEnteredDoneAt(
|
||||
if (completedAt !== null) {
|
||||
return completedAt
|
||||
}
|
||||
// Why: display is looser than ranking — an interrupted turn still shows when it stopped.
|
||||
if (entry.state === 'done' && entry.interrupted === true && entry.sessionBoundary !== true) {
|
||||
// Why: display is looser than ranking — a stopped turn still shows when it ended.
|
||||
if (entry.state === 'done' && agentTurnStoppedByUser(entry) && entry.sessionBoundary !== true) {
|
||||
return entry.stateStartedAt
|
||||
}
|
||||
// Why: a failed main agent reads failed while its subagents run, so it shows when it failed.
|
||||
if (entry.state !== 'done' && entry.mainAgent && agentVerdictDisplayMark(entry) === 'failed') {
|
||||
return entry.mainAgent.stateStartedAt
|
||||
}
|
||||
for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) {
|
||||
if (entry.stateHistory[i].state === 'done') {
|
||||
return entry.stateHistory[i].startedAt
|
||||
|
||||
@@ -68,7 +68,12 @@ function makeTab(overrides: Partial<TerminalTab> & { id: string }): TerminalTab
|
||||
}
|
||||
}
|
||||
|
||||
function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrupted?: boolean }) {
|
||||
function makeAgentRow(args: {
|
||||
paneKey: string
|
||||
state: AgentStatusState
|
||||
interrupted?: boolean
|
||||
mainAgent?: AgentStatusEntry['mainAgent']
|
||||
}) {
|
||||
const entry: AgentStatusEntry = {
|
||||
state: args.state,
|
||||
prompt: 'Fix it',
|
||||
@@ -78,7 +83,8 @@ function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrup
|
||||
terminalTitle: 'Claude',
|
||||
stateHistory: [],
|
||||
agentType: 'claude',
|
||||
interrupted: args.interrupted
|
||||
interrupted: args.interrupted,
|
||||
mainAgent: args.mainAgent
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -134,6 +140,34 @@ describe('collectRetainedAgentsOnDisappear', () => {
|
||||
expect(result.toRetain).toEqual([])
|
||||
})
|
||||
|
||||
it('retains a failed done row so the failure stays visible, but not a cancelled one', () => {
|
||||
const retainedFor = (outcome: 'failure' | 'cancellation') =>
|
||||
collectRetainedAgentsOnDisappear({
|
||||
previousAgents: new Map([
|
||||
[
|
||||
'tab-1:1',
|
||||
{
|
||||
row: makeAgentRow({
|
||||
paneKey: 'tab-1:1',
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: 100 }
|
||||
}),
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
]
|
||||
]),
|
||||
currentAgents: new Map(),
|
||||
retainedAgentsByPaneKey: {},
|
||||
retentionSuppressedPaneKeys: {},
|
||||
recentlyClosedAgentStatusTabIds: {},
|
||||
recentlyRetiredAgentStatusPaneKeys: {}
|
||||
}).toRetain
|
||||
|
||||
expect(retainedFor('failure')).toHaveLength(1)
|
||||
expect(retainedFor('failure')[0]?.entry.mainAgent?.outcome).toBe('failure')
|
||||
expect(retainedFor('cancellation')).toEqual([])
|
||||
})
|
||||
|
||||
it('refreshes the retained snapshot when a reused paneKey starts a newer run', () => {
|
||||
// Why: a reused paneKey (same tab+pane, fresh agent start after a prior
|
||||
// retained run) produces a newer startedAt. Without the freshness check
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
import {
|
||||
createWorktreeTabBucketProjection,
|
||||
@@ -299,13 +300,12 @@ export function collectRetainedAgentsOnDisappear(args: {
|
||||
if (args.recentlyClosedAgentStatusTabIds[ownerTabId]) {
|
||||
continue
|
||||
}
|
||||
// Why: only keep a sticky snapshot when the agent finished cleanly
|
||||
// (state === 'done' and not interrupted). Explicit teardown paths mark
|
||||
// Why: only keep a sticky snapshot when the agent finished and the user did not
|
||||
// stop it; a failure is kept so it stays visible. Explicit teardown paths mark
|
||||
// pane keys as suppression candidates, so a close/quit/crash cannot
|
||||
// resurrect a stale `done` row on the next sync.
|
||||
const lastState = prev.row.state
|
||||
const wasInterrupted = prev.row.entry.interrupted === true
|
||||
if (lastState !== 'done' || wasInterrupted) {
|
||||
if (lastState !== 'done' || agentTurnStoppedByUser(prev.row.entry)) {
|
||||
continue
|
||||
}
|
||||
toRetain.push({
|
||||
|
||||
+66
-5
@@ -10,6 +10,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { AgentJournalTurnOutcome } from '../../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionStatusEvent,
|
||||
AgentSessionTurnCompletion,
|
||||
AgentSessionTurnCompletionEvent
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
@@ -27,7 +28,9 @@ type TestStore = {
|
||||
type BridgeMocks = {
|
||||
store: TestStore | null
|
||||
emitters: ((event: AgentSessionTurnCompletionEvent) => void)[]
|
||||
statusEmitters: ((event: AgentSessionStatusEvent) => void)[]
|
||||
subscribeCompletions: Mock
|
||||
subscribeStatus: Mock
|
||||
supportsCapability: Mock
|
||||
unsubscribe: Mock
|
||||
}
|
||||
@@ -35,7 +38,9 @@ type BridgeMocks = {
|
||||
const mocks = vi.hoisted<BridgeMocks>(() => ({
|
||||
store: null,
|
||||
emitters: [],
|
||||
statusEmitters: [],
|
||||
subscribeCompletions: vi.fn(),
|
||||
subscribeStatus: vi.fn(),
|
||||
supportsCapability: vi.fn(),
|
||||
unsubscribe: vi.fn()
|
||||
}))
|
||||
@@ -58,11 +63,16 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions
|
||||
subscribeStructuredAgentSessionTurnCompletions: mocks.subscribeCompletions,
|
||||
subscribeStructuredAgentSessionStatus: mocks.subscribeStatus
|
||||
}))
|
||||
|
||||
import { StructuredAgentSessionAttentionBridge } from './StructuredAgentSessionAttentionBridge'
|
||||
import { resetStructuredAgentSessionTurnCompletionFeedsForTests } from '@/runtime/structured-agent-session-turn-completion-feed'
|
||||
import {
|
||||
getStructuredAgentSessionStatusFeed,
|
||||
resetStructuredAgentSessionStatusFeedsForTests
|
||||
} from '@/runtime/structured-agent-session-status-feed'
|
||||
import {
|
||||
makeTabGroup,
|
||||
makeUnifiedTab,
|
||||
@@ -176,7 +186,15 @@ describe('StructuredAgentSessionAttentionBridge', () => {
|
||||
}
|
||||
})
|
||||
resetStructuredAgentSessionTurnCompletionFeedsForTests()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
mocks.emitters.length = 0
|
||||
mocks.statusEmitters.length = 0
|
||||
mocks.subscribeStatus.mockImplementation(
|
||||
(_target: unknown, emit: (event: AgentSessionStatusEvent) => void) => {
|
||||
mocks.statusEmitters.push(emit)
|
||||
return Promise.resolve({ unsubscribe: vi.fn() })
|
||||
}
|
||||
)
|
||||
mocks.subscribeCompletions.mockImplementation(
|
||||
(_target: unknown, emit: (event: AgentSessionTurnCompletionEvent) => void) => {
|
||||
mocks.emitters.push(emit)
|
||||
@@ -215,6 +233,7 @@ describe('StructuredAgentSessionAttentionBridge', () => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
resetStructuredAgentSessionTurnCompletionFeedsForTests()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
})
|
||||
|
||||
it('lights the unread indicators when the host reports a successful turn', async () => {
|
||||
@@ -235,14 +254,14 @@ describe('StructuredAgentSessionAttentionBridge', () => {
|
||||
worktreeId: WORKSPACE,
|
||||
paneKey: CHAT_SUBJECT,
|
||||
agentState: 'done',
|
||||
agentInterrupted: false
|
||||
agentTurnOutcome: 'success'
|
||||
})
|
||||
})
|
||||
|
||||
// A settled turn is news whichever way it settled, exactly as the CLI lane treats one. The
|
||||
// difference is wording, and it rides the notification flag that already says "stopped".
|
||||
// difference is wording, which main picks from the verdict.
|
||||
it.each(['failure', 'cancellation'] as const)(
|
||||
'lights the indicators and says stopped for a %s the host reports',
|
||||
'lights the indicators and hands main the %s the host reports',
|
||||
async (outcome) => {
|
||||
render(<StructuredAgentSessionAttentionBridge />)
|
||||
await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce())
|
||||
@@ -254,7 +273,7 @@ describe('StructuredAgentSessionAttentionBridge', () => {
|
||||
paneDot: 'agent-completion',
|
||||
tabDot: 'agent-completion'
|
||||
})
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true })
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -299,6 +318,48 @@ describe('StructuredAgentSessionAttentionBridge', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Remote clients receive the status and completion streams over separate sockets, unordered, so
|
||||
// the wording must come from the completion alone. The mirror is set to disagree in each case.
|
||||
function mirrorStatus(status: 'idle' | 'attention'): void {
|
||||
mocks.statusEmitters[0]?.({
|
||||
type: 'status',
|
||||
session: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'host-side-workspace',
|
||||
agent: 'claude',
|
||||
status,
|
||||
latestPrompt: 'Ship it',
|
||||
updatedAt: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ awaitingUser: true, mirror: 'idle', agentState: 'blocked' },
|
||||
{ awaitingUser: undefined, mirror: 'attention', agentState: 'done' }
|
||||
] as const)(
|
||||
'words awaitingUser=$awaitingUser as $agentState whatever the status mirror says ($mirror)',
|
||||
async ({ awaitingUser, mirror, agentState }) => {
|
||||
const stopStatus = getStructuredAgentSessionStatusFeed({ kind: 'local' }).activate()
|
||||
render(<StructuredAgentSessionAttentionBridge />)
|
||||
await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
|
||||
const completion = turnCompletion()
|
||||
|
||||
act(() => {
|
||||
mirrorStatus(mirror)
|
||||
hostStream()({
|
||||
type: 'completion',
|
||||
completion: awaitingUser ? { ...completion, awaitingUser } : completion
|
||||
})
|
||||
})
|
||||
|
||||
expect(indicators().paneDot).toBe('agent-completion')
|
||||
expect(onlyDispatch()).toMatchObject({ agentState, agentTurnOutcome: 'success' })
|
||||
stopStatus()
|
||||
}
|
||||
)
|
||||
|
||||
it('lights nothing for a turn whose outcome the host never stated', async () => {
|
||||
render(<StructuredAgentSessionAttentionBridge />)
|
||||
await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce())
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from '../../../../shared/agent-status-child-work-projection'
|
||||
import {
|
||||
continueMainAgentStatus,
|
||||
isAgentStatusHeldOpenByChildWork
|
||||
isAgentStatusHeldOpenByChildWork,
|
||||
mainAgentTurnInterrupted
|
||||
} from '../../../../shared/agent-lead-status-fold'
|
||||
import { mainAgentStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
@@ -98,6 +99,8 @@ function projectStatus(
|
||||
state: agentStatus.state,
|
||||
...(agentStatus.workingMode ? { workingMode: agentStatus.workingMode } : {}),
|
||||
mainAgent,
|
||||
// Derived from `mainAgent`, so the equality below needs no second check of it.
|
||||
interrupted: mainAgentTurnInterrupted(mainAgent),
|
||||
prompt: summary.latestPrompt,
|
||||
agentType: tab.agentSessionAgent,
|
||||
// The host projects these from the journal so the row reads like a hook-reported one:
|
||||
|
||||
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type { AgentSessionTurnCompletion } from '../../../../shared/agent-session-wire'
|
||||
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
import {
|
||||
createTestStore,
|
||||
@@ -230,7 +231,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => {
|
||||
expect(indicators().paneDot).toBe('agent-completion')
|
||||
})
|
||||
|
||||
it('words a successful turn as finished and a stopped one through the shipped interrupted flag', () => {
|
||||
it('hands main the host verdict, which picks finished, failed or stopped', () => {
|
||||
dispatchStructuredTurnCompletionAttention(structuredTab(), completion())
|
||||
// 'done' is the host's report that the turn settled, not a reading of the status row: main
|
||||
// words a 'working' state as "working", which would announce a finished turn as unfinished.
|
||||
@@ -238,16 +239,39 @@ describe('dispatchStructuredTurnCompletionAttention', () => {
|
||||
source: 'agent-task-complete',
|
||||
surface: 'agent-session',
|
||||
agentState: 'done',
|
||||
agentInterrupted: false
|
||||
agentTurnOutcome: 'success'
|
||||
})
|
||||
|
||||
for (const [outcome, turnId] of [
|
||||
['cancellation', 'turn-2'],
|
||||
['failure', 'turn-3']
|
||||
] as const) {
|
||||
dispatched.length = 0
|
||||
seed()
|
||||
dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ outcome, turnId }))
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: outcome })
|
||||
}
|
||||
})
|
||||
|
||||
it('calls back a send refused before any turn, named by its journal item key, as failed', () => {
|
||||
dispatchStructuredTurnCompletionAttention(
|
||||
structuredTab(),
|
||||
completion({ outcome: 'failure', turnId: agentJournalSubmissionKey('m1') })
|
||||
)
|
||||
expect(indicators().paneDot).toBe('agent-completion')
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'failure' })
|
||||
})
|
||||
|
||||
it('asks for input when the host settled a request while a prompt waits on the user', () => {
|
||||
// e.g. a subagent's approval is unanswered.
|
||||
dispatchStructuredTurnCompletionAttention(structuredTab(), completion({ awaitingUser: true }))
|
||||
expect(indicators().paneDot).toBe('agent-completion')
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'blocked', agentTurnOutcome: 'success' })
|
||||
|
||||
dispatched.length = 0
|
||||
seed()
|
||||
dispatchStructuredTurnCompletionAttention(
|
||||
structuredTab(),
|
||||
completion({ outcome: 'cancellation', turnId: 'turn-2' })
|
||||
)
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: true })
|
||||
dispatchStructuredTurnCompletionAttention(structuredTab(), completion())
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done' })
|
||||
})
|
||||
|
||||
it('says done even while the status row still reads working, because the host settled the turn', () => {
|
||||
@@ -270,7 +294,7 @@ describe('dispatchStructuredTurnCompletionAttention', () => {
|
||||
}
|
||||
})
|
||||
dispatchStructuredTurnCompletionAttention(structuredTab(), completion())
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentInterrupted: false })
|
||||
expect(onlyDispatch()).toMatchObject({ agentState: 'done', agentTurnOutcome: 'success' })
|
||||
})
|
||||
|
||||
it('delivers an id the acknowledgement round trip dismisses when the user reads the chat', () => {
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
* adapter, and the same delivery tail — so suppression, acknowledgement, addressing, the success
|
||||
* sound and the blocked-permission fallback all have exactly one implementation.
|
||||
*
|
||||
* EVERY SETTLED TURN NOTIFIES, matching the CLI lane: success says "finished", and failure and
|
||||
* cancellation say "stopped" through the shipped `agentInterrupted` flag rather than a second
|
||||
* vocabulary. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing
|
||||
* here may turn that absence into success.
|
||||
* EVERY SETTLED TURN NOTIFIES, matching the CLI lane: the outcome picks the wording — "finished",
|
||||
* "failed" or "stopped" — exactly as the hook lane's verdict does. A turn with no outcome is UNKNOWN — the host sends no event for one, and nothing
|
||||
* here may turn that absence into success. A request that settles while a prompt (a subagent's
|
||||
* approval, say) waits on the user is worded "needs input" instead, as the hook lane words a
|
||||
* blocked row.
|
||||
*
|
||||
* Unread and delivery come out of ONE `resolveAgentAttention` decision. "Do not alert me about
|
||||
* something I am watching" is already answered by focus, in the surface adapter's viewed gates and
|
||||
@@ -107,10 +108,10 @@ export function dispatchStructuredTurnCompletionAttention(
|
||||
...(row?.agentType ? { agentType: row.agentType } : {}),
|
||||
// 'done' is what the host told us, not an inference from the row — the row's own state
|
||||
// can still read 'working' when the completion outruns the status re-projection, and
|
||||
// main words a 'working' notification as "working". The outcome picks the wording from
|
||||
// there: interrupted covers failure and cancellation alike.
|
||||
agentState: 'done',
|
||||
agentInterrupted: completion.outcome !== 'success',
|
||||
// main words a 'working' notification as "working". The outcome picks the wording from there.
|
||||
// `awaitingUser` is the row's 'blocked': the user has a prompt to answer.
|
||||
agentState: completion.awaitingUser ? 'blocked' : 'done',
|
||||
agentTurnOutcome: completion.outcome,
|
||||
...(row?.prompt ? { agentPrompt: row.prompt } : {}),
|
||||
...(row?.lastAssistantMessage
|
||||
? { agentLastAssistantMessage: row.lastAssistantMessage }
|
||||
|
||||
@@ -25,6 +25,7 @@ const AGENT_STATUS_TOOLTIP_STATUSES = new Set<Status>([
|
||||
'working',
|
||||
'monitoring',
|
||||
'permission',
|
||||
'failed',
|
||||
'interrupted',
|
||||
'done'
|
||||
])
|
||||
@@ -58,7 +59,7 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
||||
<Activity className="size-3 text-yellow-500" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
} else if (status === 'interrupted') {
|
||||
} else if (status === 'failed' || status === 'interrupted') {
|
||||
indicator = (
|
||||
<span
|
||||
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
|
||||
|
||||
@@ -102,6 +102,27 @@ describe('mostRecentAttentionInHistory', () => {
|
||||
).toBe(NOW - 4_000)
|
||||
})
|
||||
|
||||
it('counts a failed history done as attention and skips a cancelled one', () => {
|
||||
expect(
|
||||
mostRecentAttentionInHistory([
|
||||
makeHistory('done', NOW - 4_000),
|
||||
{
|
||||
...makeHistory('done', NOW - 1_000),
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 1_000 }
|
||||
}
|
||||
])
|
||||
).toBe(NOW - 1_000)
|
||||
expect(
|
||||
mostRecentAttentionInHistory([
|
||||
makeHistory('done', NOW - 4_000),
|
||||
{
|
||||
...makeHistory('done', NOW - 1_000),
|
||||
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: NOW - 1_000 }
|
||||
}
|
||||
])
|
||||
).toBe(NOW - 4_000)
|
||||
})
|
||||
|
||||
it('returns null when only interrupted dones exist', () => {
|
||||
expect(mostRecentAttentionInHistory([makeHistory('done', NOW - 1_000, true)])).toBeNull()
|
||||
})
|
||||
@@ -181,6 +202,31 @@ describe('resolveAttention', () => {
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('ranks a failed done in Class 2 at its end time, like a completion', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 90_000 },
|
||||
stateStartedAt: NOW - 90_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual({
|
||||
cls: 2,
|
||||
attentionTimestamp: NOW - 90_000
|
||||
})
|
||||
})
|
||||
|
||||
it('still demotes a done the user stopped, recorded as a cancellation verdict', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: 't:1',
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: NOW - 90_000 },
|
||||
stateStartedAt: NOW - 90_000,
|
||||
updatedAt: NOW - 30_000
|
||||
})
|
||||
expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE)
|
||||
})
|
||||
|
||||
it('treats a session boundary as idle unless it displaced a real completion', () => {
|
||||
const boundary = makeEntry({
|
||||
paneKey: 't:1',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
|
||||
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
|
||||
import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
|
||||
import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
@@ -85,18 +86,15 @@ export function hasFreshAttributedAgentStatus(
|
||||
|
||||
/**
|
||||
* Return the timestamp of the most recent `done`/`blocked`/`waiting` history row, ignoring
|
||||
* interrupted `done` rows (Ctrl+C). Returns `null` when no qualifying row exists.
|
||||
* `done` rows the user stopped (Ctrl+C). Returns `null` when no qualifying row exists.
|
||||
*/
|
||||
export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): number | null {
|
||||
let max = 0
|
||||
for (const h of history) {
|
||||
// Why: setAgentStatus preserves `interrupted` on history rows, so filter them like the current entry.
|
||||
if (h.state === 'done' && h.interrupted) {
|
||||
continue
|
||||
}
|
||||
// Why: history rows keep the verdict, so filter a stopped turn like the current entry.
|
||||
if (h.state === 'done' || h.state === 'blocked' || h.state === 'waiting') {
|
||||
// Why: Infinity from a corrupted row would pin the worktree atop Class 3 forever; treat non-finite as missing.
|
||||
if (!Number.isFinite(h.startedAt)) {
|
||||
if (agentTurnStoppedByUser(h) || !Number.isFinite(h.startedAt)) {
|
||||
continue
|
||||
}
|
||||
if (h.startedAt > max) {
|
||||
@@ -163,8 +161,8 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
|
||||
ts = entry.stateStartedAt
|
||||
cause = entry.state
|
||||
} else if (entry.state === 'done') {
|
||||
// Why: null covers interrupted `done` (Ctrl+C — user is finished with it) and idle session
|
||||
// boundaries; neither is attention.
|
||||
// Why: null covers a stopped `done` (not a completion) and idle session boundaries;
|
||||
// neither is attention. A failed `done` ranks here: it is news the user has not seen.
|
||||
const completedAt = agentEntryCompletionAt(entry)
|
||||
if (completedAt === null) {
|
||||
continue
|
||||
|
||||
@@ -85,7 +85,8 @@ function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }):
|
||||
tabId: overrides.tabId,
|
||||
terminalTitle: overrides.terminalTitle,
|
||||
stateHistory: overrides.stateHistory ?? [],
|
||||
interrupted: overrides.interrupted
|
||||
interrupted: overrides.interrupted,
|
||||
mainAgent: overrides.mainAgent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +335,31 @@ describe('smart sort — interrupted and stale handling', () => {
|
||||
expect(sorted.map((w) => w.id)).toEqual(['real-done', 'interrupted'])
|
||||
})
|
||||
|
||||
it('ranks a failed turn above an idle worktree and a cancelled one below it', () => {
|
||||
const idle = makeWorktree({ id: 'idle', displayName: 'Idle', lastActivityAt: NOW - 10_000 })
|
||||
const failed = makeWorktree({ id: 'failed', displayName: 'Failed', lastActivityAt: 0 })
|
||||
const stopped = makeWorktree({ id: 'stopped', displayName: 'Stopped', lastActivityAt: 0 })
|
||||
const tabs = {
|
||||
[idle.id]: [makeTab({ id: 'tab-idle', worktreeId: idle.id })],
|
||||
[failed.id]: [makeTab({ id: 'tab-failed', worktreeId: failed.id })],
|
||||
[stopped.id]: [makeTab({ id: 'tab-stopped', worktreeId: stopped.id })]
|
||||
}
|
||||
const doneWith = (tabId: string, outcome: 'failure' | 'cancellation') =>
|
||||
makeEntry({
|
||||
paneKey: paneKey(tabId, '1'),
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: NOW - 60_000 },
|
||||
stateStartedAt: NOW - 60_000,
|
||||
updatedAt: NOW - 1_000
|
||||
})
|
||||
const entries = {
|
||||
[paneKey('tab-failed', '1')]: doneWith('tab-failed', 'failure'),
|
||||
[paneKey('tab-stopped', '1')]: doneWith('tab-stopped', 'cancellation')
|
||||
}
|
||||
const sorted = sortSmart([stopped, idle, failed], tabs, entries)
|
||||
expect(sorted.map((w) => w.id)).toEqual(['failed', 'idle', 'stopped'])
|
||||
})
|
||||
|
||||
it('stale entries fall to Class 4', () => {
|
||||
const stale = makeWorktree({
|
||||
id: 'stale',
|
||||
|
||||
@@ -26,9 +26,11 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasFailed,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone,
|
||||
hasRetainedFailed,
|
||||
agentStatusPaneIdsByTabId,
|
||||
stalePaneIdsByTabId
|
||||
} = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
|
||||
@@ -49,9 +51,11 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasFailed,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
hasRetainedDone,
|
||||
hasRetainedFailed
|
||||
}),
|
||||
[
|
||||
tabs,
|
||||
@@ -64,9 +68,11 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasFailed,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
hasRetainedDone,
|
||||
hasRetainedFailed
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,9 +34,11 @@ export function selectWorktreeActivityStatuses(
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasFailed,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone,
|
||||
hasRetainedFailed,
|
||||
agentStatusPaneIdsByTabId,
|
||||
stalePaneIdsByTabId
|
||||
} = selectWorktreeAgentActivitySummary(statusInputs, worktreeId)
|
||||
@@ -53,9 +55,11 @@ export function selectWorktreeActivityStatuses(
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
hasFailed,
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
hasRetainedDone,
|
||||
hasRetainedFailed
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeAgentStatusEntry(args: {
|
||||
restoredUnconfirmed?: true
|
||||
workingMode?: AgentStatusEntry['workingMode']
|
||||
interrupted?: true
|
||||
mainAgent?: AgentStatusEntry['mainAgent']
|
||||
}): AgentStatusEntry {
|
||||
return {
|
||||
paneKey: args.paneKey,
|
||||
@@ -34,6 +35,7 @@ function makeAgentStatusEntry(args: {
|
||||
restoredUnconfirmed: args.restoredUnconfirmed,
|
||||
workingMode: args.workingMode,
|
||||
interrupted: args.interrupted,
|
||||
mainAgent: args.mainAgent,
|
||||
orchestration: args.parentPaneKey
|
||||
? {
|
||||
taskId: 'task-1',
|
||||
@@ -223,6 +225,134 @@ describe('selectWorktreeAgentActivitySummary', () => {
|
||||
expect(summary).toMatchObject({ hasInterrupted: true, hasLiveDone: false })
|
||||
})
|
||||
|
||||
it('separates a failed outcome from clean completion and from a cancellation', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2_000)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
const summary = selectWorktreeAgentActivitySummary(
|
||||
{
|
||||
tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] },
|
||||
agentStatusEpoch: 3,
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: makeAgentStatusEntry({
|
||||
paneKey,
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1_000 }
|
||||
})
|
||||
},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
runtimeAgentOrchestrationByPaneKey: {},
|
||||
retainedAgentsByPaneKey: {}
|
||||
},
|
||||
'repo::/wt-1'
|
||||
)
|
||||
|
||||
expect(summary).toMatchObject({ hasFailed: true, hasInterrupted: false, hasLiveDone: false })
|
||||
})
|
||||
|
||||
it('reports a main agent that failed while its subagents run, beside their pending question', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2_000)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
const summaryFor = (state: 'working' | 'waiting', outcome: 'failure' | 'success') =>
|
||||
selectWorktreeAgentActivitySummary(
|
||||
{
|
||||
tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] },
|
||||
agentStatusEpoch: state === 'working' ? (outcome === 'failure' ? 5 : 6) : 7,
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: makeAgentStatusEntry({
|
||||
paneKey,
|
||||
state,
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 }
|
||||
})
|
||||
},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
runtimeAgentOrchestrationByPaneKey: {},
|
||||
retainedAgentsByPaneKey: {}
|
||||
},
|
||||
'repo::/wt-1'
|
||||
)
|
||||
|
||||
expect(summaryFor('working', 'failure')).toMatchObject({
|
||||
hasFailed: true,
|
||||
hasLiveWorking: false
|
||||
})
|
||||
expect(summaryFor('working', 'success')).toMatchObject({
|
||||
hasFailed: false,
|
||||
hasLiveWorking: true
|
||||
})
|
||||
expect(summaryFor('waiting', 'failure')).toMatchObject({ hasFailed: true, hasPermission: true })
|
||||
})
|
||||
|
||||
describe('a failed agent on the worktree card', () => {
|
||||
const worktreeId = 'repo::/wt-2'
|
||||
const liveTab = makeTab('tab-1', worktreeId)
|
||||
const retainedTab = makeTab('tab-2', worktreeId)
|
||||
const failure = { state: 'done', outcome: 'failure', stateStartedAt: 1_000 } as const
|
||||
const workingKey = makePaneKey('tab-1', LEAF_ID)
|
||||
const failedKey = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222')
|
||||
const working = makeAgentStatusEntry({ paneKey: workingKey, state: 'working' })
|
||||
const retainedFailure = {
|
||||
'tab-2:0': {
|
||||
entry: makeAgentStatusEntry({ paneKey: 'tab-2:0', state: 'done', mainAgent: failure }),
|
||||
worktreeId,
|
||||
tab: retainedTab,
|
||||
agentType: 'claude' as const,
|
||||
startedAt: 1_000
|
||||
}
|
||||
}
|
||||
let epoch = 100
|
||||
const cardFor = (
|
||||
agentStatusByPaneKey: AgentActivityInput['agentStatusByPaneKey'],
|
||||
retainedAgentsByPaneKey: AgentActivityInput['retainedAgentsByPaneKey']
|
||||
) => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2_000)
|
||||
const summary = selectWorktreeAgentActivitySummary(
|
||||
{
|
||||
tabsByWorktree: { [worktreeId]: [liveTab, retainedTab] },
|
||||
agentStatusEpoch: epoch++,
|
||||
agentStatusByPaneKey,
|
||||
migrationUnsupportedByPtyId: {},
|
||||
runtimeAgentOrchestrationByPaneKey: {},
|
||||
retainedAgentsByPaneKey
|
||||
},
|
||||
worktreeId
|
||||
)
|
||||
return {
|
||||
summary,
|
||||
status: resolveWorktreeStatus({ tabs: [], browserTabs: [], ptyIdsByTabId: {}, ...summary })
|
||||
}
|
||||
}
|
||||
|
||||
it('reads a retained failure as failed once nothing else is live, not done', () => {
|
||||
const { summary, status } = cardFor({}, retainedFailure)
|
||||
|
||||
expect(summary).toMatchObject({ hasRetainedFailed: true, hasRetainedDone: false })
|
||||
expect(status).toBe('failed')
|
||||
})
|
||||
|
||||
it('lets live work outrank a departed agent that failed', () => {
|
||||
const { summary, status } = cardFor({ [workingKey]: working }, retainedFailure)
|
||||
|
||||
expect(summary).toMatchObject({ hasFailed: false, hasRetainedFailed: true })
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
|
||||
it('keeps a live failure above live work', () => {
|
||||
const { status } = cardFor(
|
||||
{
|
||||
[workingKey]: working,
|
||||
[failedKey]: makeAgentStatusEntry({
|
||||
paneKey: failedKey,
|
||||
state: 'done',
|
||||
mainAgent: failure
|
||||
})
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
expect(status).toBe('failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an unconfirmed restored row suppress only its pane title', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2_000)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
|
||||
@@ -8,18 +8,23 @@ import {
|
||||
} from '@/lib/agent-status-worktree-attribution'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry,
|
||||
type AgentStatusOrchestrationContext
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { applyAgentPaneActivityFlags } from '@/lib/agent-pane-activity-flags'
|
||||
|
||||
export type WorktreeAgentActivitySummary = {
|
||||
hasPermission: boolean
|
||||
hasLiveWorking: boolean
|
||||
hasLiveMonitoring: boolean
|
||||
/** A fresh failed main agent, also while its subagents run; kept apart from clean done. */
|
||||
hasFailed: boolean
|
||||
/** Fresh interrupted completion, kept separate from clean done outcomes. */
|
||||
hasInterrupted: boolean
|
||||
hasLiveDone: boolean
|
||||
hasRetainedDone: boolean
|
||||
/** A departed agent's failure; unlike `hasFailed` it yields to live work. */
|
||||
hasRetainedFailed: boolean
|
||||
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
|
||||
/** Stale rows suppress generated permission labels while preserving native title fallback. */
|
||||
stalePaneIdsByTabId: Record<string, ReadonlySet<string>>
|
||||
@@ -31,9 +36,11 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveMonitoring: false,
|
||||
hasFailed: false,
|
||||
hasInterrupted: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false,
|
||||
hasRetainedFailed: false,
|
||||
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID,
|
||||
stalePaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID
|
||||
}
|
||||
@@ -134,7 +141,7 @@ function getWorktreeAgentActivitySummaries(
|
||||
if (entry.state === 'done') {
|
||||
addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId)
|
||||
}
|
||||
applyLiveAgentState(summary, entry)
|
||||
applyAgentPaneActivityFlags(summary, entry)
|
||||
}
|
||||
|
||||
for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) {
|
||||
@@ -147,7 +154,12 @@ function getWorktreeAgentActivitySummaries(
|
||||
|
||||
for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) {
|
||||
const summary = summaryForWorktree(retained.worktreeId)
|
||||
summary.hasRetainedDone = true
|
||||
// Why: a failed agent is retained so its failure stays visible, not so it reads done.
|
||||
if (agentVerdictDisplayMark(retained.entry) === 'failed') {
|
||||
summary.hasRetainedFailed = true
|
||||
} else {
|
||||
summary.hasRetainedDone = true
|
||||
}
|
||||
const paneIdentity = parseAgentStatusPaneIdentity(retained.entry?.paneKey)
|
||||
if (paneIdentity) {
|
||||
addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId)
|
||||
@@ -190,9 +202,11 @@ function summariesEqual(
|
||||
previous.hasPermission === next.hasPermission &&
|
||||
previous.hasLiveWorking === next.hasLiveWorking &&
|
||||
previous.hasLiveMonitoring === next.hasLiveMonitoring &&
|
||||
previous.hasFailed === next.hasFailed &&
|
||||
previous.hasInterrupted === next.hasInterrupted &&
|
||||
previous.hasLiveDone === next.hasLiveDone &&
|
||||
previous.hasRetainedDone === next.hasRetainedDone &&
|
||||
previous.hasRetainedFailed === next.hasRetainedFailed &&
|
||||
agentStatusPaneIdsByTabIdEqual(
|
||||
previous.agentStatusPaneIdsByTabId,
|
||||
next.agentStatusPaneIdsByTabId
|
||||
@@ -227,26 +241,6 @@ function agentStatusPaneIdsByTabIdEqual(
|
||||
return true
|
||||
}
|
||||
|
||||
function applyLiveAgentState(
|
||||
summary: WorktreeAgentActivitySummary,
|
||||
entry: Pick<AgentStatusEntry, 'state' | 'workingMode' | 'interrupted'>
|
||||
): void {
|
||||
if (entry.state === 'blocked' || entry.state === 'waiting') {
|
||||
summary.hasPermission = true
|
||||
} else if (entry.interrupted === true) {
|
||||
// Interrupted is encoded as done, so it must be checked first.
|
||||
summary.hasInterrupted = true
|
||||
} else if (entry.state === 'working') {
|
||||
if (entry.workingMode === 'monitoring') {
|
||||
summary.hasLiveMonitoring = true
|
||||
} else {
|
||||
summary.hasLiveWorking = true
|
||||
}
|
||||
} else if (entry.state === 'done') {
|
||||
summary.hasLiveDone = true
|
||||
}
|
||||
}
|
||||
|
||||
function addAgentStatusPaneId(
|
||||
summary: WorktreeAgentActivitySummary,
|
||||
tabId: string,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
|
||||
import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row'
|
||||
import { getAgentDotState, summarizeAgents } from './worktree-card-agent-summary'
|
||||
import { buildSubagentChildRows } from './worktree-subagent-child-rows'
|
||||
|
||||
function monitoringAgent(): DashboardAgentRowData {
|
||||
return {
|
||||
@@ -102,4 +103,72 @@ describe('worktree card agent summary', () => {
|
||||
|
||||
expect(summarizeAgents([done, interrupted], 'Agents')).toBe('Agents: 1 interrupted, 1 done')
|
||||
})
|
||||
|
||||
it('lists a failed turn as failed, not done, ahead of an interrupted one', () => {
|
||||
const done = monitoringAgent()
|
||||
done.state = 'done'
|
||||
done.entry.state = 'done'
|
||||
done.entry.workingMode = undefined
|
||||
const failed = {
|
||||
...done,
|
||||
paneKey: 'tab-1:leaf-3',
|
||||
entry: {
|
||||
...done.entry,
|
||||
paneKey: 'tab-1:leaf-3',
|
||||
mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 1 }
|
||||
}
|
||||
}
|
||||
const interrupted = {
|
||||
...done,
|
||||
paneKey: 'tab-1:leaf-2',
|
||||
entry: { ...done.entry, paneKey: 'tab-1:leaf-2', interrupted: true }
|
||||
}
|
||||
|
||||
expect(getAgentDotState(failed)).toBe('failed')
|
||||
expect(getCompactAgentSecondary(failed, 0)).toBe('Failed')
|
||||
expect(getCompactAgentSecondary(interrupted, 0)).toBe('Interrupted by user')
|
||||
expect(summarizeAgents([done, interrupted, failed], 'Agents')).toBe(
|
||||
'Agents: 1 failed, 1 interrupted, 1 done'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a main agent that failed while its subagent works as failed, ranked above working', () => {
|
||||
const held = (outcome: 'failure' | 'success' | 'cancellation'): DashboardAgentRowData => {
|
||||
const agent = monitoringAgent()
|
||||
agent.entry.workingMode = undefined
|
||||
agent.entry.mainAgent = { state: 'done', outcome, stateStartedAt: 1 }
|
||||
return agent
|
||||
}
|
||||
const working = monitoringAgent()
|
||||
working.paneKey = 'tab-1:leaf-2'
|
||||
working.entry = { ...working.entry, paneKey: 'tab-1:leaf-2', workingMode: undefined }
|
||||
|
||||
expect(getAgentDotState(held('failure'))).toBe('failed')
|
||||
expect(getCompactAgentSecondary(held('failure'), 0)).toBe('Failed')
|
||||
expect(summarizeAgents([working, held('failure')], 'Agents')).toBe(
|
||||
'Agents: 1 failed, 1 working'
|
||||
)
|
||||
// Only a failure outranks live work; a success or a stop with a live subagent reads working.
|
||||
expect(getAgentDotState(held('success'))).toBe('working')
|
||||
expect(getAgentDotState(held('cancellation'))).toBe('working')
|
||||
expect(getCompactAgentSecondary(held('cancellation'), 0)).not.toBe('Interrupted by user')
|
||||
})
|
||||
|
||||
it('keeps a subagent row on its own state while its failed main agent reads failed', () => {
|
||||
const parent = monitoringAgent()
|
||||
parent.entry = {
|
||||
...parent.entry,
|
||||
workingMode: undefined,
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1 },
|
||||
subagents: [{ id: 'child-1', state: 'working', startedAt: 1 }]
|
||||
}
|
||||
const [child] = buildSubagentChildRows({
|
||||
parentEntry: parent.entry,
|
||||
tab: parent.tab,
|
||||
parentIsFresh: true
|
||||
})
|
||||
|
||||
expect(getAgentDotState(parent)).toBe('failed')
|
||||
expect(getAgentDotState(child)).toBe('working')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentDotState } from '@/components/AgentStateDot'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
|
||||
import { formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import { agentRowDotState } from '@/lib/agent-row-dot-state'
|
||||
import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
export type SummaryAgentGroup = {
|
||||
state: AgentDotState
|
||||
@@ -11,6 +12,8 @@ export type SummaryAgentGroup = {
|
||||
const SUMMARY_STATE_ORDER: AgentDotState[] = [
|
||||
'waiting',
|
||||
'blocked',
|
||||
// Why: a failed main agent outranks live work; a pending question still comes first.
|
||||
'failed',
|
||||
'working',
|
||||
'monitoring',
|
||||
'interrupted',
|
||||
@@ -21,10 +24,9 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [
|
||||
]
|
||||
|
||||
export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
|
||||
if (agent.entry.interrupted === true) {
|
||||
return 'interrupted'
|
||||
}
|
||||
return agentRowDotState(agent.state, agent.entry.workingMode)
|
||||
return (
|
||||
agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode)
|
||||
)
|
||||
}
|
||||
|
||||
export function formatSummaryStateLabel(state: AgentDotState): string {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAgentRowConversationName } from '@/components/dashboard/use-agent-ro
|
||||
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
|
||||
import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
|
||||
import { formatShortTimeAgo } from '@/lib/short-time-ago'
|
||||
import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
function getCompactAgentPrimary(
|
||||
agent: DashboardAgentRowData,
|
||||
@@ -28,9 +29,13 @@ export function getCompactAgentSecondary(
|
||||
now: number,
|
||||
lastAssistantMessageOverride?: string
|
||||
): string {
|
||||
if (agent.entry.interrupted === true) {
|
||||
const verdictMark = agentVerdictDisplayMark(agent.entry)
|
||||
if (verdictMark === 'interrupted') {
|
||||
return 'Interrupted by user'
|
||||
}
|
||||
if (verdictMark === 'failed') {
|
||||
return 'Failed'
|
||||
}
|
||||
// Why: the only honest thing to say about a pane Orca still holds but no longer hears
|
||||
// from is how long the silence has run; the user supplies the meaning.
|
||||
if (agent.state === 'unverifiable') {
|
||||
|
||||
@@ -177,6 +177,36 @@ describe('StarNagAgentValueMomentObserver', () => {
|
||||
expect(showAgentValueMoment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a prompted done whose turn failed', async () => {
|
||||
;({ root, container } = renderObserver())
|
||||
|
||||
setAgentEntries({ pane: entry({ state: 'working' }) })
|
||||
setAgentEntries({
|
||||
pane: entry({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1 }
|
||||
})
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2400)
|
||||
})
|
||||
|
||||
expect(agentValueMoment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a return to a done dated before the work it ended', async () => {
|
||||
;({ root, container } = renderObserver())
|
||||
|
||||
setAgentEntries({ pane: entry({ state: 'done', stateStartedAt: 1 }) })
|
||||
setAgentEntries({ pane: entry({ state: 'working', stateStartedAt: 5 }) })
|
||||
setAgentEntries({ pane: entry({ state: 'done', stateStartedAt: 1 }) })
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2400)
|
||||
})
|
||||
|
||||
expect(agentValueMoment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for other live agents and recent typing to quiet', async () => {
|
||||
;({ root, container } = renderObserver())
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { agentTurnEndedUncleanly } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
// Why: leave a short quiet window after agents finish so the prompt does not
|
||||
@@ -36,7 +37,9 @@ function hasSuccessfulDoneTransition(
|
||||
// Why: a session-boundary done is an idle connect (STA-3386), not a value moment —
|
||||
// a stale working row + resume would otherwise nag on launch.
|
||||
entry.sessionBoundary !== true &&
|
||||
!entry.interrupted &&
|
||||
!agentTurnEndedUncleanly(entry) &&
|
||||
// A new done, not a return to one dated before the work it ended (a command at rest).
|
||||
entry.stateStartedAt >= previousEntry.stateStartedAt &&
|
||||
hasMeaningfulPrompt(entry)
|
||||
) {
|
||||
return true
|
||||
|
||||
@@ -163,6 +163,39 @@ describe('resolveTerminalTabActivityStatus', () => {
|
||||
).toBe('interrupted')
|
||||
})
|
||||
|
||||
it('reports a failed done as failed, and not as a clean finish', () => {
|
||||
const failed = entry(FIRST_LEAF_ID, 'done', {
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW }
|
||||
})
|
||||
const finished = entry(SECOND_LEAF_ID, 'done')
|
||||
expect(
|
||||
resolveTerminalTabActivityStatus({
|
||||
tab: TAB,
|
||||
agentStatusByPaneKey: { [failed.paneKey]: failed, [finished.paneKey]: finished },
|
||||
ptyIdsByTabId: LIVE_PTY
|
||||
})
|
||||
).toBe('failed')
|
||||
})
|
||||
|
||||
it('reads a main agent that failed while its subagent works as failed; success or stop as working', () => {
|
||||
const status = (outcome: 'failure' | 'success' | 'cancellation') => {
|
||||
const held = entry(FIRST_LEAF_ID, 'working', {
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: NOW }
|
||||
})
|
||||
return resolveTerminalTabActivityStatus({
|
||||
tab: TAB,
|
||||
agentStatusByPaneKey: { [held.paneKey]: held },
|
||||
ptyIdsByTabId: LIVE_PTY
|
||||
})
|
||||
}
|
||||
expect(status('failure')).toBe('failed')
|
||||
expect(resolveTerminalTabAttentionBadge({ status: status('failure'), hasUnread: false })).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(status('success')).toBe('working')
|
||||
expect(status('cancellation')).toBe('working')
|
||||
})
|
||||
|
||||
it('does not let a finished sibling mask an interrupted outcome', () => {
|
||||
const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true })
|
||||
const finished = entry(SECOND_LEAF_ID, 'done')
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
applyAgentPaneActivityFlags,
|
||||
type AgentPaneActivityFlags
|
||||
} from '@/lib/agent-pane-activity-flags'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
|
||||
// Why: a terminal tab is a container of panes, exactly like a worktree card is
|
||||
@@ -17,15 +21,8 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter
|
||||
// skip the card's retained-done promotion — see resolveTerminalTabActivityStatus).
|
||||
export type TerminalTabActivityStatus = WorktreeStatus
|
||||
|
||||
// Per-tab live-hook flags, mirroring applyLiveAgentState in
|
||||
// worktree-agent-activity-summary.ts. blocked/waiting collapse to permission,
|
||||
// matching every other status surface in the app.
|
||||
type TerminalTabActivityFlags = {
|
||||
hasPermission: boolean
|
||||
hasLiveWorking: boolean
|
||||
hasLiveMonitoring: boolean
|
||||
hasInterrupted: boolean
|
||||
hasLiveDone: boolean
|
||||
// Per-tab live-hook flags, folded by the same applyAgentPaneActivityFlags as the worktree card.
|
||||
type TerminalTabActivityFlags = AgentPaneActivityFlags & {
|
||||
paneIds: Set<string>
|
||||
/** Panes whose row went stale; suppress generated permission labels only. */
|
||||
stalePaneIds: Set<string>
|
||||
@@ -84,20 +81,7 @@ function getTerminalTabActivityFlags(
|
||||
|
||||
const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId)
|
||||
flags.paneIds.add(identity.paneId)
|
||||
if (entry.state === 'blocked' || entry.state === 'waiting') {
|
||||
flags.hasPermission = true
|
||||
} else if (entry.state === 'working') {
|
||||
if (entry.workingMode === 'monitoring') {
|
||||
flags.hasLiveMonitoring = true
|
||||
} else {
|
||||
flags.hasLiveWorking = true
|
||||
}
|
||||
} else if (entry.interrupted === true) {
|
||||
// Interrupted is encoded as done, so it must be checked first.
|
||||
flags.hasInterrupted = true
|
||||
} else if (entry.state === 'done') {
|
||||
flags.hasLiveDone = true
|
||||
}
|
||||
applyAgentPaneActivityFlags(flags, entry)
|
||||
}
|
||||
|
||||
flagsCache = { agentStatusByPaneKey, agentStatusEpoch, flagsByTabId }
|
||||
@@ -114,6 +98,7 @@ function getOrCreateTerminalTabActivityFlags(
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveMonitoring: false,
|
||||
hasFailed: false,
|
||||
hasInterrupted: false,
|
||||
hasLiveDone: false,
|
||||
paneIds: new Set(),
|
||||
@@ -178,6 +163,7 @@ export function resolveTerminalTabActivityStatus({
|
||||
hasPermission: flags?.hasPermission ?? false,
|
||||
hasLiveWorking: flags?.hasLiveWorking ?? false,
|
||||
hasLiveMonitoring: flags?.hasLiveMonitoring ?? false,
|
||||
hasFailed: flags?.hasFailed ?? false,
|
||||
hasInterrupted: flags?.hasInterrupted ?? false,
|
||||
hasLiveDone: flags?.hasLiveDone ?? false,
|
||||
// Why: retained/orchestration promotions are worktree-aggregate concerns;
|
||||
@@ -199,6 +185,7 @@ export type TerminalTabAttentionBadge =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'permission'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
| 'unread'
|
||||
| 'done'
|
||||
@@ -229,8 +216,8 @@ export function resolveTerminalTabAttentionBadge({
|
||||
if (status === 'done') {
|
||||
return 'done'
|
||||
}
|
||||
if (status === 'interrupted') {
|
||||
return 'interrupted'
|
||||
if (status === 'failed' || status === 'interrupted') {
|
||||
return status
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -238,11 +225,12 @@ export function resolveTerminalTabAttentionBadge({
|
||||
/** Map a container activity status onto AgentStateDot's vocabulary (no unread — that's a bell). */
|
||||
export function terminalTabActivityToAgentDotState(
|
||||
status: TerminalTabActivityStatus
|
||||
): 'working' | 'monitoring' | 'permission' | 'interrupted' | 'done' | null {
|
||||
): 'working' | 'monitoring' | 'permission' | 'failed' | 'interrupted' | 'done' | null {
|
||||
switch (status) {
|
||||
case 'working':
|
||||
case 'monitoring':
|
||||
case 'permission':
|
||||
case 'failed':
|
||||
case 'interrupted':
|
||||
case 'done':
|
||||
return status
|
||||
|
||||
+1
-1
@@ -225,7 +225,7 @@ describe('connectPanePty', () => {
|
||||
agentToolName: 'Edit',
|
||||
agentToolInput: 'src/main/ipc/notifications.ts',
|
||||
agentLastAssistantMessage: 'Implemented the formatter.',
|
||||
agentInterrupted: false
|
||||
agentTurnOutcome: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
+1
-1
@@ -699,7 +699,7 @@ describe('connectPanePty', () => {
|
||||
expect('agentToolName' in dispatchArgs).toBe(false)
|
||||
expect('agentToolInput' in dispatchArgs).toBe(false)
|
||||
expect('agentLastAssistantMessage' in dispatchArgs).toBe(false)
|
||||
expect('agentInterrupted' in dispatchArgs).toBe(false)
|
||||
expect('agentTurnOutcome' in dispatchArgs).toBe(false)
|
||||
})
|
||||
|
||||
// Why: agent-row removal belongs to process/PTY lifecycle, not title reversion, so interrupts can't disappear the activity row.
|
||||
|
||||
@@ -23,6 +23,11 @@ describe('noteArmsHibernatedPaneWake', () => {
|
||||
// Why: #16308's incidental widening, kept; pty-connection-hibernation-wake.test.ts pins its effect.
|
||||
['a finished turn idle anchor (live done)', true, note({ origin: 'live' })],
|
||||
['an interrupted live turn', false, note({ origin: 'live', interrupted: true })],
|
||||
[
|
||||
'a failed live turn',
|
||||
false,
|
||||
note({ origin: 'live', mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1 } })
|
||||
],
|
||||
['a quit capture', false, note({ origin: 'quit' })],
|
||||
[
|
||||
'a manual-sleep note still working',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { POST_REPLAY_MODE_RESET } from '../../../../../shared/terminal-mode-rese
|
||||
import { isProvenProcessExit } from '../../../../../shared/terminal-exit-cause'
|
||||
import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume'
|
||||
import { agentTurnEndedUncleanly } from '../../../../../shared/agent-main-agent-verdict'
|
||||
import {
|
||||
createGitBashConsoleCapacityDetector,
|
||||
type GitBashConsoleCapacityDetector
|
||||
@@ -25,7 +26,7 @@ export function noteArmsHibernatedPaneWake(record: SleepingAgentSessionRecord):
|
||||
return (
|
||||
record.state === 'done' &&
|
||||
record.origin !== 'quit' &&
|
||||
!(record.origin === 'live' && record.interrupted === true)
|
||||
!(record.origin === 'live' && agentTurnEndedUncleanly(record))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchTerminalNotification } from './use-notification-dispatch'
|
||||
import {
|
||||
PANE_KEY,
|
||||
getLastNotificationDispatchArg,
|
||||
makeAgentStatus,
|
||||
resetNotificationDispatchMockState,
|
||||
type NotificationDispatchMockState
|
||||
} from './notification-dispatch-test-harness'
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const harness = await import('./notification-dispatch-test-harness')
|
||||
return harness.createNotificationDispatchStoreModuleMock()
|
||||
})
|
||||
|
||||
vi.mock('@/lib/desktop-notification-sound', async () => {
|
||||
const harness = await import('./notification-dispatch-test-harness')
|
||||
return harness.createDesktopNotificationSoundModuleMock()
|
||||
})
|
||||
|
||||
let mockState: NotificationDispatchMockState
|
||||
|
||||
// The hook lane hands main the row's verdict, read through the one accessor, so a terminal
|
||||
// agent's StopFailure is worded "failed" rather than "finished".
|
||||
describe('dispatchTerminalNotification verdict', () => {
|
||||
beforeEach(() => {
|
||||
mockState = resetNotificationDispatchMockState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1 } }, 'failure'],
|
||||
[{ mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } }, 'cancellation'],
|
||||
[{ interrupted: true }, 'cancellation'],
|
||||
[{}, undefined]
|
||||
] as const)('passes %j as the turn outcome %s', (overrides, outcome) => {
|
||||
mockState.agentStatusByPaneKey[PANE_KEY] = makeAgentStatus(PANE_KEY, overrides)
|
||||
|
||||
dispatchTerminalNotification('wt-primary', {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: 'codex',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
|
||||
expect(getLastNotificationDispatchArg()).toMatchObject({
|
||||
agentState: 'done',
|
||||
agentTurnOutcome: outcome
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
|
||||
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'
|
||||
import { agentMainAgentVerdict } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { shareCompatibleTitleIdentityGroup } from '../../../../shared/agent-title-owner'
|
||||
import {
|
||||
isFreshNonDoneAgentStatus,
|
||||
@@ -146,7 +147,7 @@ export function dispatchTerminalNotification(
|
||||
agentToolName: agentStatus.toolName,
|
||||
agentToolInput: agentStatus.toolInput,
|
||||
agentLastAssistantMessage: agentStatus.lastAssistantMessage,
|
||||
agentInterrupted: agentStatus.interrupted
|
||||
agentTurnOutcome: agentMainAgentVerdict(agentStatus) ?? undefined
|
||||
}
|
||||
: {}
|
||||
const notificationId =
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceTabPaletteSearchResult } from '@/lib/workspace-tab-palette-search'
|
||||
import type { TabPaneInputSources } from '@/components/sidebar/smart-attention'
|
||||
import type { AgentJournalTurnOutcome } from '../../../shared/agent-turn-outcome'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import { shouldIncludeOpenTabInRecentSection } from './worktree-jump-palette-recent-inclusion'
|
||||
import { makeAgentEntry, makeWorktree } from './worktree-jump-palette-test-fixtures'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
const TAB_ID = 'terminal-1'
|
||||
|
||||
function currentTabResult(): WorkspaceTabPaletteSearchResult {
|
||||
return {
|
||||
tabId: 'unified-terminal-1',
|
||||
entityId: TAB_ID,
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
contentType: 'terminal',
|
||||
occupantAgent: null,
|
||||
title: 'Terminal',
|
||||
secondaryText: '',
|
||||
secondaryMatches: [],
|
||||
repoName: 'repo/orca',
|
||||
worktreeName: 'Palette Worktree',
|
||||
branchName: 'main',
|
||||
titleRanges: [],
|
||||
secondaryRanges: [],
|
||||
repoRanges: [],
|
||||
worktreeRanges: [],
|
||||
branchRanges: [],
|
||||
typeAliasMatches: [],
|
||||
isCurrentTab: true,
|
||||
isCurrentWorktree: true,
|
||||
score: 0,
|
||||
qualityClass: null,
|
||||
rank: null,
|
||||
paletteIdentity: `terminal\u0000wt-1\u0000group-1\u0000unified-terminal-1`,
|
||||
lastActiveAt: null,
|
||||
activity: { ageBucket: null, timestamp: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function includesCurrentTabWith(entry: AgentStatusEntry): boolean {
|
||||
const paneSources: TabPaneInputSources = {
|
||||
entriesByTabId: new Map([[TAB_ID, [entry]]]),
|
||||
ptyIdsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {}
|
||||
}
|
||||
return shouldIncludeOpenTabInRecentSection({
|
||||
item: { id: 'item-1', type: 'workspace-tab', result: currentTabResult() },
|
||||
worktree: makeWorktree('wt-1', 'Palette Worktree'),
|
||||
row: {
|
||||
id: TAB_ID,
|
||||
worktreeId: 'wt-1',
|
||||
unifiedTabId: 'unified-terminal-1',
|
||||
terminalTab: { id: TAB_ID, title: 'zsh' },
|
||||
worktreeLastActivityAt: 0
|
||||
},
|
||||
paneSources,
|
||||
unreadTerminalTabs: {},
|
||||
unreadAgentCompletionPanes: {},
|
||||
now: NOW
|
||||
})
|
||||
}
|
||||
|
||||
function doneWith(outcome?: AgentJournalTurnOutcome): AgentStatusEntry {
|
||||
return makeAgentEntry(
|
||||
TAB_ID,
|
||||
'done',
|
||||
NOW - 1_000,
|
||||
outcome ? { mainAgent: { state: 'done', outcome, stateStartedAt: NOW - 1_000 } } : {}
|
||||
)
|
||||
}
|
||||
|
||||
describe('shouldIncludeOpenTabInRecentSection', () => {
|
||||
it('keeps the current tab out of Recent once its turn settled, a failure like a completion', () => {
|
||||
expect(includesCurrentTabWith(doneWith())).toBe(false)
|
||||
expect(includesCurrentTabWith(doneWith('cancellation'))).toBe(false)
|
||||
expect(includesCurrentTabWith(doneWith('failure'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the current tab in Recent while its agent still works', () => {
|
||||
expect(includesCurrentTabWith(makeAgentEntry(TAB_ID, 'working', NOW - 1_000))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -46,5 +46,6 @@ export function shouldIncludeOpenTabInRecentSection({
|
||||
unreadAgentCompletionPanes
|
||||
})
|
||||
})
|
||||
return badge != null && badge !== 'done' && badge !== 'interrupted'
|
||||
// Why: a settled outcome on the tab you are on is not news; a failure ranks like a completion.
|
||||
return badge != null && badge !== 'done' && badge !== 'failed' && badge !== 'interrupted'
|
||||
}
|
||||
|
||||
@@ -18361,6 +18361,7 @@
|
||||
"needsInput": "needs input",
|
||||
"working": "working",
|
||||
"stopped": "stopped",
|
||||
"failed": "failed",
|
||||
"finished": "finished"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15273,6 +15273,7 @@
|
||||
"needsInput": "necesita información",
|
||||
"working": "trabajando",
|
||||
"stopped": "detenido",
|
||||
"failed": "fallido",
|
||||
"finished": "finalizado"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18305,6 +18305,7 @@
|
||||
"needsInput": "attend une réponse",
|
||||
"working": "en cours",
|
||||
"stopped": "arrêté",
|
||||
"failed": "échoué",
|
||||
"finished": "terminé"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18257,6 +18257,7 @@
|
||||
"needsInput": "入力待ち",
|
||||
"working": "処理中",
|
||||
"stopped": "停止",
|
||||
"failed": "失敗",
|
||||
"finished": "完了"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18257,6 +18257,7 @@
|
||||
"needsInput": "입력 필요",
|
||||
"working": "작업 중",
|
||||
"stopped": "중지됨",
|
||||
"failed": "실패함",
|
||||
"finished": "완료됨"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18257,6 +18257,7 @@
|
||||
"needsInput": "需要输入",
|
||||
"working": "处理中",
|
||||
"stopped": "已停止",
|
||||
"failed": "已失败",
|
||||
"finished": "已完成"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import es from './locales/es.json'
|
||||
import fr from './locales/fr.json'
|
||||
import ja from './locales/ja.json'
|
||||
import ko from './locales/ko.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
// Main words a notification from these keys with an English fallback, so a locale missing one
|
||||
// silently notifies in English; no other gate catches it.
|
||||
describe('notification agent status locales', () => {
|
||||
it.each(Object.entries({ en, es, fr, ja, ko, zh }))(
|
||||
'%s words every verdict',
|
||||
(_locale, catalog) => {
|
||||
const words = catalog.notifications.agentStatus
|
||||
for (const key of ['needsInput', 'working', 'stopped', 'failed', 'finished'] as const) {
|
||||
expect(words[key]).toEqual(expect.any(String))
|
||||
expect(words[key].length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -203,6 +203,17 @@ describe('getActivityThreadStatusPreview', () => {
|
||||
).toBe('Interrupted by user')
|
||||
})
|
||||
|
||||
it('surfaces a failed turn as failed, not as its last reply', () => {
|
||||
expect(
|
||||
getActivityThreadStatusPreview({
|
||||
state: 'done',
|
||||
lastAssistantMessage: 'Done!',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1 },
|
||||
prompt: 'Ship it'
|
||||
})
|
||||
).toBe('Failed')
|
||||
})
|
||||
|
||||
it('names what a blocked approval is waiting on', () => {
|
||||
// Why: a permission request parks the row on 'waiting', and the tool fields are the
|
||||
// only thing that says what the user is being asked to approve (STA-3160).
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
orchestrationLabelsMatchLiveDispatch
|
||||
} from './agent-row-primary-text'
|
||||
import { formatAgentToolPreview } from './agent-row-tool-preview'
|
||||
import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
|
||||
|
||||
// Why: follow-up replies ("yes", "ok proceed") are valid hook prompts but are
|
||||
// terrible scan labels for a cross-worktree agent list — treat them as non-titles.
|
||||
@@ -194,13 +195,18 @@ export function getActivityThreadStatusPreview(
|
||||
| 'lastAssistantMessage'
|
||||
| 'lastCompletedAssistantMessage'
|
||||
| 'interrupted'
|
||||
| 'mainAgent'
|
||||
| 'prompt'
|
||||
>,
|
||||
agentState?: AgentStatusState | null
|
||||
): string {
|
||||
if (entry.interrupted === true) {
|
||||
const verdictMark = agentVerdictDisplayMark(entry)
|
||||
if (verdictMark === 'interrupted') {
|
||||
return 'Interrupted by user'
|
||||
}
|
||||
if (verdictMark === 'failed') {
|
||||
return 'Failed'
|
||||
}
|
||||
const state = agentState ?? entry.state
|
||||
const toolPreview = formatAgentToolPreview(entry, state)
|
||||
if (toolPreview) {
|
||||
@@ -228,6 +234,7 @@ export function resolveActivityThreadStatusPreview(
|
||||
| 'lastAssistantMessage'
|
||||
| 'lastCompletedAssistantMessage'
|
||||
| 'interrupted'
|
||||
| 'mainAgent'
|
||||
| 'prompt'
|
||||
>,
|
||||
agentState: AgentStatusState | null | undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/termin
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
import { lastInputBlocksHibernation } from './agent-hibernation-input-guard'
|
||||
import { isLiveResumeAnchorForCompletedAgent } from './live-resume-anchor-record'
|
||||
import { agentTurnEndedUncleanly } from '../../../shared/agent-main-agent-verdict'
|
||||
import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot'
|
||||
|
||||
export type EligiblePane = {
|
||||
@@ -89,7 +90,7 @@ export function getEligiblePane(args: {
|
||||
)
|
||||
if (
|
||||
entry.state !== 'done' ||
|
||||
entry.interrupted === true ||
|
||||
agentTurnEndedUncleanly(entry) ||
|
||||
Boolean(entry.subagents?.length) ||
|
||||
hasUnsettledOrUnknownDispatch(entry) ||
|
||||
(sleepingRecord && !hasOnlyLiveResumeAnchor)
|
||||
|
||||
@@ -110,6 +110,21 @@ describe('agent sleep planner', () => {
|
||||
expect(
|
||||
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [interrupted.paneKey]: interrupted } }))
|
||||
).toEqual([])
|
||||
// A failure ranks like a completion for attention, but a failed pane is never passive.
|
||||
const failed = entry({ mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: OLD } })
|
||||
expect(
|
||||
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [failed.paneKey]: failed } }))
|
||||
).toEqual([])
|
||||
// A main agent that finished while its subagents still run is live work, whatever its verdict.
|
||||
for (const outcome of ['success', 'failure'] as const) {
|
||||
const held = entry({
|
||||
state: 'working',
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: OLD }
|
||||
})
|
||||
expect(
|
||||
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [held.paneKey]: held } }))
|
||||
).toEqual([])
|
||||
}
|
||||
const noSession = entry({ providerSession: undefined })
|
||||
expect(
|
||||
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [noSession.paneKey]: noSession } }))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
|
||||
|
||||
/** What fresh agent panes contribute to a container's status (worktree card, terminal tab). */
|
||||
export type AgentPaneActivityFlags = {
|
||||
hasPermission: boolean
|
||||
hasLiveWorking: boolean
|
||||
hasLiveMonitoring: boolean
|
||||
hasFailed: boolean
|
||||
hasInterrupted: boolean
|
||||
hasLiveDone: boolean
|
||||
}
|
||||
|
||||
/** Fold one fresh pane's entry into its container's flags; `resolveWorktreeStatus` ranks them. */
|
||||
export function applyAgentPaneActivityFlags(
|
||||
flags: AgentPaneActivityFlags,
|
||||
entry: Pick<AgentStatusEntry, 'state' | 'workingMode' | 'interrupted' | 'mainAgent'>
|
||||
): void {
|
||||
const mark = agentVerdictDisplayMark(entry)
|
||||
if (entry.state === 'blocked' || entry.state === 'waiting') {
|
||||
flags.hasPermission = true
|
||||
}
|
||||
// Why: a failed main agent outranks the live work its subagents hold, and it still counts
|
||||
// beside a subagent's question so the failure shows once that is answered.
|
||||
if (mark === 'failed') {
|
||||
flags.hasFailed = true
|
||||
} else if (mark === 'interrupted') {
|
||||
flags.hasInterrupted = true
|
||||
} else if (entry.state === 'working') {
|
||||
if (entry.workingMode === 'monitoring') {
|
||||
flags.hasLiveMonitoring = true
|
||||
} else {
|
||||
flags.hasLiveWorking = true
|
||||
}
|
||||
} else if (entry.state === 'done') {
|
||||
flags.hasLiveDone = true
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,28 @@ describe('resolveRecentWorkspaceTabStatus', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a failed outcome as failed', () => {
|
||||
const failed = entry('failed', 'done', NOW - 1_000, {
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 1_000 }
|
||||
})
|
||||
|
||||
expect(resolveRecentWorkspaceTabStatus(row('failed'), sources([failed]), NOW)).toBe('failed')
|
||||
})
|
||||
|
||||
it('surfaces a main agent that failed while its subagent works as failed, above working', () => {
|
||||
const held = (tabId: string, outcome: 'failure' | 'success') =>
|
||||
entry(tabId, 'working', NOW - 1_000, {
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: NOW - 2_000 }
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveRecentWorkspaceTabStatus(row('held'), sources([held('held', 'failure')]), NOW)
|
||||
).toBe('failed')
|
||||
expect(
|
||||
resolveRecentWorkspaceTabStatus(row('held'), sources([held('held', 'success')]), NOW)
|
||||
).toBe('working')
|
||||
})
|
||||
|
||||
it('does not let a cleanly finished sibling mask an interruption', () => {
|
||||
const interrupted = entry('mixed', 'done', NOW - 1_000, {
|
||||
paneKey: `mixed:${LEAF_ID}`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type WorktreeAttention
|
||||
} from '@/components/sidebar/smart-attention'
|
||||
import { tabHasLivePty } from './tab-has-live-pty'
|
||||
import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
|
||||
import { isExplicitAgentStatusFresh } from './pane-agent-evidence'
|
||||
import type { WorktreeStatus } from './worktree-status'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
@@ -74,6 +75,21 @@ export function resolveRecentWorkspaceTabStatus(
|
||||
const panes = collectTabPaneInputs(row.terminalTab, row.worktreeLastActivityAt, paneSources, now)
|
||||
const attention = resolveAttention(panes, now)
|
||||
const explicit = STATUS_BY_ATTENTION_CLASS[attention.cls]
|
||||
if (explicit === 'permission') {
|
||||
return explicit
|
||||
}
|
||||
const verdicts = new Set(
|
||||
panes.flatMap((pane) =>
|
||||
pane.kind === 'hook' &&
|
||||
isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
? [agentVerdictDisplayMark(pane.entry)]
|
||||
: []
|
||||
)
|
||||
)
|
||||
// Why: a failed main agent outranks the subagent work still holding its row live.
|
||||
if (verdicts.has('failed')) {
|
||||
return 'failed'
|
||||
}
|
||||
if (explicit === 'working') {
|
||||
const hasForegroundWork = panes.some(
|
||||
(pane) =>
|
||||
@@ -82,16 +98,7 @@ export function resolveRecentWorkspaceTabStatus(
|
||||
)
|
||||
return hasForegroundWork ? 'working' : 'monitoring'
|
||||
}
|
||||
if (explicit === 'permission') {
|
||||
return explicit
|
||||
}
|
||||
const hasInterrupted = panes.some(
|
||||
(pane) =>
|
||||
pane.kind === 'hook' &&
|
||||
pane.entry.interrupted === true &&
|
||||
isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
)
|
||||
if (hasInterrupted) {
|
||||
if (verdicts.has('interrupted')) {
|
||||
return 'interrupted'
|
||||
}
|
||||
if (explicit === 'done') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { useAppStore } from '@/store'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { agentTurnEndedUncleanly } from '../../../shared/agent-main-agent-verdict'
|
||||
import type {
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalPaneLayoutNode,
|
||||
@@ -18,11 +19,11 @@ export function getProviderSessionClaimKey(record: SleepingAgentSessionRecord):
|
||||
}
|
||||
|
||||
// Why live+done counts (#16308): workspace activation must not resume a finished turn's idle anchor.
|
||||
// Quit asks to keep work resumable and a live interrupted turn is unfinished.
|
||||
// Quit asks to keep work resumable and a live stopped or failed turn is unfinished.
|
||||
export function activationTreatsNoteAsFinished(record: SleepingAgentSessionRecord): boolean {
|
||||
return (
|
||||
record.origin !== 'quit' &&
|
||||
!(record.origin === 'live' && record.interrupted === true) &&
|
||||
!(record.origin === 'live' && agentTurnEndedUncleanly(record)) &&
|
||||
record.state === 'done'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,38 @@ describe('resolveWorktreeStatus — interrupted (STA-5357)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a failure above live work and every outcome, below only a pending question', () => {
|
||||
expect(resolveWorktreeStatus({ ...base, hasFailed: true })).toBe('failed')
|
||||
expect(resolveWorktreeStatus({ ...base, hasFailed: true, hasInterrupted: true })).toBe('failed')
|
||||
expect(resolveWorktreeStatus({ ...base, hasFailed: true, hasLiveDone: true })).toBe('failed')
|
||||
expect(resolveWorktreeStatus({ ...base, hasFailed: true, hasLiveWorking: true })).toBe('failed')
|
||||
expect(resolveWorktreeStatus({ ...base, hasFailed: true, hasLiveMonitoring: true })).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(resolveWorktreeStatus({ ...base, hasFailed: true, hasPermission: true })).toBe(
|
||||
'permission'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a departed agent failure below live work and above every outcome', () => {
|
||||
expect(resolveWorktreeStatus({ ...base, hasRetainedFailed: true })).toBe('failed')
|
||||
expect(resolveWorktreeStatus({ ...base, hasRetainedFailed: true, hasInterrupted: true })).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(resolveWorktreeStatus({ ...base, hasRetainedFailed: true, hasRetainedDone: true })).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(resolveWorktreeStatus({ ...base, hasRetainedFailed: true, hasLiveWorking: true })).toBe(
|
||||
'working'
|
||||
)
|
||||
expect(
|
||||
resolveWorktreeStatus({ ...base, hasRetainedFailed: true, hasLiveMonitoring: true })
|
||||
).toBe('monitoring')
|
||||
expect(resolveWorktreeStatus({ ...base, hasRetainedFailed: true, hasPermission: true })).toBe(
|
||||
'permission'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves every other combination alone', () => {
|
||||
expect(resolveWorktreeStatus({ ...base, hasLiveDone: true })).toBe('done')
|
||||
expect(resolveWorktreeStatus({ ...base, hasLiveWorking: true })).toBe('working')
|
||||
|
||||
@@ -17,6 +17,7 @@ export type WorktreeStatus =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'permission'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
| 'done'
|
||||
| 'inactive'
|
||||
@@ -35,6 +36,7 @@ const STATUS_LABELS: Record<WorktreeStatus, string> = {
|
||||
working: 'Working',
|
||||
monitoring: 'Monitoring background tasks',
|
||||
permission: 'Needs permission',
|
||||
failed: 'Failed',
|
||||
interrupted: 'Interrupted',
|
||||
done: 'Done',
|
||||
inactive: 'Inactive'
|
||||
@@ -167,7 +169,7 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string {
|
||||
*
|
||||
* Map args are narrowed to this worktree. `hasPermission`/`hasLiveWorking`/
|
||||
* `hasLiveDone` are fresh hook entries ({blocked,waiting} / {working} / {done});
|
||||
* `hasRetainedDone` is a retained-agent snapshot scoped to this worktreeId.
|
||||
* `hasRetainedDone`/`hasRetainedFailed` are retained-agent snapshots scoped to this worktreeId.
|
||||
*/
|
||||
export function resolveWorktreeStatus(args: {
|
||||
tabs: readonly Pick<TerminalTab, 'id' | 'title' | 'launchAgent'>[]
|
||||
@@ -181,9 +183,11 @@ export function resolveWorktreeStatus(args: {
|
||||
hasPermission: boolean
|
||||
hasLiveWorking: boolean
|
||||
hasLiveMonitoring?: boolean
|
||||
hasFailed?: boolean
|
||||
hasInterrupted?: boolean
|
||||
hasLiveDone: boolean
|
||||
hasRetainedDone: boolean
|
||||
hasRetainedFailed?: boolean
|
||||
}): WorktreeStatus {
|
||||
const heuristic = getWorktreeStatus(
|
||||
args.tabs,
|
||||
@@ -204,6 +208,11 @@ export function resolveWorktreeStatus(args: {
|
||||
if (heuristic === 'permission') {
|
||||
return 'permission'
|
||||
}
|
||||
// Why: a failure is news, so it outranks live work (a failed main agent's subagents may still
|
||||
// run); only a pending question comes first.
|
||||
if (args.hasFailed) {
|
||||
return 'failed'
|
||||
}
|
||||
// Why: restored cards get the hook snapshot before panes mount; trust the explicit working row so they stay yellow on restart.
|
||||
if (args.hasLiveWorking || heuristic === 'working') {
|
||||
return 'working'
|
||||
@@ -211,7 +220,11 @@ export function resolveWorktreeStatus(args: {
|
||||
if (args.hasLiveMonitoring || heuristic === 'monitoring') {
|
||||
return 'monitoring'
|
||||
}
|
||||
// Terminal outcomes follow live states, but an interrupted outcome must not collapse into success.
|
||||
// Why: a departed agent's failure has no expiry, so it must not pin the card over live work.
|
||||
if (args.hasRetainedFailed) {
|
||||
return 'failed'
|
||||
}
|
||||
// A stop follows live states, but must not collapse into success.
|
||||
if (args.hasInterrupted) {
|
||||
return 'interrupted'
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
// under test so a drifted constant cannot make this reference silently disagree for a reason
|
||||
// unrelated to the change.
|
||||
const BUCKET_MS = AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS
|
||||
type MainAgentStatus = AppState['agentStatusByPaneKey'][string]['mainAgent']
|
||||
function mainAgentKey(mainAgent: MainAgentStatus) {
|
||||
return mainAgent ? [mainAgent.state, mainAgent.outcome ?? null, mainAgent.stateStartedAt] : null
|
||||
}
|
||||
function referenceProjection(map: AppState['agentStatusByPaneKey']): string {
|
||||
return JSON.stringify(
|
||||
Object.entries(map)
|
||||
@@ -31,14 +35,16 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string {
|
||||
state: history.state,
|
||||
prompt: history.prompt,
|
||||
startedAt: history.startedAt,
|
||||
interrupted: history.interrupted ?? null
|
||||
interrupted: history.interrupted ?? null,
|
||||
mainAgent: mainAgentKey(history.mainAgent)
|
||||
})),
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interactivePrompt: entry.interactivePrompt ?? null,
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null,
|
||||
interrupted: entry.interrupted ?? null
|
||||
interrupted: entry.interrupted ?? null,
|
||||
mainAgent: mainAgentKey(entry.mainAgent)
|
||||
}))
|
||||
)
|
||||
}
|
||||
@@ -145,4 +151,39 @@ describe('mobile agent-status projection equivalence', () => {
|
||||
expect([...projected].sort()).toEqual([...localeCompareOrderedEntries].sort())
|
||||
expect(projected).toHaveLength(localeCompareOrderedEntries.length)
|
||||
})
|
||||
|
||||
it('republishes a verdict change that leaves the interrupted flag as it was', () => {
|
||||
resetRuntimeMobileAgentStatusProjectionCacheForTests()
|
||||
const project = (live: 'success' | 'failure', history: 'success' | 'failure'): string =>
|
||||
buildRuntimeMobileAgentStatusProjectionForTests({
|
||||
'tab-0:leaf-0': makeEntry(0, {
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: live, stateStartedAt: 1740000000000 },
|
||||
stateHistory: [
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'p',
|
||||
startedAt: 1,
|
||||
mainAgent: { state: 'done', outcome: history, stateStartedAt: 1 }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
expect(project('failure', 'success')).not.toBe(project('success', 'success'))
|
||||
expect(project('success', 'failure')).not.toBe(project('success', 'success'))
|
||||
})
|
||||
|
||||
it('republishes a main agent failing while its subagents keep the row working', () => {
|
||||
resetRuntimeMobileAgentStatusProjectionCacheForTests()
|
||||
const project = (outcome?: 'failure', stateStartedAt = 1740000000000): string =>
|
||||
buildRuntimeMobileAgentStatusProjectionForTests({
|
||||
'tab-0:leaf-0': makeEntry(0, {
|
||||
state: 'working',
|
||||
mainAgent: { state: 'done', ...(outcome ? { outcome } : {}), stateStartedAt }
|
||||
})
|
||||
})
|
||||
expect(project('failure')).not.toBe(project())
|
||||
// The main agent's own clock dates the failure on mobile, so moving it republishes too.
|
||||
expect(project('failure', 1740000001000)).not.toBe(project('failure'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,11 @@ const { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS } = await import('./sync-runtime-
|
||||
|
||||
// ── Reference implementations: the pre-change bodies, kept verbatim ────────────────────
|
||||
|
||||
type MainAgentStatus = AppState['agentStatusByPaneKey'][string]['mainAgent']
|
||||
function mainAgentKey(mainAgent: MainAgentStatus) {
|
||||
return mainAgent ? [mainAgent.state, mainAgent.outcome ?? null, mainAgent.stateStartedAt] : null
|
||||
}
|
||||
|
||||
function referenceEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string {
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
@@ -112,14 +117,16 @@ function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']):
|
||||
state: history.state,
|
||||
prompt: history.prompt,
|
||||
startedAt: history.startedAt,
|
||||
interrupted: history.interrupted ?? null
|
||||
interrupted: history.interrupted ?? null,
|
||||
mainAgent: mainAgentKey(history.mainAgent)
|
||||
})),
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
interactivePrompt: entry.interactivePrompt ?? null,
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null,
|
||||
interrupted: entry.interrupted ?? null
|
||||
interrupted: entry.interrupted ?? null,
|
||||
mainAgent: mainAgentKey(entry.mainAgent)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { AgentMainAgentStatus } from '../../../../shared/main-agent-status'
|
||||
import { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS, graphState } from './graph-state'
|
||||
import type { AgentStatusProjectionCacheEntry } from './types'
|
||||
|
||||
function mainAgentKey(mainAgent: AgentMainAgentStatus | undefined) {
|
||||
return mainAgent ? [mainAgent.state, mainAgent.outcome ?? null, mainAgent.stateStartedAt] : null
|
||||
}
|
||||
|
||||
function serializeAgentStatusEntry(
|
||||
paneKey: string,
|
||||
entry: AppState['agentStatusByPaneKey'][string]
|
||||
@@ -20,7 +25,8 @@ function serializeAgentStatusEntry(
|
||||
state: history.state,
|
||||
prompt: history.prompt,
|
||||
startedAt: history.startedAt,
|
||||
interrupted: history.interrupted ?? null
|
||||
interrupted: history.interrupted ?? null,
|
||||
mainAgent: mainAgentKey(history.mainAgent)
|
||||
})),
|
||||
toolName: entry.toolName ?? null,
|
||||
toolInput: entry.toolInput ?? null,
|
||||
@@ -28,7 +34,9 @@ function serializeAgentStatusEntry(
|
||||
interactivePrompt: entry.interactivePrompt ?? null,
|
||||
lastAssistantMessage: entry.lastAssistantMessage ?? null,
|
||||
lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null,
|
||||
interrupted: entry.interrupted ?? null
|
||||
interrupted: entry.interrupted ?? null,
|
||||
// A failure changes the verdict and leaves `interrupted` as it was.
|
||||
mainAgent: mainAgentKey(entry.mainAgent)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* A paired client mirrors a host's agent rows from `session.tabs`. A main agent that fails while
|
||||
* its subagents keep the row working changes nothing but `mainAgent`: the row's state, start and
|
||||
* (within one host frame) update time all stay put. The mirror must still take the new row and
|
||||
* invalidate the worktree rollup, which caches on the agent-status epoch.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { AgentMainAgentStatus } from '../../../shared/main-agent-status'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers'
|
||||
import { resetRendererOwnedAgentStatusPanesForTests } from '../components/terminal-pane/renderer-owned-agent-status-registry'
|
||||
import {
|
||||
applyFreshWebSessionTabsSnapshot,
|
||||
resetWebSessionTabsSnapshotFreshnessForTests
|
||||
} from './web-session-tabs-sync'
|
||||
import { selectWorktreeAgentActivitySummary } from '../components/sidebar/worktree-agent-activity-summary'
|
||||
|
||||
// Why: web-session-tabs-sync imports the app-level store singleton; this
|
||||
// harness drives a createTestStore instance instead, like its sibling suites.
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: {
|
||||
setState: vi.fn(),
|
||||
getState: vi.fn(() => ({})),
|
||||
subscribe: vi.fn(() => () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
const ENV = 'remote-env-1'
|
||||
const T0 = 1_700_000_000_000
|
||||
const HOST_TAB_ID = 'host-tab-1'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function hostSnapshot(snapshotVersion: number, mainAgent: AgentMainAgentStatus) {
|
||||
const snapshot: RuntimeMobileSessionTabsResult = {
|
||||
worktree: WT,
|
||||
publicationEpoch: 'host-epoch-1',
|
||||
snapshotVersion,
|
||||
activeGroupId: 'host-group-1',
|
||||
activeTabId: `${HOST_TAB_ID}::${LEAF_ID}`,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `${HOST_TAB_ID}::${LEAF_ID}`,
|
||||
title: 'Claude Code',
|
||||
parentTabId: HOST_TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
launchAgent: 'claude',
|
||||
status: 'ready',
|
||||
terminal: 'terminal-1',
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
prompt: 'ship it',
|
||||
updatedAt: T0 - 1_000,
|
||||
stateStartedAt: T0 - 5_000,
|
||||
agentType: 'claude',
|
||||
paneKey: makePaneKey(HOST_TAB_ID, LEAF_ID),
|
||||
tabId: HOST_TAB_ID,
|
||||
worktreeId: WT,
|
||||
stateHistory: [],
|
||||
mainAgent
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
describe('a mirrored main agent that fails while its subagents keep the row working', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(T0)
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
resetRendererOwnedAgentStatusPanesForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetRendererOwnedAgentStatusPanesForTests()
|
||||
})
|
||||
|
||||
it('reaches the store and the worktree rollup', () => {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
settings: getDefaultSettings('/tmp'),
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] },
|
||||
activeWorktreeId: WT
|
||||
})
|
||||
const apply = (snapshot: RuntimeMobileSessionTabsResult): void => {
|
||||
store.setState(applyFreshWebSessionTabsSnapshot(store.getState(), snapshot, ENV, T0))
|
||||
}
|
||||
|
||||
apply(hostSnapshot(1, { state: 'working', stateStartedAt: T0 - 5_000 }))
|
||||
expect(selectWorktreeAgentActivitySummary(store.getState(), WT)).toMatchObject({
|
||||
hasLiveWorking: true,
|
||||
hasFailed: false
|
||||
})
|
||||
|
||||
apply(hostSnapshot(2, { state: 'done', outcome: 'failure', stateStartedAt: T0 - 2_000 }))
|
||||
const paneKey = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID)
|
||||
expect(store.getState().agentStatusByPaneKey[paneKey]?.mainAgent?.outcome).toBe('failure')
|
||||
expect(selectWorktreeAgentActivitySummary(store.getState(), WT)).toMatchObject({
|
||||
hasLiveWorking: false,
|
||||
hasFailed: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
|
||||
import { agentMainAgentVerdict } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { isWebTerminalSurfaceTabId, toWebTerminalSurfaceTabId } from '../web-runtime-session'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
@@ -221,6 +222,9 @@ export function buildMirroredAgentStatusPatch(
|
||||
entry.state === 'done' &&
|
||||
agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry)
|
||||
const workingModeChanged = existing?.workingMode !== entry.workingMode
|
||||
// A verdict moves no clock, including a main agent failing while its subagents keep the row working.
|
||||
const verdictChanged =
|
||||
!!existing && agentMainAgentVerdict(existing) !== agentMainAgentVerdict(entry)
|
||||
const entrySortRelevantChange =
|
||||
!existing ||
|
||||
existing.state !== entry.state ||
|
||||
@@ -230,7 +234,7 @@ export function buildMirroredAgentStatusPatch(
|
||||
doneAttentionChanged ||
|
||||
isMirroredCommandCodeTurnBump(existing, entry)
|
||||
aggregateRelevantChange =
|
||||
aggregateRelevantChange || entrySortRelevantChange || workingModeChanged
|
||||
aggregateRelevantChange || entrySortRelevantChange || workingModeChanged || verdictChanged
|
||||
sortRelevantChange = sortRelevantChange || entrySortRelevantChange
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStateHistoryEntry } from '../../../../shared/agent-status-types'
|
||||
import { sameAgentStateHistory } from './state-equality-core'
|
||||
|
||||
function doneEntry(mainAgent?: AgentStateHistoryEntry['mainAgent']): AgentStateHistoryEntry {
|
||||
return { state: 'done', prompt: 'ship it', startedAt: 1_000, ...(mainAgent ? { mainAgent } : {}) }
|
||||
}
|
||||
|
||||
describe('sameAgentStateHistory', () => {
|
||||
it('sees a history verdict or main agent clock change under an unchanged flag', () => {
|
||||
const failed = doneEntry({ state: 'done', outcome: 'failure', stateStartedAt: 900 })
|
||||
expect(sameAgentStateHistory([failed], [{ ...failed }])).toBe(true)
|
||||
expect(sameAgentStateHistory([doneEntry()], [failed])).toBe(false)
|
||||
expect(
|
||||
sameAgentStateHistory(
|
||||
[failed],
|
||||
[doneEntry({ state: 'done', outcome: 'success', stateStartedAt: 900 })]
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
sameAgentStateHistory(
|
||||
[failed],
|
||||
[doneEntry({ state: 'done', outcome: 'failure', stateStartedAt: 950 })]
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
|
||||
import { mainAgentStatusEqual } from '../../../../shared/main-agent-status'
|
||||
import type {
|
||||
WebSessionTabsBatchContext,
|
||||
WebSessionTabsBatchRecordKey,
|
||||
@@ -29,7 +30,8 @@ export function sameAgentStateHistory(
|
||||
entry.state === b[index]?.state &&
|
||||
entry.prompt === b[index]?.prompt &&
|
||||
entry.startedAt === b[index]?.startedAt &&
|
||||
entry.interrupted === b[index]?.interrupted
|
||||
entry.interrupted === b[index]?.interrupted &&
|
||||
mainAgentStatusEqual(entry.mainAgent, b[index]?.mainAgent)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ export function agentStatusEntryEqual(
|
||||
a.lastAssistantMessage === b.lastAssistantMessage &&
|
||||
a.lastAssistantMessageIsToolOutput === b.lastAssistantMessageIsToolOutput &&
|
||||
a.interrupted === b.interrupted &&
|
||||
mainAgentStatusEqual(a.mainAgent, b.mainAgent) &&
|
||||
a.promptInteractionKey === b.promptInteractionKey &&
|
||||
a.restoredUnconfirmed === b.restoredUnconfirmed &&
|
||||
agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) &&
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
shouldReplaceRetainedWithLive
|
||||
} from './agent-status-pane-key-tab-binding'
|
||||
import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority'
|
||||
import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
function removeAcknowledgement(
|
||||
acknowledgements: Record<string, number>,
|
||||
@@ -162,7 +163,7 @@ export function createAgentStatusDropActions(
|
||||
if (
|
||||
liveEntry?.state === 'done' &&
|
||||
liveEntry.agentType !== undefined &&
|
||||
liveEntry.interrupted !== true
|
||||
!agentTurnStoppedByUser(liveEntry)
|
||||
) {
|
||||
retainedEvidence.set(
|
||||
paneKey,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AgentStateHistoryEntry,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { agentVerdictFields } from '../../../../shared/agent-main-agent-verdict'
|
||||
import type { AgentStatusPayload } from './agent-status-contract'
|
||||
|
||||
/** The history a live entry carries after this write, and when its state was first observed. A
|
||||
@@ -36,7 +37,7 @@ export function resolveAgentStatusLiveEntryStateHistory(
|
||||
prompt: existing.prompt,
|
||||
startedAt: existing.stateStartedAt,
|
||||
observedAt: existing.stateObservedAt,
|
||||
interrupted: existing.interrupted
|
||||
...agentVerdictFields(existing)
|
||||
}
|
||||
]
|
||||
if (history.length > AGENT_STATE_HISTORY_MAX) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './agent-status-pane-key-tab-binding'
|
||||
import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries'
|
||||
import { sleepingRecordFromEntry } from './agent-status-sleeping-records'
|
||||
import { agentMainAgentVerdict } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
export type AgentStatusLiveFacts = {
|
||||
existingSleepingRecord: SleepingAgentSessionRecord | undefined
|
||||
@@ -95,12 +96,16 @@ export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): Agen
|
||||
entry.lastAssistantMessageIsToolOutput !== existing.lastAssistantMessageIsToolOutput ||
|
||||
entry.orchestration !== existing.orchestration ||
|
||||
entry.subagents !== existing.subagents ||
|
||||
entry.providerSession !== existing.providerSession ||
|
||||
entry.interrupted !== existing.interrupted)
|
||||
entry.providerSession !== existing.providerSession)
|
||||
// A verdict moves no clock: a failure keeps a done's completion time, and a main agent that fails
|
||||
// while its subagents keep the row working leaves the row's state and start as they were.
|
||||
const verdictChanged =
|
||||
!!existing && agentMainAgentVerdict(entry) !== agentMainAgentVerdict(existing)
|
||||
const retentionRelevantChange =
|
||||
sortRelevantChange ||
|
||||
attributionChanged ||
|
||||
existing?.workingMode !== entry.workingMode ||
|
||||
verdictChanged ||
|
||||
doneRetentionFieldsChanged
|
||||
const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey]
|
||||
const liveRecoveryWorktreeId =
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { removePaneKeys } from './agent-status-pane-keyed-records'
|
||||
import { registryEntryMatchesStatus } from './agent-status-launch-config'
|
||||
import { copyLaunchConfig } from './agent-status-sleeping-records'
|
||||
import { agentVerdictFields } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
export function createAgentStatusProviderSessionActions(
|
||||
runtime: AgentStatusRuntime
|
||||
@@ -113,9 +114,7 @@ export function createAgentStatusProviderSessionActions(
|
||||
? { connectionId: existingRecord.connectionId }
|
||||
: {}),
|
||||
...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}),
|
||||
...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined
|
||||
? { interrupted: existingRecord.interrupted }
|
||||
: {}),
|
||||
...(preservesCompletedRecoveryRecord ? agentVerdictFields(existingRecord) : {}),
|
||||
origin: preservesQuitOrigin ? 'quit' : 'live'
|
||||
}
|
||||
removedLiveStatus = existingStatus !== undefined
|
||||
|
||||
@@ -455,6 +455,61 @@ describe('recordAgentProviderSession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a completed recovery record failed on a same-session update', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
const providerSession = makePiCompatibleProviderSession('pi')
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{ state: 'working', prompt: 'finish the task', agentType: 'pi' },
|
||||
'Pi',
|
||||
{ updatedAt: 20, stateStartedAt: 20 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession }
|
||||
)
|
||||
store.getState().setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'pi',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 30 }
|
||||
},
|
||||
'Pi',
|
||||
{ updatedAt: 30, stateStartedAt: 30 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession }
|
||||
)
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 30 }
|
||||
})
|
||||
|
||||
store
|
||||
.getState()
|
||||
.recordAgentProviderSession(
|
||||
'tab-1:leaf-1',
|
||||
'pi',
|
||||
providerSession,
|
||||
{ updatedAt: 40 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' }
|
||||
)
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
|
||||
providerSession,
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 30 },
|
||||
origin: 'live'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not downgrade a quit recovery record on a same-session update', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type CollectSleepingAgentSessionRecordsOptions
|
||||
} from './agent-status-sleeping-records'
|
||||
import { getLaunchConfigForEntry } from './agent-status-launch-config'
|
||||
import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record'
|
||||
|
||||
export function collectSleepingAgentSessionRecordsForWorktree(
|
||||
@@ -153,7 +154,7 @@ export function collectHibernatedCompletionEvidenceForWorktree(
|
||||
!allowedPaneKeys.has(paneKey) ||
|
||||
entry.state !== 'done' ||
|
||||
agentType === undefined ||
|
||||
entry.interrupted === true
|
||||
agentTurnStoppedByUser(entry)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
SleepingAgentLaunchConfig
|
||||
} from '../../../../shared/agent-session-resume'
|
||||
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
|
||||
import { agentMainAgentVerdict } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
export function launchConfigsEqual(
|
||||
a: SleepingAgentLaunchConfig | undefined,
|
||||
@@ -41,7 +42,7 @@ export function sleepingRecordsEquivalentIgnoringCaptureTime(
|
||||
existing.updatedAt === next.updatedAt &&
|
||||
existing.terminalTitle === next.terminalTitle &&
|
||||
existing.lastAssistantMessage === next.lastAssistantMessage &&
|
||||
existing.interrupted === next.interrupted &&
|
||||
agentMainAgentVerdict(existing) === agentMainAgentVerdict(next) &&
|
||||
existing.origin === next.origin &&
|
||||
launchConfigsEqual(existing.launchConfig, next.launchConfig)
|
||||
)
|
||||
@@ -61,7 +62,7 @@ export function recoveryRecordMatches(
|
||||
existing.worktreeId === next.worktreeId &&
|
||||
existing.tabId === next.tabId &&
|
||||
existing.state === next.state &&
|
||||
existing.interrupted === next.interrupted &&
|
||||
agentMainAgentVerdict(existing) === agentMainAgentVerdict(next) &&
|
||||
agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) &&
|
||||
launchConfigsEqual(existing.launchConfig, next.launchConfig)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { AppState } from '../types'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
agentTurnEndedUncleanly,
|
||||
agentVerdictFields
|
||||
} from '../../../../shared/agent-main-agent-verdict'
|
||||
import {
|
||||
getAgentResumeArgv,
|
||||
isResumableTuiAgent,
|
||||
@@ -57,7 +61,7 @@ export function sleepingRecordFromEntry(args: {
|
||||
? { lastAssistantMessage: args.entry.lastAssistantMessage }
|
||||
: {}),
|
||||
...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}),
|
||||
...(args.entry.interrupted ? { interrupted: true } : {}),
|
||||
...agentVerdictFields(args.entry),
|
||||
...(args.origin ? { origin: args.origin } : {})
|
||||
}
|
||||
}
|
||||
@@ -79,7 +83,7 @@ export function normalizeSleepingAgentSessionCollectOptions(
|
||||
}
|
||||
|
||||
export function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean {
|
||||
return entry.state === 'done' && entry.interrupted !== true
|
||||
return entry.state === 'done' && !agentTurnEndedUncleanly(entry)
|
||||
}
|
||||
|
||||
// Why: a finished pane is passive wake evidence, and a mobile wake background-mounts every passive
|
||||
@@ -98,14 +102,18 @@ export function isDurableSleepingCapture(record: SleepingAgentSessionRecord): bo
|
||||
}
|
||||
|
||||
// Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead
|
||||
// turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the
|
||||
// turn's verdict — and an explicitly slept workspace is never stale at wake, so a row the
|
||||
// user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved
|
||||
// so a done pane wakes lazily in place instead of spawning a new tab.
|
||||
export function manualSleepCaptureEntry(
|
||||
entry: AgentStatusEntry,
|
||||
capturedAt: number
|
||||
): AgentStatusEntry {
|
||||
return { ...entry, updatedAt: capturedAt, interrupted: false }
|
||||
if (!entry.mainAgent) {
|
||||
return { ...entry, updatedAt: capturedAt, interrupted: false }
|
||||
}
|
||||
const { outcome: _outcome, ...mainAgent } = entry.mainAgent
|
||||
return { ...entry, updatedAt: capturedAt, interrupted: false, mainAgent }
|
||||
}
|
||||
|
||||
export function removeSleepingRecordsReplacedByManualWorktreeSleep(
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema'
|
||||
import { buildPaneActivityEvents } from '@/components/activity/activity-pane-events'
|
||||
import { makeTab, makeWorktree } from '@/components/activity/ActivityPrototypePage-test-fixtures'
|
||||
import type { AppState } from '../types'
|
||||
import { agentMeta, agentTitle } from '@/components/activity/activity-thread-presentation'
|
||||
import { activationTreatsNoteAsFinished } from '@/lib/sleeping-agent-pane-ownership'
|
||||
import { resolveAgentStatusLiveEntryStateHistory } from './agent-status-live-entry-state-history'
|
||||
import { deriveAgentStatusLiveFacts } from './agent-status-live-facts'
|
||||
import {
|
||||
recoveryRecordMatches,
|
||||
sleepingRecordsEquivalentIgnoringCaptureTime
|
||||
} from './agent-status-recovery-equivalence'
|
||||
import {
|
||||
isValidCompletedAgentHibernationEntry,
|
||||
manualSleepCaptureEntry,
|
||||
sleepingRecordFromEntry
|
||||
} from './agent-status-sleeping-records'
|
||||
|
||||
// Every copy of a row's verdict carries it: history, sleep records and their equality checks.
|
||||
// A copy that kept only `interrupted` would read a failure as a clean finish.
|
||||
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
const FAILED_MAIN_AGENT = { state: 'done', outcome: 'failure', stateStartedAt: 2_000 } as const
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these readers touch only the three maps given; every tab lookup is answered by the tab the caller passes.
|
||||
const STATE = {
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
tabsByWorktree: {}
|
||||
} as unknown as AppState
|
||||
|
||||
function failedDone(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
state: 'done',
|
||||
prompt: 'ship it',
|
||||
updatedAt: 2_000,
|
||||
stateStartedAt: 2_000,
|
||||
stateHistory: [],
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 2_000 },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function record(overrides: Partial<SleepingAgentSessionRecord> = {}): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'ship it',
|
||||
state: 'done',
|
||||
capturedAt: 3_000,
|
||||
updatedAt: 2_000,
|
||||
origin: 'live',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('a failed done keeps its verdict in every copy', () => {
|
||||
it('copies the verdict into history with the state it leaves', () => {
|
||||
const { history } = resolveAgentStatusLiveEntryStateHistory(
|
||||
failedDone(),
|
||||
{ state: 'working' },
|
||||
3_000
|
||||
)
|
||||
expect(history.at(-1)).toMatchObject({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 2_000 }
|
||||
})
|
||||
})
|
||||
|
||||
it('draws a history done as failed from its own main agent status, not the live row', () => {
|
||||
// The main agent failed before the row settled, so its clock differs from the row's.
|
||||
const mainAgent = { ...FAILED_MAIN_AGENT, stateStartedAt: 1_500 }
|
||||
const entry: AgentStatusEntry = {
|
||||
...failedDone({ state: 'working', stateStartedAt: 3_000, mainAgent: undefined }),
|
||||
stateHistory: resolveAgentStatusLiveEntryStateHistory(
|
||||
failedDone({ mainAgent }),
|
||||
{ state: 'working' },
|
||||
3_000
|
||||
).history
|
||||
}
|
||||
const events = buildPaneActivityEvents({
|
||||
entry,
|
||||
worktree: makeWorktree(),
|
||||
repo: null,
|
||||
tab: makeTab(),
|
||||
agentType: 'claude',
|
||||
agentAlive: true,
|
||||
acknowledgedAt: 0,
|
||||
clearedAt: 0,
|
||||
liveState: null
|
||||
})
|
||||
const done = events.find((event) => event.state === 'done')
|
||||
expect(done?.entry.mainAgent).toEqual(mainAgent)
|
||||
expect(done && agentTitle(done)).toBe('Agent failed')
|
||||
expect(done && agentMeta(done)).toBe('Claude failed')
|
||||
})
|
||||
|
||||
it('records the verdict on a sleep record and never hibernates a failed pane', () => {
|
||||
const sleeping = sleepingRecordFromEntry({
|
||||
state: STATE,
|
||||
entry: failedDone(),
|
||||
worktreeId: 'wt-1',
|
||||
tab: makeTab(),
|
||||
capturedAt: 3_000,
|
||||
origin: 'live'
|
||||
})
|
||||
expect(sleeping?.mainAgent).toEqual({
|
||||
state: 'done',
|
||||
outcome: 'failure',
|
||||
stateStartedAt: 2_000
|
||||
})
|
||||
expect(isValidCompletedAgentHibernationEntry(failedDone())).toBe(false)
|
||||
// A live checkpoint of a turn that failed is still work the user owns.
|
||||
expect(activationTreatsNoteAsFinished(record({ mainAgent: FAILED_MAIN_AGENT }))).toBe(false)
|
||||
expect(activationTreatsNoteAsFinished(record())).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the main agent status through persistence, and drops only a malformed one', () => {
|
||||
const hydrate = (sleeping: unknown) => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
sleepingAgentSessionsByPaneKey: { [PANE_KEY]: sleeping }
|
||||
})
|
||||
return result.ok ? result.value.sleepingAgentSessionsByPaneKey?.[PANE_KEY] : undefined
|
||||
}
|
||||
const sleeping = sleepingRecordFromEntry({
|
||||
state: STATE,
|
||||
entry: failedDone(),
|
||||
worktreeId: 'wt-1',
|
||||
tab: makeTab(),
|
||||
capturedAt: 3_000,
|
||||
origin: 'live'
|
||||
})
|
||||
expect(hydrate(sleeping)?.mainAgent).toEqual(FAILED_MAIN_AGENT)
|
||||
const malformed = hydrate({ ...sleeping, mainAgent: { state: 'done', outcome: 'failure' } })
|
||||
expect(malformed).toMatchObject({ paneKey: PANE_KEY, state: 'done', origin: 'live' })
|
||||
expect(malformed?.mainAgent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves the verdict behind when the user sleeps the workspace', () => {
|
||||
expect(manualSleepCaptureEntry(failedDone(), 4_000).mainAgent).not.toHaveProperty('outcome')
|
||||
})
|
||||
|
||||
// A failure keeps its completion clock, so only a verdict compare sees success -> failure.
|
||||
it('re-retains a done whose verdict changed under an unchanged flag and clock', () => {
|
||||
const failed = failedDone()
|
||||
const withVerdict = (outcome: 'success' | 'failure'): AgentStatusEntry => ({
|
||||
...failed,
|
||||
mainAgent: { state: 'done', outcome, stateStartedAt: 2_000 }
|
||||
})
|
||||
const facts = (existing: AgentStatusEntry, entry: AgentStatusEntry) =>
|
||||
deriveAgentStatusLiveFacts({
|
||||
state: STATE,
|
||||
paneKey: PANE_KEY,
|
||||
entry,
|
||||
existing,
|
||||
launchConfigSource: undefined,
|
||||
retainsResumableRecoveryIdentity: false,
|
||||
commandCodeNewTurn: false,
|
||||
updatedAt: 2_000
|
||||
})
|
||||
expect(facts(withVerdict('success'), withVerdict('failure')).retentionRelevantChange).toBe(true)
|
||||
expect(facts(withVerdict('failure'), withVerdict('failure')).retentionRelevantChange).toBe(
|
||||
false
|
||||
)
|
||||
// A main agent failing while its subagents keep the row working is a verdict change too.
|
||||
const held = (outcome: 'success' | 'failure'): AgentStatusEntry => ({
|
||||
...withVerdict(outcome),
|
||||
state: 'working'
|
||||
})
|
||||
expect(facts(held('success'), held('failure')).retentionRelevantChange).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a verdict change as a different record even when interrupted did not move', () => {
|
||||
const clean = record({ mainAgent: { ...FAILED_MAIN_AGENT, outcome: 'success' } })
|
||||
const failed = record({ mainAgent: FAILED_MAIN_AGENT })
|
||||
expect(sleepingRecordsEquivalentIgnoringCaptureTime(clean, failed)).toBe(false)
|
||||
expect(recoveryRecordMatches(clean, failed)).toBe(false)
|
||||
expect(sleepingRecordsEquivalentIgnoringCaptureTime(failed, { ...failed })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
shouldReplaceRetainedWithLive
|
||||
} from './agent-status-pane-key-tab-binding'
|
||||
import { removePaneKeys } from './agent-status-pane-keyed-records'
|
||||
import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict'
|
||||
|
||||
export function createAgentStatusWorktreeDropActions(
|
||||
runtime: AgentStatusRuntime
|
||||
@@ -63,7 +64,7 @@ export function createAgentStatusWorktreeDropActions(
|
||||
allowedPaneKeys.has(paneKey) &&
|
||||
entry.state === 'done' &&
|
||||
entry.agentType !== undefined &&
|
||||
entry.interrupted !== true
|
||||
!agentTurnStoppedByUser(entry)
|
||||
) {
|
||||
retainedEvidence.set(
|
||||
paneKey,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { AgentStateHistoryEntry, AgentStatusEntry } from './agent-status-types'
|
||||
import { agentTurnStoppedByUser } from './agent-main-agent-verdict'
|
||||
|
||||
/** The subset of a hook entry a completion time is derived from. */
|
||||
export type AgentCompletionSource = Pick<
|
||||
AgentStatusEntry,
|
||||
'state' | 'stateStartedAt' | 'stateHistory' | 'interrupted' | 'sessionBoundary'
|
||||
'state' | 'stateStartedAt' | 'stateHistory' | 'interrupted' | 'mainAgent' | 'sessionBoundary'
|
||||
>
|
||||
|
||||
function mostRecentCompletedTurnInHistory(
|
||||
@@ -13,7 +14,7 @@ function mostRecentCompletedTurnInHistory(
|
||||
for (const row of history ?? []) {
|
||||
if (
|
||||
row.state === 'done' &&
|
||||
row.interrupted !== true &&
|
||||
!agentTurnStoppedByUser(row) &&
|
||||
Number.isFinite(row.startedAt) &&
|
||||
row.startedAt > max
|
||||
) {
|
||||
@@ -29,11 +30,11 @@ function mostRecentCompletedTurnInHistory(
|
||||
* can't rank as freshly done while showing an age past the staleness threshold.
|
||||
*
|
||||
* A completion is only:
|
||||
* - a non-interrupted `done` (its `stateStartedAt` — unmoved by same-state tool/prompt pings); or
|
||||
* - a `done` the user did not stop, failures included (its `stateStartedAt` — unmoved by same-state tool/prompt pings); or
|
||||
* - for a session-boundary `done` (connected idle, not a turn), the real completion it displaced.
|
||||
*/
|
||||
export function agentEntryCompletionAt(entry: AgentCompletionSource): number | null {
|
||||
if (entry.state !== 'done' || entry.interrupted === true) {
|
||||
if (entry.state !== 'done' || agentTurnStoppedByUser(entry)) {
|
||||
return null
|
||||
}
|
||||
if (entry.sessionBoundary === true) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentMainAgentVerdict,
|
||||
agentTurnEndedUncleanly,
|
||||
agentTurnStoppedByUser,
|
||||
agentVerdictDisplayMark,
|
||||
agentVerdictFields,
|
||||
type AgentMainAgentVerdictSource
|
||||
} from './agent-main-agent-verdict'
|
||||
import type { AgentStatusState } from './agent-status-types'
|
||||
import { AGENT_JOURNAL_TURN_OUTCOMES, type AgentJournalTurnOutcome } from './agent-turn-outcome'
|
||||
|
||||
const STATES: AgentStatusState[] = ['working', 'blocked', 'waiting', 'done']
|
||||
const OUTCOMES: (AgentJournalTurnOutcome | undefined)[] = [
|
||||
undefined,
|
||||
...AGENT_JOURNAL_TURN_OUTCOMES
|
||||
]
|
||||
|
||||
// The rule as the plan states it: the main agent's own state decides when a row carries it; a row
|
||||
// without it (a legacy or old-host row) reads the legacy flag, which alone needs the combined `done`.
|
||||
function expected(row: AgentMainAgentVerdictSource): AgentJournalTurnOutcome | null {
|
||||
const legacyFlag = row.state === 'done' && row.interrupted === true ? 'cancellation' : null
|
||||
if (row.mainAgent) {
|
||||
return row.mainAgent.state === 'done' ? (row.mainAgent.outcome ?? legacyFlag) : null
|
||||
}
|
||||
return legacyFlag
|
||||
}
|
||||
|
||||
const MAIN_AGENTS: (AgentMainAgentVerdictSource['mainAgent'] | undefined)[] = [
|
||||
undefined,
|
||||
...STATES.flatMap((state) =>
|
||||
OUTCOMES.map((outcome) => ({ state, ...(outcome ? { outcome } : {}) }))
|
||||
)
|
||||
]
|
||||
|
||||
const PRODUCT: AgentMainAgentVerdictSource[] = STATES.flatMap((state) =>
|
||||
MAIN_AGENTS.flatMap((mainAgent) =>
|
||||
[undefined, false, true].map((interrupted) => ({
|
||||
state,
|
||||
...(interrupted !== undefined ? { interrupted } : {}),
|
||||
...(mainAgent ? { mainAgent } : {})
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
describe('agentMainAgentVerdict', () => {
|
||||
it('decodes the one verdict over (main agent present/absent) x combined state x outcomes x flag', () => {
|
||||
for (const row of PRODUCT) {
|
||||
const verdict = expected(row)
|
||||
const label = JSON.stringify(row)
|
||||
expect(agentMainAgentVerdict(row), label).toBe(verdict)
|
||||
expect(agentTurnEndedUncleanly(row), label).toBe(
|
||||
verdict === 'cancellation' || verdict === 'failure'
|
||||
)
|
||||
expect(agentTurnStoppedByUser(row), label).toBe(verdict === 'cancellation')
|
||||
expect(agentVerdictDisplayMark(row), label).toBe(
|
||||
verdict === 'failure'
|
||||
? 'failed'
|
||||
: verdict === 'cancellation' && row.state === 'done'
|
||||
? 'interrupted'
|
||||
: null
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a main agent that failed while its subagent still works as failed', () => {
|
||||
const row = {
|
||||
state: 'working' as const,
|
||||
mainAgent: { state: 'done' as const, outcome: 'failure' as const }
|
||||
}
|
||||
expect(agentMainAgentVerdict(row)).toBe('failure')
|
||||
expect(agentVerdictDisplayMark(row)).toBe('failed')
|
||||
})
|
||||
|
||||
it('keeps a success or a stop with live subagent work reading working', () => {
|
||||
for (const outcome of ['success', 'cancellation'] as const) {
|
||||
const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } }
|
||||
expect(agentVerdictDisplayMark(row)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('has no verdict while the main agent itself is not done, whatever the combined row says', () => {
|
||||
const row = {
|
||||
state: 'done' as const,
|
||||
interrupted: true,
|
||||
mainAgent: { state: 'working' as const }
|
||||
}
|
||||
expect(agentMainAgentVerdict(row)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the legacy flag only on a done row, and under a done main agent with no outcome', () => {
|
||||
expect(agentMainAgentVerdict({ state: 'working', interrupted: true })).toBeNull()
|
||||
expect(
|
||||
agentMainAgentVerdict({ state: 'done', interrupted: true, mainAgent: { state: 'done' } })
|
||||
).toBe('cancellation')
|
||||
expect(agentMainAgentVerdict({ state: 'done', interrupted: true })).toBe('cancellation')
|
||||
})
|
||||
|
||||
it('prefers the recorded verdict over the legacy flag', () => {
|
||||
expect(
|
||||
agentMainAgentVerdict({
|
||||
state: 'done',
|
||||
interrupted: true,
|
||||
mainAgent: { state: 'done', outcome: 'failure' }
|
||||
})
|
||||
).toBe('failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentVerdictFields', () => {
|
||||
const mainAgent = { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 7 }
|
||||
|
||||
it('copies the whole main agent status and a true legacy flag together', () => {
|
||||
expect(agentVerdictFields({ interrupted: true, mainAgent })).toEqual({
|
||||
interrupted: true,
|
||||
mainAgent
|
||||
})
|
||||
})
|
||||
|
||||
it('copies nothing a row does not carry, and never a false flag', () => {
|
||||
expect(agentVerdictFields({ interrupted: false })).toEqual({})
|
||||
expect(agentVerdictFields({})).toEqual({})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user