From b354aab18e1a691cedb7f40f7579ef7e9b9a6f17 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:37:26 -0700 Subject: [PATCH 1/4] fix(mobile-chat): a failed message's text is added to the draft, and an expired resend no longer blocks that text (#25149) * fix(mobile-chat): a returned message is appended to the draft, and an expired resend id is spent * test(mobile-chat): leave the idempotent append to the shared rule's own test * docs(native-chat): say a whitespace-only draft counts as empty * docs(mobile-chat): don't overclaim where returned text survives --- .../mobile-structured-send-delivery.test.ts | 24 +++++++++++++++++-- .../mobile-structured-send-delivery.ts | 20 ++++++++++++---- .../use-mobile-native-chat-drafts.test.ts | 8 +++---- .../session/use-mobile-native-chat-drafts.ts | 16 ++++++------- ...ile-structured-agent-session-send.test.tsx | 14 ++++++++--- .../native-chat/native-chat-draft-cache.ts | 7 ++---- ...session-withdrawn-message-restore.test.tsx | 6 +++-- .../native-chat/use-native-chat-draft.ts | 8 +++---- src/shared/returned-draft-text.test.ts | 23 ++++++++++++++++++ src/shared/returned-draft-text.ts | 19 +++++++++++++++ 10 files changed, 112 insertions(+), 33 deletions(-) create mode 100644 src/shared/returned-draft-text.test.ts create mode 100644 src/shared/returned-draft-text.ts diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 6305bf75df5..8431e32a7aa 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -169,7 +169,9 @@ describe('mobileStructuredSendDelivery', () => { }) }) - it('never releases an ambiguous id on a later RPC refusal or failure', () => { + it('spends an ambiguous id the host has expired, and says to check the chat', () => { + // The host refuses an expired id on every replay; keeping it would refuse this text forever. + // The earlier attempt may still be in the chat, so the words never say it was not sent. expect( mobileStructuredSendDelivery( { @@ -179,7 +181,25 @@ describe('mobileStructuredSendDelivery', () => { }, true ) - ).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Operation expired' }) + ).toEqual({ + outcome: 'rejected', + operationIdSpent: true, + error: + "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." + }) + }) + + it('never releases an ambiguous id on any other later RPC refusal or failure', () => { + expect( + mobileStructuredSendDelivery( + { + status: 'refused', + code: 'agent_session_operation_conflict', + message: 'Operation conflict' + }, + true + ) + ).toEqual({ outcome: 'rejected', operationIdSpent: false, error: 'Operation conflict' }) expect( mobileStructuredSendDelivery( { status: 'failed', message: 'Your message was not sent. Send it again.' }, diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index 466a0d29ff8..f20dc097188 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -15,11 +15,11 @@ // message is a new message and must carry a new id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, -// keeps it because neither proves a retained delivery did not happen. The -// one exception is a host that refuses the replay's request shape itself -// (an older host's strict schema turning `delivery` away): that host can -// never accept the replay, so keeping the id would only refuse every later -// send of the same text. +// keeps it because neither proves a retained delivery did not happen. Two +// exceptions spend it anyway, because the host can never accept the replay +// and keeping the id would only refuse every later send of the same text: +// a host that refuses the replay's request shape itself (an older host's +// strict schema turning `delivery` away), and an id the host has expired. // unknown — the one answer that KEEPS its id, whether it came from the host or // from an ack-loss on the way back. The message may be with the provider, so // the retry has to stay a replay. Rotating here is what sent one message to a @@ -28,6 +28,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' @@ -52,6 +53,15 @@ export function mobileStructuredSendDelivery( if (refusalState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } + if (retained && result.code === 'agent_session_operation_expired') { + // The host refuses this id for good once its day is up, so keeping it would refuse this text + // forever. The earlier attempt may already be in the chat, so the words say to check first. + return { + outcome: 'rejected', + operationIdSpent: true, + error: agentSessionWriteNoticeEnglish(['sendOutcomeLost']) + } + } return { outcome: 'rejected', operationIdSpent: refusalState === 'settled-rejected' && !retained, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 2547d9191be..a693069dd8c 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -142,7 +142,7 @@ describe('useMobileNativeChatDrafts', () => { expect(state?.composerText).toBe('ping') }) - it('does not clobber newer edits when restoring a rejected send', async () => { + it('appends a rejected send after edits typed while it was in flight', async () => { await mount('a') act(() => state?.setComposerText('ping')) const origin = state?.captureSendOrigin('ping') @@ -157,10 +157,10 @@ describe('useMobileNativeChatDrafts', () => { state?.restoreRejectedDraft(origin, 'ping') } }) - expect(state?.composerText).toBe('newer edit') + expect(state?.composerText).toBe('newer edit\n\nping') }) - it('preserves an intentional clear after a newer edit while a rejection is pending', async () => { + it('returns a rejected send even after a newer edit was cleared', async () => { await mount('a') act(() => state?.setComposerText('ping')) const origin = state?.captureSendOrigin('ping') @@ -177,7 +177,7 @@ describe('useMobileNativeChatDrafts', () => { } }) - expect(state?.composerText).toBe('') + expect(state?.composerText).toBe('ping') }) it('restores a rejected send onto its originating tab only', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index f17e1826a67..027e6a9a1d8 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -8,6 +8,7 @@ import { type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { appendReturnedDraftText } from '../../../src/shared/returned-draft-text' import { countUserTextOccurrences, findLandedImagePreviewEchoes, @@ -77,7 +78,7 @@ export function useMobileNativeChatDrafts(args: { readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null /** Clear the composer at send time, before the RPC settles. */ clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void - /** Put the text back after a definite rejection, unless newer edits exist. */ + /** Put the text back after a definite rejection, after whatever the composer holds now. */ restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void holdUnconfirmedSend: ( @@ -200,13 +201,12 @@ export function useMobileNativeChatDrafts(args: { }, []) const restoreRejectedDraft = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { - // Why: never clobber text the user typed while the rejection was in flight. - setDrafts((previous) => - draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) && - (previous[origin.draftKey] ?? '') === '' - ? { ...previous, [origin.draftKey]: text } - : previous - ) + // Appended, so typing done while the send was in flight stays and the returned text isn't dropped. + setDrafts((previous) => { + const current = previous[origin.draftKey] ?? '' + const next = appendReturnedDraftText(current, text) + return next === current ? previous : { ...previous, [origin.draftKey]: next } + }) }, []) const acceptSend = useCallback( diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 34b66f1dd1f..086482537ef 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -390,7 +390,7 @@ describe('mobile structured send retries', () => { expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) }) - it('keeps an ambiguous id after the host replay window expires', async () => { + it('sends under a new id once the host has expired an ambiguous one', async () => { let attempts = 0 sendRequest.mockImplementation(async (method) => { if (method !== 'agentSession.send') { @@ -400,6 +400,9 @@ describe('mobile structured send retries', () => { if (attempts === 1) { throw markRpcDeliveryUnknown(new Error('Connection closed')) } + if (attempts === 3) { + return sendResult('accepted') + } return ok({ ok: false, refusal: { @@ -413,11 +416,16 @@ describe('mobile structured send retries', () => { await act(async () => { expect(await hook!.sendWithOutcome('old ambiguity')).toBe('unknown') expect(await hook!.sendWithOutcome('old ambiguity')).toBe('rejected') - expect(await hook!.sendWithOutcome('old ambiguity')).toBe('rejected') + expect(await hook!.sendWithOutcome('old ambiguity')).toBe('accepted') }) expect(calls()).toHaveLength(3) - expect(new Set(sentIds()).size).toBe(1) + const [first, replay, fresh] = sentIds() + expect(replay).toBe(first) + expect(fresh).not.toBe(first) + expect(onSendError).toHaveBeenCalledWith( + "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." + ) }) it('does not retain an id when the action budget expires before dispatch', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 333ab7a2c50..7787c97019d 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -5,6 +5,7 @@ import type { JSONContent } from '@tiptap/react' // draft would be lost on every TUI/GUI round-trip. Mirrors the attachment cache // so both halves of an unsent message survive toggles and reconnects. +import { appendReturnedDraftText } from '../../../../shared/returned-draft-text' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' const draftCache = new Map() @@ -28,10 +29,6 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void }) } -export function appendNativeChatDraftText(draft: string, text: string): string { - return draft === '' ? text : `${draft.trimEnd()}\n\n${text}` -} - // Only a write from outside the composer notifies; its own writes already hold the text. const appendListeners = new Map void>>() @@ -42,7 +39,7 @@ export function appendNativeChatDraftCache(scopeKey: string, text: string): void } writeNativeChatDraftCache( scopeKey, - appendNativeChatDraftText(readNativeChatDraftCache(scopeKey), text) + appendReturnedDraftText(readNativeChatDraftCache(scopeKey), text) ) appendListeners.get(scopeKey)?.forEach((listener) => listener(text)) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index dde230110dd..7fe615208c0 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -262,11 +262,13 @@ describe('a message the host withdrew at a Stop', () => { unsubscribe() expect(storedAtRestore).toEqual([id]) - // The drop never reached storage: the next mount gives the text back again rather than losing it. + // The drop never reached storage: after a crash, which loses the in-memory draft, the next mount + // gives the text back again rather than losing it. cleanup() + writeNativeChatDraftCache(PANE, '') writeOutbox(SESSION, beforeDrop) renderOutbox().rerender({ submissions: [withdrawn(id)] }) - expect(readNativeChatDraftCache(PANE)).toBe('hello\n\nhello') + expect(readNativeChatDraftCache(PANE)).toBe('hello') }) it("leaves a message rejected for any other reason to the host's row, and gives nothing back", async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-draft.ts b/src/renderer/src/components/native-chat/use-native-chat-draft.ts index 5653c0a77a8..a5b5d99a143 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-draft.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-draft.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { appendReturnedDraftText } from '../../../../shared/returned-draft-text' import { - appendNativeChatDraftText, readNativeChatDraftCache, subscribeToNativeChatDraftAppend, writeNativeChatDraftCache @@ -45,7 +45,7 @@ export function useNativeChatDraft( pendingAppendRef.current = { scopeKey, text: - pending?.scopeKey === scopeKey ? appendNativeChatDraftText(pending.text, text) : text + pending?.scopeKey === scopeKey ? appendReturnedDraftText(pending.text, text) : text } }), [isComposing, scopeKey] @@ -61,7 +61,7 @@ export function useNativeChatDraft( writeNativeChatDraftCache( scopeKey, pending?.scopeKey === scopeKey - ? appendNativeChatDraftText(resolved, pending.text) + ? appendReturnedDraftText(resolved, pending.text) : resolved ) return resolved @@ -74,7 +74,7 @@ export function useNativeChatDraft( const pending = pendingAppendRef.current pendingAppendRef.current = null if (pending?.scopeKey === scopeKey) { - setDraft((previous) => appendNativeChatDraftText(previous, pending.text)) + setDraft((previous) => appendReturnedDraftText(previous, pending.text)) } }, [scopeKey, setDraft]) diff --git a/src/shared/returned-draft-text.test.ts b/src/shared/returned-draft-text.test.ts new file mode 100644 index 00000000000..79f27507cde --- /dev/null +++ b/src/shared/returned-draft-text.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { appendReturnedDraftText } from './returned-draft-text' + +describe('appendReturnedDraftText', () => { + it('fills an empty composer with the returned text as it was typed', () => { + expect(appendReturnedDraftText('', 'ping ')).toBe('ping ') + expect(appendReturnedDraftText(' \n', 'ping')).toBe('ping') + }) + + it('puts the returned text after a draft, a blank line apart', () => { + expect(appendReturnedDraftText('newer edit\n', 'ping')).toBe('newer edit\n\nping') + }) + + it('adds nothing when the draft already ends with that text', () => { + expect(appendReturnedDraftText('ping', 'ping')).toBe('ping') + expect(appendReturnedDraftText('first\n\nping\n', 'ping ')).toBe('first\n\nping\n') + expect(appendReturnedDraftText('ping', '')).toBe('ping') + }) + + it('still appends text that only ends a line of the draft', () => { + expect(appendReturnedDraftText('say ping', 'ping')).toBe('say ping\n\nping') + }) +}) diff --git a/src/shared/returned-draft-text.ts b/src/shared/returned-draft-text.ts new file mode 100644 index 00000000000..4dc67520d7f --- /dev/null +++ b/src/shared/returned-draft-text.ts @@ -0,0 +1,19 @@ +/** + * Puts text a chat handed back after what the composer already holds, a blank line apart. It never + * replaces typed text (a whitespace-only draft counts as empty), and adds nothing when the draft + * already ends with that text, so handing the same text back twice leaves one copy. + */ +export function appendReturnedDraftText(draft: string, text: string): string { + const returned = text.trimEnd() + if (returned.trim() === '') { + return draft + } + const kept = draft.trimEnd() + if (kept === '') { + return text + } + if (kept === returned || kept.endsWith(`\n\n${returned}`)) { + return draft + } + return `${kept}\n\n${text}` +} From ca2cdc0ad9638695306da1be553aeee8b4723818 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:57:25 -0700 Subject: [PATCH 2/4] feat(native-chat): answer /context in chat view for OpenClaude and OMP (#22294) * feat(native-chat): answer /context in chat view for Claude Over a terminal-backed chat, Claude's /context paints a grid only the hidden terminal sees and records nothing in the transcript, so the chat showed nothing. The chat host now answers it itself: the transcript decoder keeps each assistant response's API usage and model, and the composer replies with the same estimate the CLI statusline shows (input + cache tokens against the model's window) instead of typing the command into the PTY. The command is also listed in the curated Claude catalog so the slash menu offers it. * fix(native-chat): only state a /context window the host can establish The /context answer sized the window from the transcript's model id, which never carries the [1m] suffix, so a 1M session read as 200k (e.g. 450k / 200k, 225%). The window now comes from the session's tracked model resolved through the host CLI's own model listing (the resolvedModel field the list_models probe already returned), and only a [1m] id that matches the model the transcript last answered with is sized; anything else reports the used figure alone. Also: - Offer /context only in the desktop terminal-backed composer; the shared catalog is mobile's too, and mobile cannot answer it. - Answer a typed /context even with images attached, as the picker does, and keep the attachments armed instead of sending them to the terminal. - Report nothing until a response lands after a /compact or /clear sent from the chat, instead of the pre-compaction size. * test(native-chat): pin resolvedModel through Claude model discovery * fix(native-chat): say /context is unavailable when the host never reports usage A host that predates usage decoding, or a scraped view, decodes Claude's replies without a model or usage, so the chat promised an answer after the next response that would never come. Once the agent has answered and no answer names its model, say usage is not available for this session. * feat(native-chat): answer /context for OMP instead of Claude Claude and Codex answer /context themselves in the structured chat lane, so the terminal-backed Claude answer, its [1m] window rule, and the resolved-model plumbing through the model listing go away. OMP's /context only paints a panel inside its TUI. The OMP decoder now keeps the provider, model and usage of each reply (none for aborted or errored replies, which OMP's own gauge skips) and turns compaction rows into the existing compaction boundary. The OMP model listing keeps each model's context window, and the chat answers /context from the last reply's prompt size against the window of the provider/model that served it. * feat(native-chat): show OpenClaude's own /context report in chat OpenClaude writes its /context grid to the transcript as a row, which the chat hid as harness noise. The live message preparation now turns the stdout row that answers an opted-in command (/context for OpenClaude) into command output with the ANSI colors stripped, and the notice row renders command output in monospace so the grid keeps its columns. Replies to other local commands stay hidden, and Claude is not opted in. OpenClaude's slash menu now lists /context; Claude's does not. * test(native-chat): pin OpenClaude /context output through live message preparation * test(native-chat): pin the OMP context window through model discovery * test(native-chat): pin monospace rendering of command output * test(native-chat): restore the notice row suite the command-output test replaced The previous commit rewrote NativeChatNoticeRow.test.tsx wholesale, deleting main's compaction, tone, plan, provider-notice and old-reader coverage. Restore it and pin monospace command output through the message row instead. * fix(mobile): keep OpenClaude /context off mobile, which cannot show its report The shared slash catalog now lists /context for OpenClaude because the desktop chat surfaces its transcript reply. Mobile's transcript fold does not, so the command did nothing there. Mobile now offers the catalog minus commands answered only by a surfaced reply, which returns its OpenClaude menu and send classification to main's behavior. * refactor(native-chat): drop the unread estimated flag from context usage Left from the earlier design; the answer text states the estimate itself. * fix(native-chat): keep /model replies hidden after an OpenClaude /context Skill surfacing ran first and rewrote non-catalog envelopes such as /model into plain user text, so command-output pairing no longer saw them. A later /model reply then paired with the earlier /context envelope and appeared in the chat. Pair outputs against the raw envelopes before skill surfacing. * fix(native-chat): word the OMP /context answer for a compacted session and show it as an aside After a compaction the agent has already answered, so the unknown-usage line now promises the next response instead. The host answer is one sentence, so it draws as the muted line it replaces rather than a monospace grid. The provider/model selector now comes from one helper shared by the OMP listing parser and the answer, and new tests drive real OpenClaude and OMP transcript lines through the decoders. Co-Authored-By: Claude * refactor(native-chat): pair command replies by row link and declare /context replies on the catalog A `` row now answers the command row its `parentUuid` names, carried on decoded messages as the optional `parentId`, instead of the newest command row at or before its timestamp. A reply with no loaded linked row (older host, command outside the tail window) stays hidden. How each agent's `/context` is answered is declared once, as `reply` on its catalog row (`composer` for OMP, `transcript` for OpenClaude). The slash menu, the composer's answer, the transcript surfacing and the mobile catalog all derive from it, and both send paths share one intercept. Mobile no longer lists OMP `/context`, which did nothing there. * perf(native-chat): skip command-output surfacing for agents that declare no transcript reply Every Claude-format row now carries its parent link, so once a session holds any local-command reply (/model, /compact) the surfacing pass built a map of every message on each live update and changed nothing for agents like Claude. Agents whose catalog declares no transcript reply now return before the scan. --------- Co-authored-by: Claude --- .../src/session/MobileNativeChatComposer.tsx | 4 +- ...le-native-chat-send-classification.test.ts | 14 +- .../mobile-native-chat-send-classification.ts | 15 +- ...openclaude-0.31.0-local-command-rows.jsonl | 13 + ...ine-decoders-claude-command-output.test.ts | 60 ++++ .../transcript-line-decoders-claude.ts | 9 +- .../transcript-line-decoders-omp.ts | 71 ++++- .../transcript-line-decoders.omp.test.ts | 115 +++++++- .../worker-transcript-payload.test.ts | 7 + .../worker-transcript-payload.ts | 3 + .../native-chat/NativeChatComposer.tsx | 3 + .../native-chat/NativeChatNoticeRow.test.tsx | 21 ++ .../native-chat/NativeChatNoticeRow.tsx | 8 + .../native-chat/NativeChatResolvedView.tsx | 8 +- .../native-chat/native-chat-command-marker.ts | 18 +- .../native-chat/native-chat-composer-types.ts | 8 +- .../native-chat-context-command.test.tsx | 261 ++++++++++++++++++ .../native-chat-context-usage-answer.ts | 38 +++ ...tive-chat-live-message-preparation.test.ts | 63 +++++ .../native-chat-live-message-preparation.ts | 6 +- .../native-chat/native-chat-pending.test.ts | 11 + .../native-chat-pty-session-options.ts | 6 +- .../native-chat-session-option-discovery.ts | 1 + ...ive-chat-session-option-enrichment.test.ts | 31 +++ .../use-native-chat-local-command-answer.ts | 104 +++++++ ...use-native-chat-picker-command-dispatch.ts | 27 +- .../use-native-chat-pty-composer-send.ts | 10 +- src/renderer/src/i18n/locales/en.json | 6 + src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/fr.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + .../agent-session-option-catalog-omp.test.ts | 27 ++ .../agent-session-option-catalog-types.ts | 2 + src/shared/commit-message-agent-spec.ts | 4 + src/shared/native-chat-agent-profiles.test.ts | 36 +++ src/shared/native-chat-agent-profiles.ts | 27 +- src/shared/native-chat-command-output.test.ts | 104 +++++++ src/shared/native-chat-command-output.ts | 75 +++++ src/shared/native-chat-context-usage.test.ts | 105 +++++++ src/shared/native-chat-context-usage.ts | 54 ++++ src/shared/native-chat-slash-commands.test.ts | 7 + src/shared/native-chat-slash-commands.ts | 19 +- src/shared/native-chat-types.ts | 10 + src/shared/omp-model-list-probe.ts | 19 +- 46 files changed, 1427 insertions(+), 33 deletions(-) create mode 100644 src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl create mode 100644 src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-context-command.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts create mode 100644 src/shared/native-chat-command-output.test.ts create mode 100644 src/shared/native-chat-command-output.ts create mode 100644 src/shared/native-chat-context-usage.test.ts create mode 100644 src/shared/native-chat-context-usage.ts diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index b04e09d80e2..ae05d9622c1 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -11,7 +11,6 @@ import { } from 'react-native' import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native' import { colors, radii, spacing } from '../theme/mobile-theme' -import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles' import { structuredSlashCommands } from '../../../src/shared/structured-agent-session-composer' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' import { @@ -31,6 +30,7 @@ import { } from './MobileNativeChatSessionOptionPickers' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' +import { getMobileNativeChatCommands } from './mobile-native-chat-send-classification' import { keepHeldPressThroughLongPress } from './held-press-long-press' const NO_FILE_PATHS: string[] = [] @@ -137,7 +137,7 @@ export function MobileNativeChatComposer({ structuredCommands !== undefined ? structuredSlashCommands(structuredCommands, agent) : agent - ? getVerifiedNativeChatCommands(agent) + ? getMobileNativeChatCommands(agent) : [] // Why: Codex's catalog is 45 commands and this list is a plain ScrollView // (~5 rows visible), so an uncapped `/` would mount every row and diff --git a/mobile/src/session/mobile-native-chat-send-classification.test.ts b/mobile/src/session/mobile-native-chat-send-classification.test.ts index 8ad61d01c61..bdd2168532d 100644 --- a/mobile/src/session/mobile-native-chat-send-classification.test.ts +++ b/mobile/src/session/mobile-native-chat-send-classification.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' +import { + classifyMobileNativeChatSend, + getMobileNativeChatCommands +} from './mobile-native-chat-send-classification' describe('classifyMobileNativeChatSend', () => { it('recognizes OMP selectors and context commands without claiming generic help', () => { @@ -24,6 +27,15 @@ describe('classifyMobileNativeChatSend', () => { expect(classifyMobileNativeChatSend('claude', '/diff')).toBe('unknown-token') }) + it('leaves /context off mobile, which has no way to show its answer', () => { + // OpenClaude's report is a transcript row; OMP's is answered by the desktop composer. + for (const agent of ['openclaude', 'omp']) { + expect(classifyMobileNativeChatSend(agent, '/context')).toBe('unknown-token') + expect(classifyMobileNativeChatSend(agent, '/compact')).toBe('command') + expect(getMobileNativeChatCommands(agent).map(({ name }) => name)).not.toContain('context') + } + }) + it('keeps prose as chat, including leading-whitespace slash text', () => { expect(classifyMobileNativeChatSend('claude', 'hello there')).toBe('chat') expect(classifyMobileNativeChatSend('claude', ' /clear is a command')).toBe('chat') diff --git a/mobile/src/session/mobile-native-chat-send-classification.ts b/mobile/src/session/mobile-native-chat-send-classification.ts index 2a34efabdd2..cd3ae68294b 100644 --- a/mobile/src/session/mobile-native-chat-send-classification.ts +++ b/mobile/src/session/mobile-native-chat-send-classification.ts @@ -5,16 +5,23 @@ // the optimistic echo would never reconcile. import { - getNativeChatAgentProfile, - getVerifiedNativeChatCommands + getAgentAnsweredNativeChatCommands, + getNativeChatAgentProfile } from '../../../src/shared/native-chat-agent-profiles' import { classifyNativeChatSend, - type NativeChatSendClassification + type NativeChatSendClassification, + type SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' export type { NativeChatSendClassification } +/** The curated catalog mobile offers over a terminal session. Why: mobile has + * neither the desktop composer's answers nor its transcript reply rows. */ +export function getMobileNativeChatCommands(agent: string): readonly SlashCommandSuggestion[] { + return getAgentAnsweredNativeChatCommands(agent) +} + /** Classify a mobile chat send for the tab's agent. Mobile has no skill picker, * so there is never a picker-origin token that reclassifies a `/token` as chat. */ export function classifyMobileNativeChatSend( @@ -27,7 +34,7 @@ export function classifyMobileNativeChatSend( const profile = getNativeChatAgentProfile(agent) return classifyNativeChatSend( text, - getVerifiedNativeChatCommands(agent), + getMobileNativeChatCommands(agent), null, profile?.skillPrefix ?? null ) diff --git a/src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl b/src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl new file mode 100644 index 00000000000..78307deee6c --- /dev/null +++ b/src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl @@ -0,0 +1,13 @@ +{"type":"mode","mode":"normal","sessionId":"00000000-0000-4000-8000-000000000000"} +{"type":"file-history-snapshot","messageId":"b57b5064-f405-496e-972e-a8f0401f5195","snapshot":{"messageId":"b57b5064-f405-496e-972e-a8f0401f5195","trackedFileBackups":{},"timestamp":"2026-09-23T00:21:16.966Z"},"isSnapshotUpdate":false} +{"parentUuid":null,"isSidechain":false,"type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"77469cbb-626a-48d7-a43c-c15c7614a95f","timestamp":"2026-09-23T00:21:16.965Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"77469cbb-626a-48d7-a43c-c15c7614a95f","isSidechain":false,"type":"user","message":{"role":"user","content":"/context\n context\n "},"uuid":"b57b5064-f405-496e-972e-a8f0401f5195","timestamp":"2026-09-23T00:21:16.965Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"b57b5064-f405-496e-972e-a8f0401f5195","isSidechain":false,"type":"user","message":{"role":"user","content":" \u001b[1mContext Usage\u001b[22m\n\u001b[38;5;244m⛁ ⛁ ⛁ ⛁ ⛁ ⛁ \u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[39m \u001b[38;5;246mgpt-4o · 18.3k/128k tokens (14%)\u001b[39m\n\n\u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[38;5;220m⛀ \u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m\u001b[3mEstimated usage by category\u001b[23m\u001b[39m\n \u001b[38;5;244m⛁\u001b[39m System prompt: \u001b[38;5;246m8k tokens (6.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛁\u001b[39m System tools: \u001b[38;5;246m10k tokens (7.8%)\u001b[39m\n \u001b[38;5;220m⛁\u001b[39m Skills: \u001b[38;5;246m304 tokens (0.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛶\u001b[39m Free space: \u001b[38;5;246m63.3k (49.5%)\u001b[39m\n \u001b[38;5;246m⛝ Autocompact buffer: 46.4k tokens (36.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\n\u001b[1mSkills\u001b[22m\u001b[38;5;246m · /skills\u001b[39m"},"uuid":"ed4aeadc-1c29-4cd6-a074-fc179a168707","timestamp":"2026-09-23T00:21:16.965Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"type":"file-history-snapshot","messageId":"46776637-ece8-482c-b346-65c2938f80aa","snapshot":{"messageId":"46776637-ece8-482c-b346-65c2938f80aa","trackedFileBackups":{},"timestamp":"2026-09-23T00:21:28.270Z"},"isSnapshotUpdate":false} +{"parentUuid":"ed4aeadc-1c29-4cd6-a074-fc179a168707","isSidechain":false,"type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"b42e5475-0947-44ae-a15e-24f1e74d6a72","timestamp":"2026-09-23T00:21:28.270Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"b42e5475-0947-44ae-a15e-24f1e74d6a72","isSidechain":false,"type":"user","message":{"role":"user","content":"/cost\n cost\n "},"uuid":"46776637-ece8-482c-b346-65c2938f80aa","timestamp":"2026-09-23T00:21:28.269Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"46776637-ece8-482c-b346-65c2938f80aa","isSidechain":false,"type":"system","subtype":"local_command","content":"\u001b[2mTotal cost: $0.0000\u001b[22m\n\u001b[2mTotal duration (API): 0s\u001b[22m\n\u001b[2mTotal duration (wall): 24s\u001b[22m\n\u001b[2mTotal code changes: 0 lines added, 0 lines removed\u001b[22m\n\nUsage: 0 input, 0 output","level":"info","timestamp":"2026-09-23T00:21:28.270Z","uuid":"a1ea4717-298a-4f3e-b5d7-f8a6b2936f10","isMeta":false,"userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"type":"file-history-snapshot","messageId":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","snapshot":{"messageId":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","trackedFileBackups":{},"timestamp":"2026-09-23T00:21:39.716Z"},"isSnapshotUpdate":false} +{"parentUuid":"a1ea4717-298a-4f3e-b5d7-f8a6b2936f10","isSidechain":false,"type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"a3492e9f-3fd7-4c15-a04f-dd6b170326ff","timestamp":"2026-09-23T00:21:39.715Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"a3492e9f-3fd7-4c15-a04f-dd6b170326ff","isSidechain":false,"type":"user","message":{"role":"user","content":"/context\n context\n "},"uuid":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","timestamp":"2026-09-23T00:21:39.715Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","isSidechain":false,"type":"user","message":{"role":"user","content":" \u001b[1mContext Usage\u001b[22m\n\u001b[38;5;244m⛁ ⛁ ⛁ ⛁ ⛁ ⛁ \u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[39m \u001b[38;5;246mgpt-4o · 18.9k/128k tokens (15%)\u001b[39m\n\n\u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[38;5;220m⛀ \u001b[38;5;135m⛀ \u001b[38;5;246m⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m\u001b[3mEstimated usage by category\u001b[23m\u001b[39m\n \u001b[38;5;244m⛁\u001b[39m System prompt: \u001b[38;5;246m8k tokens (6.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛁\u001b[39m System tools: \u001b[38;5;246m10k tokens (7.8%)\u001b[39m\n \u001b[38;5;220m⛁\u001b[39m Skills: \u001b[38;5;246m304 tokens (0.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;135m⛁\u001b[39m Messages: \u001b[38;5;246m607 tokens (0.5%)\u001b[39m\n \u001b[38;5;246m⛶\u001b[39m Free space: \u001b[38;5;246m62.7k (49.0%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛝ Autocompact buffer: 46.4k tokens (36.2%)\u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\n\u001b[1mSkills\u001b[22m\u001b[38;5;246m · /skills\u001b[39m"},"uuid":"2b989a6e-c7d6-45b6-8367-f656471e4e2b","timestamp":"2026-09-23T00:21:39.715Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} diff --git a/src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts b/src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts new file mode 100644 index 00000000000..ff35d83822c --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { surfaceNativeChatCommandOutputs } from '../../shared/native-chat-command-output' +import { stripNoiseMessages } from '../../shared/native-chat-noise' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' + +// OpenClaude 0.31.0 running `/context`, `/cost`, `/context` with no model call; +// session id and cwd scrubbed. `/cost` replies in a `system` row. +const FIXTURE_LINES = readFileSync( + join(__dirname, '__fixtures__', 'openclaude-0.31.0-local-command-rows.jsonl'), + 'utf8' +) + .split('\n') + .filter((line) => line.trim() !== '') + +function decoded(lines: readonly string[]): NativeChatMessage[] { + return lines.flatMap((line, index) => decodeClaudeTranscriptLine(line, `f${index}`) ?? []) +} + +function commandRow(messages: NativeChatMessage[], index: number): NativeChatMessage | undefined { + return messages.filter((message) => + message.blocks.some((block) => block.type === 'text' && block.text.includes('')) + )[index] +} + +describe("OpenClaude's local command rows through the transcript decoder", () => { + const messages = decoded(FIXTURE_LINES) + + it("links each reply row to its command row's id", () => { + const replies = messages.filter((message) => + message.blocks.some( + (block) => block.type === 'text' && block.text.includes('') + ) + ) + expect(replies.map(({ parentId }) => parentId)).toEqual([ + commandRow(messages, 0)?.id, + commandRow(messages, 2)?.id + ]) + }) + + it('shows both /context reports as plain command output and nothing else', () => { + const visible = stripNoiseMessages(surfaceNativeChatCommandOutputs(messages, 'openclaude')) + expect(visible.map(({ role }) => role)).toEqual(['system', 'system']) + const totals = visible.map(({ blocks: [block] }) => { + expect(block).toMatchObject({ type: 'text', presentation: 'command-output' }) + const text = block?.type === 'text' ? block.text : '' + expect(text.split('\n')[0]).toBe('Context Usage') + expect(text).not.toContain('\u001b') + return /gpt-4o · \S+ tokens \(\d+%\)/.exec(text)?.[0] + }) + expect(totals).toEqual(['gpt-4o · 18.3k/128k tokens (14%)', 'gpt-4o · 18.9k/128k tokens (15%)']) + }) + + it('keeps the reply hidden for a host that sends no row link', () => { + const unlinked = messages.map(({ parentId: _parentId, ...message }) => message) + expect(stripNoiseMessages(surfaceNativeChatCommandOutputs(unlinked, 'openclaude'))).toEqual([]) + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index bc7a988e79e..8693b4296a9 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -85,6 +85,9 @@ export function decodeClaudeTranscriptLine( } const timestamp = parseTimestamp(record.timestamp) const recordMessageId = extractString(record.uuid) ?? fallbackId + // Why: the row link pairs a local command's reply with its command row. + const parentUuid = extractString(record.parentUuid) + const parent = parentUuid ? { parentId: parentUuid } : {} if (claudeInterruptedMessageId(record)) { // Why: keep Claude's injected boilerplate out of the user-bubble path while // preserving the interruption as a quiet, replayable conversation status. @@ -93,7 +96,8 @@ export function decodeClaudeTranscriptLine( role: 'system', blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], timestamp, - source: 'transcript' + source: 'transcript', + ...parent } } const message = asRecord(record.message) @@ -127,7 +131,8 @@ export function decodeClaudeTranscriptLine( role: claudeMessageRole(role, blocks), blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks, timestamp, - source: 'transcript' + source: 'transcript', + ...parent } } diff --git a/src/main/native-chat/transcript-line-decoders-omp.ts b/src/main/native-chat/transcript-line-decoders-omp.ts index 60fcd736f35..bfe44fd87bf 100644 --- a/src/main/native-chat/transcript-line-decoders-omp.ts +++ b/src/main/native-chat/transcript-line-decoders-omp.ts @@ -12,6 +12,7 @@ import { type NativeChatBlock, type NativeChatMessage } from '../../shared/native-chat-types' +import type { AgentSessionTokenUsage } from '../../shared/agent-session-context-usage' import { asRecord, extractString, @@ -24,21 +25,35 @@ import { toolResultOutput } from './transcript-record-blocks' * omp session rows: `type: 'message'` turns carrying user/assistant/toolResult/ * developer records with text, thinking, toolCall and image content blocks, the * content-less bash/python execution cells, plus the `type: 'custom_message'` - * rows extensions inject into the conversation. - * Session bookkeeping rows (session_init, mode_change, compaction, custom) are - * skipped, as are records of an unrecognized type. + * rows extensions inject into the conversation, and a `compaction` row as the + * boundary it draws in the context. Other session bookkeeping rows (session_init, + * mode_change, custom) are skipped, as are records of an unrecognized type. */ export function decodeOmpTranscriptLine( line: string, fallbackId: string ): NativeChatMessage | null { const record = parseJsonObject(line) - if (!record || (record.type !== 'message' && record.type !== 'custom_message')) { + if ( + !record || + (record.type !== 'message' && record.type !== 'custom_message' && record.type !== 'compaction') + ) { return null } const id = extractString(record.id) ?? fallbackId const timestamp = parseTimestamp(record.timestamp) + if (record.type === 'compaction') { + // Why: responses before this row measured a context omp has since replaced. + return { + id, + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp, + source: 'transcript' + } + } + if (record.type === 'custom_message') { // Why: these extension-authored turns reach the model, and omp's own // transcript renders them — but only when `display` is set; the rest are @@ -124,8 +139,52 @@ export function decodeOmpTranscriptLine( } : null } - const messageRole = role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : 'system' - return { id, role: messageRole, blocks, timestamp, source: 'transcript' } + if (role === 'assistant') { + return { + id, + role: 'assistant', + blocks, + timestamp, + source: 'transcript', + ...ompServing(message) + } + } + return { id, role: role === 'user' ? 'user' : 'system', blocks, timestamp, source: 'transcript' } +} + +/** Which provider/model served an assistant record, and what prompt it read. */ +function ompServing( + message: Record +): Pick { + const model = extractString(message.model) + const provider = extractString(message.provider) + // Why: omp's own context gauge skips aborted and errored replies' accounting. + const usage = + message.stopReason === 'aborted' || message.stopReason === 'error' + ? null + : ompTokenUsage(message.usage) + return { + ...(model ? { model } : {}), + ...(provider ? { provider } : {}), + ...(usage ? { usage } : {}) + } +} + +function ompTokenUsage(value: unknown): AgentSessionTokenUsage | null { + const usage = asRecord(value) + if (!usage) { + return null + } + return { + inputTokens: tokenCount(usage.input), + cacheCreationInputTokens: tokenCount(usage.cacheWrite), + cacheReadInputTokens: tokenCount(usage.cacheRead), + outputTokens: tokenCount(usage.output) + } +} + +function tokenCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 } /** A bash/python execution cell: the invocation, then its captured output. */ diff --git a/src/main/native-chat/transcript-line-decoders.omp.test.ts b/src/main/native-chat/transcript-line-decoders.omp.test.ts index 04e28dfbc22..6b890146493 100644 --- a/src/main/native-chat/transcript-line-decoders.omp.test.ts +++ b/src/main/native-chat/transcript-line-decoders.omp.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest' +import { deriveNativeChatContextUsage } from '../../shared/native-chat-context-usage' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { ompModelSelector, parseOmpModelList } from '../../shared/omp-model-list-probe' import { decodeOmpTranscriptLine } from './transcript-line-decoders' const line = (record: unknown): string => JSON.stringify(record) @@ -20,7 +23,6 @@ describe('decodeOmpTranscriptLine', () => { expect( decodeOmpTranscriptLine(line({ type: 'custom', customType: 'tool_execution_start' }), 'f') ).toBeNull() - expect(decodeOmpTranscriptLine(line({ type: 'compaction', shortSummary: 's' }), 'f')).toBeNull() expect(decodeOmpTranscriptLine(line({ type: 'a-type-from-the-future' }), 'f')).toBeNull() }) @@ -277,4 +279,115 @@ describe('decodeOmpTranscriptLine', () => { expect(decoded?.id).toBe('fallback-9') expect(decoded?.timestamp).toBeNull() }) + + describe('what served a reply, and the prompt it read', () => { + // An assistant row as OMP 17.0.5 writes it, provider payload and ids trimmed. + const reply = (extra: Record = {}): string => + message('assistant', [{ type: 'text', text: 'The `value` field is set to **42**.' }], { + api: 'openai-codex-responses', + provider: 'openai-codex', + model: 'gpt-5.5', + usage: { + input: 680, + output: 16, + cacheRead: 20992, + cacheWrite: 0, + totalTokens: 21688, + cost: { + input: 0.0034, + output: 0.00048, + cacheRead: 0.010496, + cacheWrite: 0, + total: 0.014376 + } + }, + stopReason: 'stop', + contextSnapshot: { promptTokens: 21672, nonMessageTokens: 20318 }, + ...extra + }) + + it('keeps the provider, model and usage of a completed reply', () => { + expect(decodeOmpTranscriptLine(reply(), 'f')).toMatchObject({ + role: 'assistant', + provider: 'openai-codex', + model: 'gpt-5.5', + usage: { + inputTokens: 680, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 20992, + outputTokens: 16 + } + }) + }) + + it('drops the accounting of an aborted or errored reply, keeping its model', () => { + for (const stopReason of ['aborted', 'error']) { + const decoded = decodeOmpTranscriptLine(reply({ stopReason }), 'f') + expect(decoded?.model).toBe('gpt-5.5') + expect(decoded).not.toHaveProperty('usage') + } + }) + + it("measures the prompt OMP recorded, against its listing's window for that model", () => { + // A `omp models --json` row as OMP 17.0.5 prints it. + const listing = parseOmpModelList( + line({ + models: [ + { + provider: 'openai-codex', + id: 'gpt-5.5', + selector: 'openai-codex/gpt-5.5', + name: 'GPT-5.5', + contextWindow: 272000 + } + ] + }) + ) + const windowFor = (served: NativeChatMessage): number | null => + listing.find(({ id }) => id === ompModelSelector(served.provider, served.model)) + ?.contextWindowTokens ?? null + const decoded = decodeOmpTranscriptLine(reply(), 'f')! + // 21,672 is the `contextSnapshot.promptTokens` OMP stored on this reply. + expect(deriveNativeChatContextUsage([decoded], windowFor)).toEqual({ + usedTokens: 21672, + windowTokens: 272000, + percentage: 8 + }) + const compaction = decodeOmpTranscriptLine(line({ type: 'compaction', id: 'c' }), 'f')! + expect(deriveNativeChatContextUsage([decoded, compaction], windowFor)).toBeNull() + }) + + it('puts none of it on turns that are not replies', () => { + const decoded = decodeOmpTranscriptLine( + message('user', [{ type: 'text', text: 'hi' }], { model: 'gpt-5.5', usage: { input: 5 } }), + 'f' + ) + expect(decoded).not.toHaveProperty('usage') + expect(decoded).not.toHaveProperty('model') + }) + }) + + it('marks a compaction row as the boundary it draws', () => { + // Field shape of `appendCompaction` in OMP 17.0.5; summary text elided. + const decoded = decodeOmpTranscriptLine( + line({ + type: 'compaction', + id: 'cmp-1', + parentId: 'rec-9', + timestamp: '2026-07-16T01:00:00.000Z', + summary: '…', + shortSummary: '…', + firstKeptEntryId: 'rec-7', + tokensBefore: 180000 + }), + 'f' + ) + expect(decoded).toEqual({ + id: 'cmp-1', + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp: Date.parse('2026-07-16T01:00:00.000Z'), + source: 'transcript' + }) + }) }) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index 802b724a2aa..d2b1523a96c 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -236,6 +236,7 @@ describe('worker transcript wire bounds', () => { const message = { id: `${transcriptPath}:0000000000000042`, turnId: `${transcriptPath}:0000000000000001`, + parentId: `${transcriptPath}:0000000000000041`, role: 'assistant' as const, timestamp: null, source: 'transcript' as const, @@ -248,6 +249,12 @@ describe('worker transcript wire bounds', () => { expect(first.messages).toEqual(second.messages) expect(first.messages[0]?.id).toMatch(/^worker-message-/) expect(first.messages[0]?.turnId).toMatch(/^worker-message-/) + // The parent link stays joinable to the parent row's opaque id. + const parent = boundWorkerTranscriptMessages( + [{ ...message, id: message.parentId, parentId: undefined }], + transcriptPath + ) + expect(first.messages[0]?.parentId).toBe(parent.messages[0]?.id) expect(first.messages[0]?.blocks[0]).toEqual({ type: 'image-ref' }) expect(JSON.stringify(first)).not.toContain('Users') expect(first.warnings).toEqual( diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index 684dfc771e2..2a05d74a645 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -114,6 +114,9 @@ function boundMessage( ...served, id: boundIdentifier(message.id, transcriptPath, state), ...(message.turnId ? { turnId: boundIdentifier(message.turnId, transcriptPath, state) } : {}), + ...(message.parentId + ? { parentId: boundIdentifier(message.parentId, transcriptPath, state) } + : {}), blocks: blocks.map((block) => boundBlock(block, state)) } } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d..29f7b099109 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -63,6 +63,7 @@ const NativeChatComposerPane = forwardRef { expect(disclosure?.querySelector('summary')).not.toHaveTextContent('Check the configuration') expect(disclosure?.querySelector('pre')).toHaveTextContent('Check the configuration') }) + it('keeps the column layout of command output in monospace', () => { + const text = + 'Context Usage\n⛁ ⛁ ⛶ gpt-4o · 16.6k/128k tokens (13%)\n ⛁ Skills: 304 tokens' + render( + + ) + const output = screen.getByText(/Context Usage/) + expect(output.tagName).toBe('PRE') + expect(output).toHaveClass('font-mono') + expect(output.textContent).toBe(text) + }) // The host's text is only for a client that can't word the row itself. it.each([ ['history-repaired', "Part of this chat's history couldn't be loaded."], diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index bc9b0a348d6..b63eb73abd1 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -49,6 +49,14 @@ export function NativeChatNoticeRow({ ) } + if (block.presentation === 'command-output') { + // Why: command output is laid out in columns; proportional type breaks its grid. + return ( +
+        {block.text}
+      
+ ) + } if (isAgentSessionHostStatusPresentation(block.presentation)) { // The look of any other host status line; only the words are the reader's. return ( diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 7c9c3f8337e..6adf30e1e50 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -46,6 +46,7 @@ import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { useNativeChatLinkActions } from './use-native-chat-link-actions' import type { NativeChatResolvedViewProps } from './native-chat-view-types' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { useNativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' import { matchNativeChatSplitShortcut } from './native-chat-split-shortcut' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { formatShortcutLabel } from '@/hooks/useShortcutLabel' @@ -180,8 +181,8 @@ export function NativeChatResolvedView({ [record] ) const onSlashCommand = useCallback( - (command: string) => { - setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) + (command: string, output?: string) => { + setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command, Date.now(), output)) }, [commandMarkerScope] ) @@ -203,6 +204,8 @@ export function NativeChatResolvedView({ ? sessionWithLaunchPrompt : { ...sessionWithLaunchPrompt, messages } }, [sessionWithLaunchPrompt, commandMarkers]) + // Why: answer from the conversation the pane shows, so a `/clear` sent here reads as reset. + const answerLocally = useNativeChatLocalCommandAnswer(agent, sessionAfterCommandBoundaries) const launchPromptDeliveryNotices = useNativeChatLaunchPromptDeliveryNotice( paneLaunchPrompt?.failed ? launchPromptMessage?.id : null, sessionAfterCommandBoundaries.messages @@ -396,6 +399,7 @@ export function NativeChatResolvedView({ onOptimisticSendCanceled={delivery.cancel} optimisticSendOutcome={delivery} onSlashCommand={onSlashCommand} + answerCommandLocally={answerLocally} onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft }} diff --git a/src/renderer/src/components/native-chat/native-chat-command-marker.ts b/src/renderer/src/components/native-chat/native-chat-command-marker.ts index 9625cee5166..33461479ff6 100644 --- a/src/renderer/src/components/native-chat/native-chat-command-marker.ts +++ b/src/renderer/src/components/native-chat/native-chat-command-marker.ts @@ -14,6 +14,8 @@ export type NativeChatCommandMarker = { /** The command as typed, e.g. `/clear`. */ command: string sentAt: number + /** What the host answered, for a command the agent never saw. */ + output?: string } export type NativeChatCommandMarkerScope = { @@ -39,7 +41,8 @@ export function readCommandMarkerCache( export function appendCommandMarkerCache( scope: NativeChatCommandMarkerScope, command: string, - sentAt = Date.now() + sentAt = Date.now(), + output?: string ): NativeChatCommandMarker[] { commandMarkerCounter += 1 const key = commandMarkerScopeKey(scope) @@ -47,7 +50,12 @@ export function appendCommandMarkerCache( // are not transcript turns, so their local feedback needs a pane-scoped cache. const next = [ ...(commandMarkerCache.get(key) ?? []), - { id: `${sentAt}-${commandMarkerCounter}`, command, sentAt } + { + id: `${sentAt}-${commandMarkerCounter}`, + command, + sentAt, + ...(output === undefined ? {} : { output }) + } ].slice(-COMMAND_MARKER_LIMIT) // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, // sessionId changes on every /clear) is ephemeral and was never evicted, so it @@ -92,14 +100,16 @@ export function applyCommandMarkerBoundaries( /** Render command markers as compact `system` messages. The `system` role draws * as a muted aside (not a user bubble); the text avoids the harness noise - * prefixes so stripNoiseMessages keeps it. */ + * prefixes so stripNoiseMessages keeps it. A host-answered command shows its + * answer in place of the `Ran` line: the answer is the feedback. */ export function commandMarkersAsMessages( markers: readonly NativeChatCommandMarker[] ): NativeChatMessage[] { return markers.map((marker) => ({ id: `command:${marker.id}`, role: 'system' as const, - blocks: [{ type: 'text' as const, text: `Ran ${marker.command}` }], + // Why: a host answer is one sentence, so it reads as the aside it replaces, not a grid. + blocks: [{ type: 'text' as const, text: marker.output ?? `Ran ${marker.command}` }], timestamp: marker.sentAt, source: 'scrape' as const })) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 922d16924f3..3ecde4cc22f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -9,6 +9,7 @@ import type { } from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import type { NativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' export type NativeChatOptionPickerRequest = { id: string @@ -65,8 +66,11 @@ export type NativeChatComposerProps = { optimisticSendOutcome?: NativeChatOptimisticSendOutcome /** Remove an optimistic echo when its delayed submit is canceled. */ onOptimisticSendCanceled?: (pendingId: string) => void - /** Record a dispatched slash command that does not create a chat turn. */ - onSlashCommand?: (command: string) => void + /** Record a dispatched slash command that does not create a chat turn; `output` + * carries the host's answer when the agent never saw the command. */ + onSlashCommand?: (command: string, output?: string) => void + /** The host's own answer to a command the agent must not see, or null to send it. */ + answerCommandLocally?: NativeChatLocalCommandAnswer /** Picker-only agent commands continue in the hosted TUI after dispatch. */ onSwitchToTerminal?: () => void /** Reads the hosted TUI's current rendered screen when chat is entered. */ diff --git a/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx b/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx new file mode 100644 index 00000000000..3630860d0a7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx @@ -0,0 +1,261 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options' +import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' +import { + answerNativeChatLocalCommand, + type NativeChatLocalCommandAnswer +} from './use-native-chat-local-command-answer' +import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' +import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' + +const mocks = vi.hoisted(() => ({ + sendNativeChatMessage: vi.fn(() => ({ id: 'send' })), + sendNativeChatMessageWithImageAttachments: vi.fn(() => ({ id: 'image-send' })) +})) + +vi.mock('../../store', () => { + const state = { clearNativeChatLaunchDraft: vi.fn() } + return { useAppStore: { getState: () => state } } +}) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: mocks.sendNativeChatMessage, + sendNativeChatTypedCommand: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: mocks.sendNativeChatMessageWithImageAttachments +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) + +// `omp models --json` rows as OMP 17.0.5 prints them, reduced to what discovery keeps. +const DISCOVERED: CatalogModel[] = [ + { id: 'openai-codex/gpt-5.5', label: 'GPT-5.5', contextWindowTokens: 272_000, options: [] }, + { id: 'openai-codex/gpt-5.4', label: 'GPT-5.4', contextWindowTokens: 1_000_000, options: [] }, + { id: 'anthropic/claude-opus', label: 'Claude Opus', options: [] } +] + +// A reply as OMP records it: provider and model on the row, usage per request. +function response( + promptTokens: number, + timestamp: number, + model = 'gpt-5.5', + provider = 'openai-codex' +): NativeChatMessage { + return { + id: `a-${timestamp}`, + role: 'assistant', + blocks: [{ type: 'text', text: 'ok' }], + timestamp, + source: 'transcript', + model, + provider, + usage: { + inputTokens: 680, + cacheCreationInputTokens: 0, + cacheReadInputTokens: promptTokens - 680, + outputTokens: 16 + } + } +} + +function liveSurface(models?: CatalogModel[]) { + return createNativeChatPtySessionOptions({ + agent: 'omp', + scopeKey: 'pty-context', + ...(models ? { initialModels: models } : {}), + mode: 'live', + reportedValues: { model: 'openai-codex/gpt-5.5' }, + dispatchCommand: vi.fn() + })! +} + +beforeEach(() => { + clearNativeChatSessionOptionCacheForTests() + mocks.sendNativeChatMessage.mockClear() + mocks.sendNativeChatMessageWithImageAttachments.mockClear() +}) + +describe('the context window the model listing states', () => { + it('reads the window of a listed model once discovery has run', () => { + const surface = liveSurface(DISCOVERED) + expect(surface.contextWindowTokens('openai-codex/gpt-5.4')).toBe(1_000_000) + expect(surface.contextWindowTokens('anthropic/claude-opus')).toBeNull() + expect(surface.contextWindowTokens('unlisted/model')).toBeNull() + }) + + it('knows no window before discovery', () => { + expect(liveSurface().contextWindowTokens('openai-codex/gpt-5.5')).toBeNull() + }) + + it('follows a later discovery result', () => { + const surface = liveSurface() + surface.replaceModels(DISCOVERED) + expect(surface.contextWindowTokens('openai-codex/gpt-5.5')).toBe(272_000) + }) +}) + +describe('answerNativeChatLocalCommand', () => { + const surface = liveSurface(DISCOVERED) + + function answer(args: { messages: NativeChatMessage[]; command?: string }): string | null { + return answerNativeChatLocalCommand({ + agent: 'omp', + command: args.command ?? '/context', + messages: args.messages, + contextWindowTokens: surface.contextWindowTokens + }) + } + + it('states the window of the model that served the last response', () => { + expect(answer({ messages: [response(21_672, 10)] })).toBe( + 'Context: 21.7k / 272k tokens (8%), estimated from the last response.' + ) + // A switch in the TUI shows up on the next reply, not in the picker. + expect(answer({ messages: [response(21_672, 10), response(450_000, 20, 'gpt-5.4')] })).toBe( + 'Context: 450k / 1M tokens (45%), estimated from the last response.' + ) + }) + + it('gives the used figure alone when the listing states no window', () => { + const used = 'Context: 54.6k tokens used, estimated from the last response.' + expect(answer({ messages: [response(54_600, 10, 'claude-opus', 'anthropic')] })).toBe(used) + // The same bare model under a provider the listing does not have. + expect(answer({ messages: [response(54_600, 10, 'gpt-5.5', 'openrouter')] })).toBe(used) + }) + + it('reports nothing between a compaction and the next response', () => { + const unavailable = + 'Context usage is not known yet. It becomes available after the agent next responds.' + const compaction: NativeChatMessage = { + id: 'c', + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp: 20, + source: 'transcript' + } + const messages = [response(200_000, 10), compaction] + expect(answer({ messages })).toBe(unavailable) + expect(answer({ messages: [...messages, response(30_000, 30)] })).toBe( + 'Context: 30k / 272k tokens (11%), estimated from the last response.' + ) + }) + + it('does not promise a later answer when the host never reports usage', () => { + const pending = + 'Context usage is not known yet. It becomes available after the agent next responds.' + const { + model: _model, + provider: _provider, + usage: _usage, + ...olderHostRow + } = response(54_600, 10) + // An older host decodes the same reply without its model or usage. + expect(answer({ messages: [olderHostRow] })).toBe( + 'Context usage is not available for this session.' + ) + expect(answer({ messages: [] })).toBe(pending) + // A live preview is not an answer the host decoded. + expect(answer({ messages: [{ ...olderHostRow, source: 'hook' }] })).toBe(pending) + }) + + it('leaves every other command, and other agents, to the agent', () => { + expect(answer({ messages: [], command: '/context all' })).not.toBeNull() + expect(answer({ messages: [], command: '/compact' })).toBeNull() + expect(answer({ messages: [], command: '/contextual' })).toBeNull() + expect(answer({ messages: [], command: 'what is my /context' })).toBeNull() + expect( + answerNativeChatLocalCommand({ + agent: 'openclaude', + command: '/context', + messages: [], + contextWindowTokens: surface.contextWindowTokens + }) + ).toBeNull() + }) +}) + +describe('host-answered /context in the composer', () => { + const target = { ptyId: 'pty-1', settings: {} } + + function composerArgs(answerCommandLocally: NativeChatLocalCommandAnswer) { + return { + agent: 'omp' as const, + disabled: false, + isDispatchingSessionOption: false, + resolveTarget: () => target, + onSlashCommand: vi.fn(), + answerCommandLocally, + sessionOptionsSurface: liveSurface(DISCOVERED), + trackPendingSend: vi.fn(), + setHistory: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + clearSkillOrigin: vi.fn(), + clearImageAttachments: vi.fn(), + setNotice: vi.fn() + } + } + + function typedSend(draft: string, answerCommandLocally: NativeChatLocalCommandAnswer) { + const args = { + ...composerArgs(answerCommandLocally), + draft, + imageAttachments: [{ path: '/tmp/shot.png' }], + launchDraftResolved: true, + classifySend: () => 'command' as const, + terminalTabId: 'tab-1' + } + const { result } = renderHook(() => useNativeChatPtyComposerSend(args)) + result.current() + return args + } + + it('answers a typed /context in the chat with the listing windows, keeping attachments', () => { + const answerCommandLocally = vi.fn( + () => 'Context: 450k / 1M tokens (45%)' + ) + const args = typedSend('/context', answerCommandLocally) + expect(answerCommandLocally).toHaveBeenCalledWith('/context', expect.any(Function)) + const windowFor = answerCommandLocally.mock.calls[0]![1] + expect(windowFor('openai-codex/gpt-5.4')).toBe(1_000_000) + expect(args.onSlashCommand).toHaveBeenCalledWith('/context', 'Context: 450k / 1M tokens (45%)') + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessageWithImageAttachments).not.toHaveBeenCalled() + expect(args.clearImageAttachments).not.toHaveBeenCalled() + expect(args.setDraft).toHaveBeenCalledWith('') + }) + + it('still sends a command the host does not answer, attachments included', () => { + const args = typedSend('/compact', () => null) + expect(mocks.sendNativeChatMessageWithImageAttachments).toHaveBeenCalled() + expect(args.onSlashCommand).toHaveBeenCalledWith('/compact') + }) + + it('answers a picked /context the same way as a typed one', () => { + const answerCommandLocally = vi.fn( + () => 'Context: 450k / 1M tokens (45%)' + ) + const args = { ...composerArgs(answerCommandLocally), setActiveSuggestion: vi.fn() } + const { result } = renderHook(() => useNativeChatPickerCommandDispatch(args)) + result.current({ + kind: 'command', + id: 'command:context', + name: 'context', + token: '/context', + skillCollision: false + }) + expect(answerCommandLocally.mock.calls[0]![1]('openai-codex/gpt-5.5')).toBe(272_000) + expect(args.onSlashCommand).toHaveBeenCalledWith('/context', 'Context: 450k / 1M tokens (45%)') + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(args.clearImageAttachments).not.toHaveBeenCalled() + expect(args.setHistory).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts b/src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts new file mode 100644 index 00000000000..aa24ef1f641 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts @@ -0,0 +1,38 @@ +import { translate } from '@/i18n/i18n' +import type { NativeChatContextUsage } from '../../../../shared/native-chat-context-usage' +import { formatContextTokenCount } from './native-chat-context-usage-summary' + +/** The chat host's answer to `/context` over a terminal session. */ +export function formatNativeChatContextUsageAnswer(usage: NativeChatContextUsage | null): string { + if (!usage) { + return translate( + 'components.native-chat.context.unavailable', + 'Context usage is not known yet. It becomes available after the agent next responds.' + ) + } + // Why: a guessed window would print a plausible but wrong percentage. + if (usage.windowTokens === null || usage.percentage === null) { + return translate( + 'components.native-chat.context.used', + 'Context: {{used}} tokens used, estimated from the last response.', + { used: formatContextTokenCount(usage.usedTokens) } + ) + } + return translate( + 'components.native-chat.context.summary', + 'Context: {{used}} / {{window}} tokens ({{percent}}%), estimated from the last response.', + { + used: formatContextTokenCount(usage.usedTokens), + window: formatContextTokenCount(usage.windowTokens), + percent: String(usage.percentage) + } + ) +} + +/** For a session whose messages will never carry usage, so no later reply helps. */ +export function formatNativeChatContextUsageUnreported(): string { + return translate( + 'components.native-chat.context.unreported', + 'Context usage is not available for this session.' + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts new file mode 100644 index 00000000000..ea18d1a175f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { prepareNativeChatLiveMessages } from './native-chat-live-message-preparation' +import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' + +const row = (id: string, text: string, source: NativeChatMessage['source'] = 'transcript') => ({ + id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }], + timestamp: 100, + source +}) + +const CONTEXT_ROWS: NativeChatMessage[] = [ + row('b-envelope', '/context\n'), + { + ...row( + 'a-stdout', + ' \u001b[1mContext Usage\u001b[22m' + ), + parentId: 'b-envelope' + } +] + +function visibleText(agent: 'openclaude' | 'claude', messages: NativeChatMessage[]): string[] { + return createNativeChatMessageListProjection()( + prepareNativeChatLiveMessages(messages, agent) + ).conversation.flatMap((message) => + message.blocks.map((block) => (block.type === 'text' ? block.text : '')) + ) +} + +describe('prepareNativeChatLiveMessages command output', () => { + it("lists OpenClaude's /context report in the chat", () => { + expect(visibleText('openclaude', CONTEXT_ROWS)).toEqual(['Context Usage']) + // A live hook preview alongside the transcript takes the reassembly path. + expect( + visibleText('openclaude', [...CONTEXT_ROWS, row('hook', 'next prompt', 'hook')]) + ).toEqual(['Context Usage', 'next prompt']) + }) + + it('keeps a later /model reply hidden after a /context report', () => { + const modelRows = [ + { ...row('d-envelope', '/model'), timestamp: 300 }, + { + ...row('c-stdout', 'Set model to gpt-4o'), + timestamp: 300, + parentId: 'd-envelope' + } + ] + // `/model` is outside the catalog, so its envelope surfaces as the typed turn + // and its linked reply stays hidden. + expect(visibleText('openclaude', [...CONTEXT_ROWS, ...modelRows])).toEqual([ + 'Context Usage', + '/model' + ]) + }) + + it('keeps the reply row hidden for Claude', () => { + // Outside Claude's catalog the envelope reads as the typed turn, as on main. + expect(visibleText('claude', CONTEXT_ROWS)).toEqual(['/context']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts index 3744c70d570..bed16d90663 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts @@ -1,5 +1,6 @@ import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { surfaceSkillInvocationUserTurns } from '../../../../shared/native-chat-command-envelope' +import { surfaceNativeChatCommandOutputs } from '../../../../shared/native-chat-command-output' import { normalizeImageTranscriptMessages } from '../../../../shared/native-chat-image-transcript-markers' import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types' import { assembleNativeChatSession } from './native-chat-session-assembler' @@ -9,7 +10,10 @@ export function prepareNativeChatLiveMessages( agent: AgentType ): NativeChatMessage[] { const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name)) - const surfaced = surfaceSkillInvocationUserTurns(messages, commandNames) + const surfaced = surfaceNativeChatCommandOutputs( + surfaceSkillInvocationUserTurns(messages, commandNames), + agent + ) const normalized = normalizeImageTranscriptMessages(surfaced) if (!hasMixedSources(normalized)) { return normalized diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 8a6d57f4604..496b4304fb8 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -802,3 +802,14 @@ describe('scope-cache key counts stay bounded (memory-leak regression)', () => { ) }) }) + +describe('commandMarkersAsMessages with a host answer', () => { + it('shows the answer in place of the Ran line', () => { + const [message] = commandMarkersAsMessages([ + { id: 'c2', command: '/context', sentAt: 9, output: 'Context: 54.6k / 200k tokens (27%)' } + ]) + expect(message?.role).toBe('system') + // Plain text: it draws as the muted aside the Ran line would have been. + expect(message?.blocks).toEqual([{ type: 'text', text: 'Context: 54.6k / 200k tokens (27%)' }]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts index 0fe37c388e6..242fc813df3 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -40,6 +40,8 @@ export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & { recordOutgoingCommand(command: string): void reportSessionOptions(values: Record): void replaceModels(models: CatalogModel[]): void + /** The context window the host's model listing states for `modelId`, or null. */ + contextWindowTokens(modelId: string): number | null } export type CreateNativeChatPtySessionOptionsArgs = { @@ -221,6 +223,8 @@ export function createNativeChatPtySessionOptions( modelsAreDiscovered = true untrackRetiredModel() publish() - } + }, + contextWindowTokens: (modelId) => + models.find((model) => model.id === modelId)?.contextWindowTokens ?? null } } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index 4fa7e279878..b4520a225c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -155,6 +155,7 @@ export async function discoverNativeChatCatalogModels( label: model.label, ...(model.description ? { description: model.description } : {}), ...(model.isDefault ? { isDefault: true as const } : {}), + ...(model.contextWindowTokens ? { contextWindowTokens: model.contextWindowTokens } : {}), options: agent === 'claude' ? createClaudeCatalogOptions({ diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 15d890f319e..aaa8c7c04a8 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -164,6 +164,37 @@ describe('native chat session option enrichment', () => { ) }) + it('carries the context window OMP lists for each model, absent from an older host', async () => { + mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ + success: true, + catalogOrigin: 'probe', + models: [ + { + id: 'openai-codex/gpt-5.5', + label: 'GPT-5.5', + description: 'openai-codex', + contextWindowTokens: 272_000 + }, + { id: 'openai-codex/gpt-5.4', label: 'GPT-5.4', description: 'openai-codex' } + ] + }) + const models = await discoverNativeChatCatalogModels('omp', { + settings: {}, + worktreeId: 'repo::/worktree', + worktreePath: '/worktree' + }) + expect(models).toEqual([ + { + id: 'openai-codex/gpt-5.5', + label: 'GPT-5.5', + description: 'openai-codex', + contextWindowTokens: 272_000, + options: [] + }, + { id: 'openai-codex/gpt-5.4', label: 'GPT-5.4', description: 'openai-codex', options: [] } + ]) + }) + it('uses only discovered Claude rows and capabilities per host', async () => { mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ success: true, diff --git a/src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts b/src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts new file mode 100644 index 00000000000..0a330c1fd39 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts @@ -0,0 +1,104 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { getNativeChatCommandReply } from '../../../../shared/native-chat-agent-profiles' +import { deriveNativeChatContextUsage } from '../../../../shared/native-chat-context-usage' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { ompModelSelector } from '../../../../shared/omp-model-list-probe' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { + formatNativeChatContextUsageAnswer, + formatNativeChatContextUsageUnreported +} from './native-chat-context-usage-answer' +import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' + +/** The context window the host's model listing states for a model id, or null. */ +export type NativeChatModelContextWindow = (modelId: string) => number | null + +/** Answers a command the chat host owns over a terminal session, or null to send it. */ +export type NativeChatLocalCommandAnswer = ( + command: string, + contextWindowTokens: NativeChatModelContextWindow +) => string | null + +/** OMP's `/context` paints a panel the chat never sees, so the host replies from the + * prompt size the last response reported, against the window of the model that + * served it. Null for any command whose catalog row is not composer-answered. */ +export function answerNativeChatLocalCommand(args: { + agent: AgentType + command: string + messages: readonly NativeChatMessage[] + contextWindowTokens: NativeChatModelContextWindow +}): string | null { + const name = /^\/(\S+)/.exec(args.command)?.[1] + if (name !== 'context' || getNativeChatCommandReply(args.agent, name) !== 'composer') { + return null + } + if (!sessionReportsUsage(args.messages)) { + return formatNativeChatContextUsageUnreported() + } + const usage = deriveNativeChatContextUsage(args.messages, (message) => { + // Why: several providers share a bare model id; the listing keys by selector. + const selector = ompModelSelector(message.provider, message.model) + return selector ? args.contextWindowTokens(selector) : null + }) + return formatNativeChatContextUsageAnswer(usage) +} + +/** False when the agent has answered yet no answer names its model: a host that + * predates usage decoding, or a scraped view, will never report usage. */ +function sessionReportsUsage(messages: readonly NativeChatMessage[]): boolean { + let answered = false + for (const message of messages) { + if (message.role !== 'assistant' || message.source === 'hook') { + continue + } + if (message.model !== undefined) { + return true + } + answered = true + } + return !answered +} + +export function useNativeChatLocalCommandAnswer( + agent: AgentType, + { messages }: { messages: readonly NativeChatMessage[] } +): NativeChatLocalCommandAnswer { + return useCallback( + (command, contextWindowTokens) => + answerNativeChatLocalCommand({ agent, command, messages, contextWindowTokens }), + [agent, messages] + ) +} + +/** The send paths' shared intercept: a composer-answered command is answered in + * place of reaching the PTY. Attachments stay armed for the next prompt. Returns + * false when the command must be sent. */ +export function answerNativeChatCommandInComposer(args: { + draft: string + answerCommandLocally?: NativeChatLocalCommandAnswer + sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null + onSlashCommand?: (command: string, output?: string) => void + setHistory: Dispatch> + setDraft: (value: string) => void + setCaret: Dispatch> + clearSkillOrigin: () => void + setNotice: Dispatch> +}): boolean { + const command = args.draft.trim() + const answer = + args.answerCommandLocally?.( + command, + (modelId) => args.sessionOptionsSurface?.contextWindowTokens(modelId) ?? null + ) ?? null + if (answer === null) { + return false + } + args.onSlashCommand?.(command, answer) + args.setHistory((previous) => pushHistory(previous, args.draft)) + args.setDraft('') + args.setCaret(0) + args.clearSkillOrigin() + args.setNotice(null) + return true +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts index 0022514fd97..1c983733dd9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -17,13 +17,18 @@ import { } from './native-chat-composer-state' import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' +import { + answerNativeChatCommandInComposer, + type NativeChatLocalCommandAnswer +} from './use-native-chat-local-command-answer' export function useNativeChatPickerCommandDispatch(args: { agent: AgentType disabled: boolean isDispatchingSessionOption: boolean resolveTarget: () => NativeChatResolvedTarget | null - onSlashCommand?: (command: string) => void + onSlashCommand?: (command: string, output?: string) => void + answerCommandLocally?: NativeChatLocalCommandAnswer sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] setHistory: Dispatch> @@ -40,6 +45,7 @@ export function useNativeChatPickerCommandDispatch(args: { isDispatchingSessionOption, resolveTarget, onSlashCommand, + answerCommandLocally, sessionOptionsSurface, trackPendingSend, setHistory, @@ -57,6 +63,24 @@ export function useNativeChatPickerCommandDispatch(args: { if (!target || disabled || isDispatchingSessionOption) { return } + if ( + answerNativeChatCommandInComposer({ + draft: text, + answerCommandLocally, + sessionOptionsSurface, + onSlashCommand, + setHistory, + setDraft, + setCaret, + clearSkillOrigin, + setNotice + }) + ) { + emitNativeChatPickerItemAccepted({ agent, itemKind: 'command' }) + emitNativeChatSendClassified({ agent, outcome: 'command' }) + setActiveSuggestion(0) + return + } trackPendingSend( agent === 'codex' ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) @@ -83,6 +107,7 @@ export function useNativeChatPickerCommandDispatch(args: { }, [ agent, + answerCommandLocally, clearImageAttachments, clearSkillOrigin, disabled, diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts index 385396190e4..e38e0282f4d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -19,6 +19,10 @@ import type { NativeChatPickerState } from './use-native-chat-picker-state' import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' import type { NativeChatOptimisticSendOutcome } from './native-chat-composer-types' +import { + answerNativeChatCommandInComposer, + type NativeChatLocalCommandAnswer +} from './use-native-chat-local-command-answer' export function useNativeChatPtyComposerSend(args: { agent: AgentType @@ -33,7 +37,8 @@ export function useNativeChatPtyComposerSend(args: { classifySend: NativeChatPickerState['classifySend'] onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined optimisticSendOutcome?: NativeChatOptimisticSendOutcome - onSlashCommand?: (command: string) => void + onSlashCommand?: (command: string, output?: string) => void + answerCommandLocally?: NativeChatLocalCommandAnswer sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null terminalTabId: string trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] @@ -59,6 +64,9 @@ export function useNativeChatPtyComposerSend(args: { return } const classification = args.classifySend(text) + if (classification === 'command' && answerNativeChatCommandInComposer(args)) { + return + } const { sendOptions: launchSendOptions } = resolveNativeChatLaunchDraftSend({ launchDraft: args.launchDraft, launchDraftResolved: args.launchDraftResolved, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a623d23664c..25087a71ea2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18139,6 +18139,12 @@ "subtitle": "Files are added to your message as paths the agent can read." }, "copyCode": "Copy code", + "context": { + "unavailable": "Context usage is not known yet. It becomes available after the agent next responds.", + "summary": "Context: {{used}} / {{window}} tokens ({{percent}}%), estimated from the last response.", + "used": "Context: {{used}} tokens used, estimated from the last response.", + "unreported": "Context usage is not available for this session." + }, "goal": { "placeholder": "Describe your goal, define measurable outcomes for best results", "clear": "Clear goal", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 40f587ab41b..ec2d25bbb34 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17954,6 +17954,12 @@ "subtitle": "Los archivos se añaden a tu mensaje como rutas que el agente puede leer." }, "copyCode": "Copiar código", + "context": { + "unavailable": "El uso del contexto aún no se conoce. Estará disponible cuando el agente vuelva a responder.", + "summary": "Contexto: {{used}} / {{window}} tokens ({{percent}} %), estimado a partir de la última respuesta.", + "used": "Contexto: {{used}} tokens usados, estimado a partir de la última respuesta.", + "unreported": "El uso del contexto no está disponible para esta sesión." + }, "goal": { "placeholder": "Describe tu objetivo y define resultados medibles para obtener mejores resultados", "clear": "Borrar objetivo", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 606c0455062..333d821d346 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18016,6 +18016,12 @@ "subtitle": "Les fichiers sont ajoutés à votre message sous forme de chemins que l'agent peut lire." }, "copyCode": "Copier le code", + "context": { + "unavailable": "L'utilisation du contexte n'est pas encore connue. Elle sera disponible après la prochaine réponse de l'agent.", + "summary": "Contexte : {{used}} / {{window}} tokens ({{percent}} %), estimation basée sur la dernière réponse.", + "used": "Contexte : {{used}} tokens utilisés, estimation basée sur la dernière réponse.", + "unreported": "L'utilisation du contexte n'est pas disponible pour cette session." + }, "goal": { "placeholder": "Décrivez votre objectif, définissez des résultats mesurables pour de meilleurs résultats", "clear": "Objectif clair", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6d1b62a60fa..5d1678b46d0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17952,6 +17952,12 @@ "subtitle": "ファイルは、Agent が読み取ることができるパスとしてメッセージに追加されます。" }, "copyCode": "コードをコピーする", + "context": { + "unavailable": "コンテキストの使用量はまだ不明です。Agent が次に応答すると表示されます。", + "summary": "コンテキスト: {{used}} / {{window}} トークン ({{percent}}%)、最後の応答から推定。", + "used": "コンテキスト: {{used}} トークン使用済み、最後の応答から推定。", + "unreported": "このセッションではコンテキストの使用量を表示できません。" + }, "goal": { "placeholder": "目標を説明し、最良の結果を得るために測定可能な結果を​​定義する", "clear": "明確な目標", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d9827f02336..5cd0371e7be 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17952,6 +17952,12 @@ "subtitle": "파일은 에이전트가 읽을 수 있는 경로로 메시지에 추가됩니다." }, "copyCode": "코드 복사", + "context": { + "unavailable": "컨텍스트 사용량을 아직 알 수 없습니다. agent가 다음에 응답하면 확인할 수 있습니다.", + "summary": "컨텍스트: {{used}}/{{window}} 토큰 ({{percent}}%), 마지막 응답 기준 추정치입니다.", + "used": "컨텍스트: {{used}} 토큰 사용, 마지막 응답 기준 추정치입니다.", + "unreported": "이 세션에서는 컨텍스트 사용량을 확인할 수 없습니다." + }, "goal": { "placeholder": "목표를 설명하고, 최상의 결과를 위해 측정 가능한 결과를 정의하세요.", "clear": "명확한 목표", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 795419d9a84..3ce2dbdb7ba 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17917,6 +17917,12 @@ "subtitle": "文件将作为代理可以读取的路径添加到您的消息中。" }, "copyCode": "复制代码", + "context": { + "unavailable": "上下文用量暂时未知,将在智能体下次响应后提供。", + "summary": "上下文:{{used}}/{{window}} token({{percent}}%),根据上一次响应的估算值。", + "used": "上下文:已用 {{used}} token,根据上一次响应的估算值。", + "unreported": "此会话无法提供上下文用量。" + }, "goal": { "placeholder": "描述您的目标,定义可衡量的结果以获得最佳结果", "clear": "明确的目标", diff --git a/src/shared/agent-session-option-catalog-omp.test.ts b/src/shared/agent-session-option-catalog-omp.test.ts index b5bd44e32f3..d874a1bd0c6 100644 --- a/src/shared/agent-session-option-catalog-omp.test.ts +++ b/src/shared/agent-session-option-catalog-omp.test.ts @@ -43,6 +43,33 @@ describe('omp model list probe', () => { ]) }) + it('keeps the context window each row states', () => { + // Row shape as `omp models --json` prints it (OMP 17.0.5). + const listing = JSON.stringify({ + models: [ + { + provider: 'openai-codex', + id: 'gpt-5.5', + selector: 'openai-codex/gpt-5.5', + name: 'GPT-5.5', + contextWindow: 272000, + maxTokens: 128000 + }, + { provider: 'openai-codex', id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 1000000 }, + { provider: 'zai', id: 'glm', name: 'GLM', contextWindow: 0 }, + { provider: 'zai', id: 'glm-air', name: 'GLM Air', contextWindow: '128000' } + ] + }) + expect( + parseOmpModelList(listing).map(({ id, contextWindowTokens }) => ({ id, contextWindowTokens })) + ).toEqual([ + { id: 'openai-codex/gpt-5.5', contextWindowTokens: 272000 }, + { id: 'openai-codex/gpt-5.4', contextWindowTokens: 1000000 }, + { id: 'zai/glm', contextWindowTokens: undefined }, + { id: 'zai/glm-air', contextWindowTokens: undefined } + ]) + }) + it('tolerates an update notice printed ahead of the JSON', () => { const noisy = `Package updates are available. Run omp update\n${LISTING}\n` expect(parseOmpModelList(noisy).map(({ id }) => id)).toEqual([ diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index eafa7b349f1..cd426987b81 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -49,6 +49,8 @@ export type CatalogModel = { label: string description?: string isDefault?: boolean + /** Tokens the model's context window holds, where the host's listing states it. */ + contextWindowTokens?: number options: CatalogOption[] } diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 2d788739149..bd47acc5bac 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -40,6 +40,8 @@ export type CommitMessageModel = { /** Set when the listing marks this as the id the CLI runs with no --model flag. * Optional so an older remote host that never reports it simply omits it. */ isDefault?: boolean + /** Tokens the model's context window holds, where the listing states it. */ + contextWindowTokens?: number } export type CommitMessageAgentSpec = { @@ -78,6 +80,8 @@ export type CommitMessageModelCapability = { supportsFastMode?: boolean /** Absent from an older remote host, which simply yields no default to display. */ isDefault?: boolean + /** Absent from an older remote host; readers then treat the window as unknown. */ + contextWindowTokens?: number } export type CommitMessageAgentCapability = { diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts index 546a4bf9db0..00b006bec31 100644 --- a/src/shared/native-chat-agent-profiles.test.ts +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + getAgentAnsweredNativeChatCommands, getHostClaimedNativeChatCommands, getNativeChatAgentProfile, + getNativeChatCommandReply, getVerifiedNativeChatCommands } from './native-chat-agent-profiles' @@ -56,3 +58,37 @@ describe('host-claimed native chat commands', () => { expect(names('grok')).toEqual([]) }) }) + +describe('native chat command replies', () => { + it('declares /context as answered by the composer for OMP and by a transcript row for OpenClaude', () => { + expect(getNativeChatCommandReply('omp', 'context')).toBe('composer') + expect(getNativeChatCommandReply('openclaude', 'context')).toBe('transcript') + // Claude and Codex answer /context in their structured sessions. + expect(getNativeChatCommandReply('claude', 'context')).toBeNull() + expect(getNativeChatCommandReply('codex', 'context')).toBeNull() + expect(getNativeChatCommandReply('omp', 'compact')).toBeNull() + }) + + it('declares only /context, the one reply the desktop chat implements', () => { + for (const agent of ['claude', 'openclaude', 'codex', 'omp', 'grok', 'custom-agent']) { + const declared = getVerifiedNativeChatCommands(agent).filter(({ reply }) => reply) + expect(declared.map(({ name }) => name)).toEqual( + agent === 'omp' || agent === 'openclaude' ? ['context'] : [] + ) + } + }) + + it('leaves out of the agent-answered catalog only the commands that declare a reply', () => { + const names = (agent: string) => + getAgentAnsweredNativeChatCommands(agent).map(({ name }) => name) + expect(names('openclaude')).toEqual(names('claude')) + expect(names('omp')).toEqual( + getVerifiedNativeChatCommands('omp') + .map(({ name }) => name) + .filter((name) => name !== 'context') + ) + expect(getAgentAnsweredNativeChatCommands('codex')).toEqual( + getVerifiedNativeChatCommands('codex') + ) + }) +}) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index 386584c1279..521755aab56 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -1,5 +1,9 @@ import type { AgentType } from './agent-status-types' -import { getAgentSlashCommands, type SlashCommandSuggestion } from './native-chat-slash-commands' +import { + getAgentSlashCommands, + type NativeChatCommandReply, + type SlashCommandSuggestion +} from './native-chat-slash-commands' export type NativeChatAgentProfile = { skillPrefix: '$' | '/' @@ -56,6 +60,27 @@ export function getVerifiedNativeChatCommands(agent: AgentType): readonly SlashC return agent === 'grok' ? [] : getAgentSlashCommands(agent) } +/** How the chat finds the reply to a verified command over a terminal session; + * null when the agent answers it in the terminal or the command is unknown. */ +export function getNativeChatCommandReply( + agent: AgentType, + commandName: string +): NativeChatCommandReply | null { + return ( + getVerifiedNativeChatCommands(agent).find((command) => command.name === commandName)?.reply ?? + null + ) +} + +/** The verified catalog minus commands only the desktop chat answers: a surface + * without the composer's answers or the transcript's reply rows (mobile) would + * offer a command that appears to do nothing. */ +export function getAgentAnsweredNativeChatCommands( + agent: AgentType +): readonly SlashCommandSuggestion[] { + return getVerifiedNativeChatCommands(agent).filter((command) => command.reply === undefined) +} + /** The mirror of the claimed set: catalog commands this agent acts on when they * arrive as message text. The picker offers these too, so a command the agent * implements is discoverable and not merely typable. */ diff --git a/src/shared/native-chat-command-output.test.ts b/src/shared/native-chat-command-output.test.ts new file mode 100644 index 00000000000..baba5fad043 --- /dev/null +++ b/src/shared/native-chat-command-output.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { surfaceNativeChatCommandOutputs } from './native-chat-command-output' +import { stripNoiseMessages } from './native-chat-noise' +import type { NativeChatMessage } from './native-chat-types' + +// The two user rows OpenClaude 0.31.0 writes for `/context` (its caveat row is +// `isMeta` and never decoded), as the transcript decoder emits them. +const CONTEXT_ENVELOPE = + '/context\n context\n ' +const CONTEXT_STDOUT = + ' \u001b[1mContext Usage\u001b[22m\n\u001b[38;5;244m\u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u001b[38;5;246m\u26c1 \u26c1 \u26c1 \u26c1 \u001b[39m \u001b[38;5;246mgpt-4o \u00b7 16.6k/128k tokens (13%)\u001b[39m\n\n\u001b[38;5;246m\u26c1 \u26c1 \u26c0 \u001b[38;5;220m\u26c0 \u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u001b[3mEstimated usage by category\u001b[23m\u001b[39m\n \u001b[38;5;244m\u26c1\u001b[39m System prompt: \u001b[38;5;246m7.9k tokens (6.1%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u26c1\u001b[39m System tools: \u001b[38;5;246m8.5k tokens (6.6%)\u001b[39m\n \u001b[38;5;220m\u26c1\u001b[39m Skills: \u001b[38;5;246m304 tokens (0.2%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u26f6\u001b[39m Free space: \u001b[38;5;246m65k (50.8%)\u001b[39m\n \u001b[38;5;246m\u26dd Autocompact buffer: 46.4k tokens (36.2%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m\n\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m\n\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\n\u001b[1mSkills\u001b[22m\u001b[38;5;246m \u00b7 /skills\u001b[39m' + +function userTurn( + id: string, + text: string, + timestamp: number | null = 100, + parentId?: string +): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript', + ...(parentId ? { parentId } : {}) + } +} + +const envelope = (name: string, id = 'env', timestamp = 100): NativeChatMessage => + userTurn(id, CONTEXT_ENVELOPE.replaceAll('context', name), timestamp) + +const reply = (id: string, parentId: string | undefined, timestamp = 100): NativeChatMessage => + userTurn(id, CONTEXT_STDOUT, timestamp, parentId) + +const MODEL_STDOUT = 'Set model to gpt-4o' + +describe('surfaceNativeChatCommandOutputs', () => { + it("shows OpenClaude's /context report as plain command output", () => { + const [shown] = surfaceNativeChatCommandOutputs( + [reply('out', 'env'), envelope('context')], + 'openclaude' + ) + expect(shown).toMatchObject({ id: 'out', role: 'system' }) + const block = shown?.blocks[0] + expect(block).toMatchObject({ type: 'text', presentation: 'command-output' }) + const text = block?.type === 'text' ? block.text : '' + expect(text).not.toContain('\u001b') + expect(text).not.toContain('local-command-stdout') + // Column layout survives: the legend stays indented past the grid. + expect(text.split('\n').slice(0, 5)).toEqual([ + 'Context Usage', + '\u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 gpt-4o \u00b7 16.6k/128k tokens (13%)', + '', + '\u26c1 \u26c1 \u26c0 \u26c0 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 Estimated usage by category', + ' \u26c1 System prompt: 7.9k tokens (6.1%)' + ]) + }) + + it('survives the noise filter while the command envelope stays hidden', () => { + const visible = stripNoiseMessages( + surfaceNativeChatCommandOutputs([envelope('context'), reply('out', 'env')], 'openclaude') + ) + expect(visible.map(({ id }) => id)).toEqual(['out']) + }) + + it('pairs each reply with the row it links to, whatever the timestamps say', () => { + // One batch, one timestamp, sorted by id: each reply sits beside the other + // command, and the /model command is the newest row at the /context reply. + const modelReply = userTurn('a-model-out', MODEL_STDOUT, 100, 'd-model') + const messages = [ + modelReply, + envelope('context', 'b-context', 100), + reply('c-context-out', 'b-context', 100), + envelope('model', 'd-model', 100) + ] + const surfaced = surfaceNativeChatCommandOutputs(messages, 'openclaude') + expect(surfaced.find(({ id }) => id === 'c-context-out')?.role).toBe('system') + // Replies to commands whose effect is the feedback stay hidden. + expect(surfaced.find(({ id }) => id === 'a-model-out')).toBe(modelReply) + // A reply stamped before its command row still answers it. + const early = [reply('out', 'env', 99), envelope('context', 'env', 100)] + expect(surfaceNativeChatCommandOutputs(early, 'openclaude')[0]?.role).toBe('system') + }) + + it('leaves a reply hidden without a link to a loaded /context row', () => { + // An older host sends no link, even for a reply right after its command. + const unlinked = [envelope('context'), reply('out', undefined)] + expect(surfaceNativeChatCommandOutputs(unlinked, 'openclaude')).toBe(unlinked) + // The command row fell outside the loaded tail window. + const windowed = [reply('out', 'env-before-window'), envelope('context', 'env-2', 200)] + expect(surfaceNativeChatCommandOutputs(windowed, 'openclaude')).toBe(windowed) + // Linked to a row that is not a command envelope. + const prose = [userTurn('env', 'what is /context'), reply('out', 'env')] + expect(surfaceNativeChatCommandOutputs(prose, 'openclaude')).toBe(prose) + }) + + it('leaves agents that declare no transcript reply untouched', () => { + const messages = [envelope('context'), reply('out', 'env')] + // Claude's TUI writes no reply row; one that appears is left to the noise filter. + expect(surfaceNativeChatCommandOutputs(messages, 'claude')).toBe(messages) + // OMP's /context is answered by the composer, never by a transcript row. + expect(surfaceNativeChatCommandOutputs(messages, 'omp')).toBe(messages) + }) +}) diff --git a/src/shared/native-chat-command-output.ts b/src/shared/native-chat-command-output.ts new file mode 100644 index 00000000000..2f38890df8a --- /dev/null +++ b/src/shared/native-chat-command-output.ts @@ -0,0 +1,75 @@ +// Claude-family harnesses record a local command's reply as a +// `` user turn linked to the command's envelope row. The +// noise filter hides those rows, which is right for commands whose effect is the +// feedback (`/model`, `/compact`). For a command that exists to report +// something, the reply is the answer, so the chat surfaces it. + +import type { AgentType } from './agent-status-types' +import { stripAnsiEscapeSequences } from './ansi-escape-sequences' +import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles' +import { parseNativeChatCommandEnvelope } from './native-chat-command-envelope' +import { isTextBlock, type NativeChatMessage } from './native-chat-types' + +const LOCAL_COMMAND_STDOUT = /^\s*([\s\S]*?)<\/local-command-stdout>\s*$/ + +function userText(message: NativeChatMessage): string | null { + return message.role === 'user' && message.blocks.every(isTextBlock) + ? message.blocks.map((block) => block.text).join('\n') + : null +} + +/** The command a user turn's envelope names, without its slash; null for other turns. */ +function envelopeCommand(message: NativeChatMessage): string | null { + const text = userText(message) + const envelope = text === null ? null : parseNativeChatCommandEnvelope(text) + return envelope ? envelope.name.replace(/^\//, '') : null +} + +/** + * Replace the stdout row answering a command whose catalog row declares a + * transcript reply with its plain text as command output. A reply answers the + * row it is linked to; without that link (an older host, or the command row + * outside the loaded window) it stays hidden. + */ +export function surfaceNativeChatCommandOutputs( + messages: NativeChatMessage[], + agent: AgentType +): NativeChatMessage[] { + const transcriptReplies = new Set( + getVerifiedNativeChatCommands(agent) + .filter((command) => command.reply === 'transcript') + .map((command) => command.name) + ) + // Why: this runs on every transcript update; agents declaring no such reply skip the scan. + if (transcriptReplies.size === 0) { + return messages + } + let rowsById: Map | null = null + let changed = false + const out = messages.map((message) => { + if (message.parentId === undefined) { + return message + } + const stdout = LOCAL_COMMAND_STDOUT.exec(userText(message) ?? '')?.[1] + if (stdout === undefined) { + return message + } + rowsById ??= new Map(messages.map((row) => [row.id, row])) + const parent = rowsById.get(message.parentId) + const command = parent ? envelopeCommand(parent) : null + if (command === null || !transcriptReplies.has(command)) { + return message + } + const text = stripAnsiEscapeSequences(stdout).trim() + if (!text) { + return message + } + changed = true + return { + ...message, + role: 'system' as const, + blocks: [{ type: 'text' as const, text, presentation: 'command-output' }] + } + }) + return changed ? out : messages +} diff --git a/src/shared/native-chat-context-usage.test.ts b/src/shared/native-chat-context-usage.test.ts new file mode 100644 index 00000000000..0e541d53bb3 --- /dev/null +++ b/src/shared/native-chat-context-usage.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + deriveNativeChatContextUsage, + isNativeChatCompactionBoundary +} from './native-chat-context-usage' +import type { AgentSessionTokenUsage } from './agent-session-context-usage' +import type { NativeChatMessage } from './native-chat-types' + +function assistant( + id: string, + usage?: AgentSessionTokenUsage, + model = 'gpt-5.5' +): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 100, + source: 'transcript', + model, + provider: 'openai-codex', + ...(usage ? { usage } : {}) + } +} + +function usage(inputTokens: number, cacheRead = 0, cacheWrite = 0): AgentSessionTokenUsage { + return { + inputTokens, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + outputTokens: 16 + } +} + +const compaction: NativeChatMessage = { + id: 'compacted', + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp: 150, + source: 'transcript' +} + +const noWindow = (): number | null => null + +describe('deriveNativeChatContextUsage', () => { + it('sums input and both cache counts of the newest response against its window', () => { + const windows: NativeChatMessage[] = [] + const derived = deriveNativeChatContextUsage( + [assistant('older', usage(5)), assistant('newest', usage(680, 20_992, 8))], + (message) => { + windows.push(message) + return 272_000 + } + ) + expect(derived).toEqual({ + usedTokens: 21_680, + windowTokens: 272_000, + percentage: 8 + }) + // The window is asked of the response that was measured, not the session's picker. + expect(windows.map(({ id }) => id)).toEqual(['newest']) + }) + + it('reports the used figure alone when the window is unknown', () => { + expect(deriveNativeChatContextUsage([assistant('a', usage(450_000))], noWindow)).toEqual({ + usedTokens: 450_000, + windowTokens: null, + percentage: null + }) + expect( + deriveNativeChatContextUsage([assistant('a', usage(10))], () => 0)?.windowTokens + ).toBeNull() + }) + + it('skips responses whose accounting does not reflect the prompt', () => { + const messages = [assistant('measured', usage(100)), assistant('aborted')] + expect(deriveNativeChatContextUsage(messages, noWindow)?.usedTokens).toBe(100) + }) + + it('knows nothing after a compaction until the next response lands', () => { + const before = [assistant('before', usage(200_000)), compaction] + expect(deriveNativeChatContextUsage(before, noWindow)).toBeNull() + expect( + deriveNativeChatContextUsage([...before, assistant('after', usage(30_000))], noWindow) + ?.usedTokens + ).toBe(30_000) + }) + + it('is null before any response carried usage', () => { + expect(deriveNativeChatContextUsage([], noWindow)).toBeNull() + expect(deriveNativeChatContextUsage([assistant('zero', usage(0))], noWindow)).toBeNull() + }) +}) + +describe('isNativeChatCompactionBoundary', () => { + it('matches only the compaction presentation', () => { + expect(isNativeChatCompactionBoundary(compaction)).toBe(true) + expect( + isNativeChatCompactionBoundary({ + ...compaction, + blocks: [{ type: 'text', text: 'Context compacted' }] + }) + ).toBe(false) + }) +}) diff --git a/src/shared/native-chat-context-usage.ts b/src/shared/native-chat-context-usage.ts new file mode 100644 index 00000000000..1bb23582b83 --- /dev/null +++ b/src/shared/native-chat-context-usage.ts @@ -0,0 +1,54 @@ +// Context usage a chat surface can derive from the transcript alone: the prompt +// the model read on the last request that reported usage, against the window of +// the model that served it. + +import { contextTokensFromUsage } from './agent-session-context-usage' +import type { NativeChatMessage } from './native-chat-types' + +export type NativeChatContextUsage = { + usedTokens: number + /** Null when the serving model's window is not known. */ + windowTokens: number | null + /** Rounded and never clamped: an over-limit turn reads above 100. Null with the window. */ + percentage: number | null +} + +/** Resolves a model's context window, or null when the host does not know it. */ +export type NativeChatContextWindowLookup = (message: NativeChatMessage) => number | null + +/** True for the transcript row an agent writes when it compacts the conversation. */ +export function isNativeChatCompactionBoundary(message: NativeChatMessage): boolean { + return ( + message.role === 'system' && + message.blocks.some((block) => block.type === 'text' && block.presentation === 'compaction') + ) +} + +/** The newest usage-bearing response decides; a compaction after it means the + * context it measured is gone, so nothing is known until the next response. */ +export function deriveNativeChatContextUsage( + messages: readonly NativeChatMessage[], + windowFor: NativeChatContextWindowLookup +): NativeChatContextUsage | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]! + if (isNativeChatCompactionBoundary(message)) { + return null + } + if (message.role !== 'assistant' || !message.usage) { + continue + } + const usedTokens = contextTokensFromUsage(message.usage) + if (usedTokens <= 0) { + return null + } + const window = windowFor(message) + const windowTokens = window !== null && window > 0 ? window : null + return { + usedTokens, + windowTokens, + percentage: windowTokens === null ? null : Math.round((usedTokens / windowTokens) * 100) + } + } + return null +} diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index 33219d8f7db..caccdb56162 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -22,6 +22,13 @@ describe('getAgentSlashCommands', () => { expect(names).toContain('clear') expect(names).toContain('compact') expect(names).not.toContain('model') + // Claude's terminal writes no /context reply the chat could show. + expect(names).not.toContain('context') + }) + + it('offers OpenClaude /context, whose report lands in its transcript', () => { + const names = getAgentSlashCommands('openclaude').map((c) => c.name) + expect(names).toEqual([...getAgentSlashCommands('claude').map((c) => c.name), 'context']) }) it('falls back to a small common set for an unknown agent (never empty)', () => { diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index d650c9b0f3a..9defeab6e4f 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -7,6 +7,12 @@ import type { AgentSessionSlashCommand } from './agent-session-wire' import type { AgentType } from './agent-status-types' +/** Where the chat finds a command's reply when the agent runs in a terminal. + * `composer`: the chat answers from state it holds and never sends the command. + * `transcript`: the agent writes its reply as a transcript row the chat shows. + * Absent: the agent answers in its own terminal, or the effect is the answer. */ +export type NativeChatCommandReply = 'composer' | 'transcript' + export type SlashCommandSuggestion = { /** The command token without its leading slash, e.g. `clear`. */ name: string @@ -15,6 +21,8 @@ export type SlashCommandSuggestion = { /** Provider-authored argument sketch, e.g. ``. */ argumentHint?: string kindUnspecified?: true + /** Declared only on curated rows; a surface that cannot show the reply drops the row. */ + reply?: NativeChatCommandReply } // Best-effort, curated per-agent catalogs. The CLIs ship no machine-readable @@ -34,6 +42,12 @@ const CLAUDE_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'help', description: 'Show available commands' } ] +// Why: OpenClaude writes its `/context` report to the transcript; Claude's TUI does not. +const OPENCLAUDE_COMMANDS: readonly SlashCommandSuggestion[] = [ + ...CLAUDE_COMMANDS, + { name: 'context', description: 'Show context usage', reply: 'transcript' } +] + const CODEX_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'model', description: 'Choose the model and reasoning effort' }, { name: 'ide', description: 'Include IDE context' }, @@ -99,7 +113,8 @@ const OMP_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'tree', description: 'Browse the session tree in Terminal' }, { name: 'session', description: 'Show session information and controls' }, { name: 'rename', description: 'Rename the session' }, - { name: 'context', description: 'Show estimated context usage' }, + // Why: OMP paints this in its TUI and records nothing the chat could show. + { name: 'context', description: 'Show estimated context usage', reply: 'composer' }, { name: 'usage', description: 'Show provider usage and limits' }, { name: 'fast', description: 'Toggle priority service tier' }, { name: 'tools', description: 'Show tools visible to the agent' }, @@ -113,7 +128,7 @@ const OMP_COMMANDS: readonly SlashCommandSuggestion[] = [ const COMMANDS_BY_AGENT: Partial> = { claude: CLAUDE_COMMANDS, - openclaude: CLAUDE_COMMANDS, + openclaude: OPENCLAUDE_COMMANDS, codex: CODEX_COMMANDS, omp: OMP_COMMANDS } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 7fd5fe66192..f48a31f7f38 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -10,6 +10,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' +import type { AgentSessionTokenUsage } from './agent-session-context-usage' import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode, @@ -207,9 +208,18 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { source: NativeChatSource /** Optional provider row cursor; split projections share it for whole-row paging. */ transcriptOffset?: number + /** Model id that produced an assistant response, as the provider API names it. */ + model?: string + /** The agent's provider that served `model`, where the agent records one. */ + provider?: string + /** On assistant responses whose accounting reflects the prompt the model read. */ + usage?: AgentSessionTokenUsage /** Optional explicit turn key. When present, two messages with the same * `turnId` are treated as the same turn for dedup regardless of `id`. */ turnId?: string + /** `id` of the transcript row this one follows in the agent's own conversation + * tree, where the decoder carries the agent's link. Absent from older hosts. */ + parentId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ diff --git a/src/shared/omp-model-list-probe.ts b/src/shared/omp-model-list-probe.ts index 1654b51ad90..a71c1dcb301 100644 --- a/src/shared/omp-model-list-probe.ts +++ b/src/shared/omp-model-list-probe.ts @@ -26,6 +26,14 @@ function parseJsonObject(stdout: string): unknown { } } +/** OMP's `provider/model` selector, the id its listing and `--model` share. */ +export function ompModelSelector( + provider: string | undefined, + model: string | undefined +): string | null { + return provider && model ? `${provider}/${model}` : null +} + /** Parses `omp models --json`. Ids are OMP's `provider/model` selector — the form * `--model` and `/model` resolve exactly, unlike a bare model id that several * providers can share. */ @@ -49,17 +57,24 @@ export function parseOmpModelList(stdout: string): CommitMessageModel[] { const bareId = 'id' in value && typeof value.id === 'string' ? value.id.trim() : '' const selector = 'selector' in value && typeof value.selector === 'string' ? value.selector.trim() : '' - const id = selector || (provider && bareId ? `${provider}/${bareId}` : '') + const id = selector || ompModelSelector(provider, bareId) if (!id || byId.has(id)) { continue } const name = 'name' in value && typeof value.name === 'string' ? value.name.trim() : '' + const contextWindow = + 'contextWindow' in value && typeof value.contextWindow === 'number' + ? value.contextWindow + : null byId.set(id, { id, label: name || labelFromModelId(id), // Why: the same model name ships under several providers; the provider is // what tells two "DeepSeek V4 Pro" rows apart in the picker. - ...(provider ? { description: provider } : {}) + ...(provider ? { description: provider } : {}), + ...(contextWindow !== null && Number.isFinite(contextWindow) && contextWindow > 0 + ? { contextWindowTokens: contextWindow } + : {}) }) } return [...byId.values()] From 57fedeed79a22ee819bef4bba6061b302c7ccb47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:14:47 -0700 Subject: [PATCH 3/4] fix(native-chat): the agent's exit ends its record, and an unconfirmed stop is joined instead of held (#24862) * fix(native-chat): a child's root exit is reported even during its close, and bookkeeping after it never reads as unproven - Both connections report the root process's exit once, with `expected` set when a close had begun. A close that came back unproven and whose root exits later is finished by the adapter, and its end reaches the host like any other. - A Claude close whose resume-point write fails after the exit was proven, and a Codex close whose terminal row is refused, now end the session and report the failure, instead of keeping a dead child indexed as if its exit were unproven. - A Codex close whose forced tree kill can't prove the descendants gone but saw the root exit reports the descendants and counts the root exit. - Every child exit with an identity, expected or not, is forwarded to the host. * fix(native-chat): the exit ends the child's record; an unfinished stop is the child's own close, which everyone joins - The host keeps no stored "stop still owed" record any more. A stop begins the child's close (`child.close`), which lives on the child and ends with it. A second Stop, the idle reaper, quit, a send and an option/answer/goal/rewind all join that close instead of retrying a separate obligation. - A caller waits on the close only as long as the step deadline; the close itself is never abandoned. A proof that lands after every caller stopped waiting reaches the host as the adapter's report of that exit, which ends the record through the same handler. - Once the exit is proven, draining, settling, the lease release and the adapter's acknowledgement are each attempted and reported on failure; none keeps the child on record. A start, and the handle's close, write a release that failed from this host's proof of that exit, so a failed write never refuses a send. - A start that meets a close still unverifiable is refused with `previousExitUnverifiable`, so the queued message is rejected with a send-again reason; nothing is held and nothing starts beside the old process. - The idle sweep goes back to idle reaping only. - Removes #24333's retry entry points, the wait row and its hold rule, the ask/failure cursors on the stored record, and the stop's own wake. Tests replace the #24333 unproven-stop test: a send joining an unproven close and an in-flight one, a late proof past the caller's bound, a root exiting after its close gave up, a proven exit whose resume-point write and lease release both failed, an unverifiable close rejecting the send and refusing an option change, a surviving descendant, quit and the idle reaper; and Codex's unverifiable, late-exit and joined-close cases. * fix(native-chat): a message refused because the old process's exit is unverifiable says so, and to send again The start failure for a refusal with reason `previousExitUnverifiable` reads "Orca couldn't confirm Claude's previous process ended. Send your message to try again." instead of "Claude couldn't restart." The status-row kind and the refusal reason stay in the shared lists for rows and hosts that still carry them; the catalogs keep one sentence for both. * fix(native-chat): a close's verdict is the root's exit alone, and what follows it is logged - A Claude close resolves as soon as the root's exit is proven: the session ends and its `ended` report goes out then. Saving the resume point runs afterwards and a failure is logged, so a slow or hung write never reads as an unproven exit or keeps a dead child on record. - A root that exits after its close came back unproven finishes that close through the same path as any close, so the session's child work is published as ended (background tasks and subagents no longer stay shown running for a dead agent), and a failure there is logged. - Codex logs a refused final row, and reports a root exit whose forced tree kill could not prove the rest of the tree gone the way Claude does, so the host logs it and blocks nothing. - Both adapters take the host's logger for this bookkeeping. * fix(native-chat): one handler ends every child's exit, and a join waits on the adapter's own close - One exit handler (`structured-agent-session-child-exit`) ends a child's record for an exit expected or not. `expected` only changes what the chat is told: the stop's cause, its end at the stop's ask, the settlement id, and no crash outcome row. The lease release keeps the exit's evidence; the handoff guard, lifecycle barrier, sink release and adapter acknowledgement apply to both. A Claude journal-sink failure ends in the same step as its stop, as Orca's own fault. - Joining a close is asking the adapter, whose close is memoized while it runs and bounded by its own kill escalation; the host keeps no attempt of its own and no 10 s caller bound. An ask after a close came back unproven runs the stop again. - A close's end is stamped where its stop was asked for (a repeated ask moves it), so the closed chat and failed start checks order a message accepted meanwhile after it. - A start refused because the old exit is unverifiable rejects what was queued in the same step. - The end of a close the host asked for no longer waits on the cross-session recovery chain. - The kill no longer waits for the stop event's write; the journal writes rows in order. * fix(native-chat): an exit's lease release lands whatever the length of its reason A crash's reason can carry kilobytes of the provider's stderr, and a lease whose death detail is over 512 characters fails the store's own check. The exit handler cut it, but the release a start or the chat handle's close re-derives did not, so after a crash whose own release failed every message was refused as not resumable until restart. The record's builder now cuts the detail to the record's bound, so no writer can hand it one too long. * fix(claude): a proven close waits at most 2 s for the output it already wrote Once the root's exit is proven, the close still waited for the SDK's output reader to end. Something outside the process tree that holds the output open would keep that close, and every send, Stop and quit joining it, waiting with no bound. The wait is now bounded; past it the close resolves as proven and the open output is logged. * fix(codex): an exit reported inside Orca's close keeps the reason Orca closed it for The connection reports the app-server's exit inside the close that ends it, so that report ended every Codex close and replaced the close's own reason (for example, a provider frame that could not be recorded) with the connection's stderr text in the ended record and the lease's exit evidence. The session now records Orca's close with its reason, and the exit it ends keeps that reason. The test connection reports its exit inside close the way the real one does. * fix(native-chat): quit stops delivery before it drains exit recovery Every exit now wakes delivery, and teardown drained exit recovery before it stopped delivery, so an exit settled in that window could start a fresh agent that teardown then killed. Teardown stops delivery first; queued messages wait for the next launch. * docs(native-chat): the unverifiable-exit refusal no longer names a caller's wait The caller's bounded wait was removed; the comment describes the close as it is now. * fix(native-chat): a stop whose kill did not take is logged, and the next ask kills again When a close's kill leaves the agent's root running, the host now logs it. Tests pin what a later ask does: each connection runs its whole stop again (Codex sends SIGKILL a second time), refuses input meanwhile, and proves the exit once the kill takes. * fix(native-chat): a start refused over the old process says Orca couldn't stop it The host reaches an unverifiable verdict only after its own kill left the agent's root running, on the machine that runs the agent, so the sentence now says that: "Orca couldn't stop {agent}'s previous process." The refusal reason, failure kind and wire shapes are unchanged. The host test also checks the failed kill is logged. * docs(native-chat): an unverifiable close verdict is a root that survived the kill The host's close runs where the agent runs, so lost contact never yields this verdict; the comment no longer says it does. * fix(native-chat): a kill that did not take is reported once, by whoever met it The log added at the close fired beside a Stop's own failure report for the same event. A stop still reports it through its failure; a send or option change refused over it now logs it at the refusal, the only place it is otherwise invisible. * test(native-chat): a second Stop joins a close the first could not prove and retries its kill * fix(native-chat): say a start refused beside an unstopped process plainly The rejection now reads "Couldn't stop {{agent}} from before. Send your message again to try once more." This kind has its own send-again step; every other failure keeps "Send your message to try again." --- ...laude-stream-json-connection-close.test.ts | 118 ++++ .../claude/claude-stream-json-connection.ts | 25 +- .../claude-structured-control-actions.ts | 26 + .../claude-structured-prompt-ownership.ts | 14 + .../claude-structured-requested-stop.test.ts | 3 +- .../claude-structured-session-acquisition.ts | 18 +- .../claude-structured-session-adapter.ts | 77 +-- .../claude-structured-session-close.test.ts | 9 +- .../claude/claude-structured-session-close.ts | 119 ++-- ...claude-structured-session-recovery.test.ts | 21 +- .../claude/claude-structured-session-state.ts | 5 + .../codex-app-server-connection-types.ts | 6 +- .../codex/codex-app-server-connection.test.ts | 36 +- src/main/codex/codex-app-server-connection.ts | 15 +- src/main/codex/codex-close-turn-end.test.ts | 1 - .../codex-requested-close-turn-timing.test.ts | 40 +- .../codex/codex-structured-session-acquire.ts | 14 +- ...odex-structured-session-adapter-fixture.ts | 12 +- .../codex/codex-structured-session-adapter.ts | 1 + .../codex-structured-session-close.test.ts | 10 +- .../codex/codex-structured-session-close.ts | 49 +- ...structured-session-options-catalog.test.ts | 1 - .../codex-structured-session-options.test.ts | 1 - .../codex/codex-structured-session-state.ts | 13 +- .../codex-structured-session-teardown.ts | 27 +- .../journal-host-database.test.ts | 3 +- ...-agent-session-accept-then-deliver.test.ts | 10 +- .../structured-agent-session-agent-start.ts | 60 +- ...structured-agent-session-attach-context.ts | 4 + .../structured-agent-session-chat-stop.ts | 15 + .../structured-agent-session-child-close.ts | 137 ++++ .../structured-agent-session-child-exit.ts | 219 +++++++ ...red-agent-session-claude-root-exit.test.ts | 19 +- ...ssion-claude-stop-exit-ends-record.test.ts | 617 ++++++++++++++++++ ...-session-claude-unproven-stop-send.test.ts | 569 ---------------- ...ctured-agent-session-close-verdict.test.ts | 15 +- ...tured-agent-session-codex-stop-row.test.ts | 99 ++- ...d-agent-session-conversation-close.test.ts | 65 +- ...red-agent-session-conversation-lifetime.ts | 14 +- ...ed-agent-session-conversation-stop.test.ts | 3 +- .../structured-agent-session-delivery-loop.ts | 52 +- ...structured-agent-session-event-recovery.ts | 65 +- ...uctured-agent-session-eviction-deadline.ts | 51 -- .../structured-agent-session-eviction.test.ts | 200 +----- .../structured-agent-session-eviction.ts | 123 +--- .../structured-agent-session-host-lifetime.ts | 250 ++----- ...tructured-agent-session-host-stop-event.ts | 12 +- .../structured-agent-session-host-types.ts | 26 +- .../structured-agent-session-host.ts | 19 +- ...tructured-agent-session-idle-sweep.test.ts | 1 - .../structured-agent-session-idle-sweep.ts | 9 - .../structured-agent-session-lease-release.ts | 47 +- ...ructured-agent-session-mutation-context.ts | 6 +- ...structured-agent-session-provider-child.ts | 12 +- ...tured-agent-session-provider-exit-proof.ts | 16 +- ...ructured-agent-session-send-preparation.ts | 7 +- ...agent-session-startup-failure-exit.test.ts | 22 +- ...d-agent-session-stop-event-binding.test.ts | 3 + ...d-agent-session-stop-event-entries.test.ts | 16 +- ...-agent-session-stop-settle-binding.test.ts | 18 + ...red-agent-session-surface-lifetime.test.ts | 30 +- .../structured-agent-session-turns-cancel.ts | 6 +- ...ured-agent-session-unexpected-exit.test.ts | 30 +- ...tructured-agent-session-unexpected-exit.ts | 198 ------ ...ctured-agent-session-wind-down-wait-row.ts | 83 --- ...gent-session-surface-release-transition.ts | 10 +- ...d-agent-session-lifecycle-delivery.test.ts | 49 ++ ...ctured-agent-session-lifecycle-delivery.ts | 9 +- ...red-agent-session-runtime-teardown.test.ts | 27 + ...ructured-agent-session-runtime-teardown.ts | 3 + .../structured-agent-session-runtime.ts | 5 +- .../structured-claude-runtime-adapter.ts | 5 +- .../agent-session-failure-words-text.ts | 5 + .../src/i18n/en-runtime-required.json | 3 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/agent-session-failure-copy.ts | 4 +- .../agent-session-failure-words.test.ts | 22 + src/shared/agent-session-failure-words.ts | 12 +- src/shared/agent-session-failure.ts | 2 +- src/shared/agent-session-record.ts | 4 +- src/shared/agent-session-refusal-details.ts | 4 +- src/shared/agent-session-stop-row-identity.ts | 2 +- 87 files changed, 2052 insertions(+), 1944 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts create mode 100644 src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts create mode 100644 src/main/runtime/structured-agent-session-runtime-teardown.test.ts diff --git a/src/main/claude/claude-stream-json-connection-close.test.ts b/src/main/claude/claude-stream-json-connection-close.test.ts index 22076f3600e..348601bbd48 100644 --- a/src/main/claude/claude-stream-json-connection-close.test.ts +++ b/src/main/claude/claude-stream-json-connection-close.test.ts @@ -4,6 +4,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { describe, expect, it, vi } from 'vitest' import type { query } from '@anthropic-ai/claude-agent-sdk' import { + CLAUDE_READER_DRAIN_AFTER_EXIT_MS, openClaudeStreamJsonConnection, type ClaudeStreamJsonLaunch } from './claude-stream-json-connection' @@ -162,6 +163,123 @@ describe('Claude stream-json close ordering', () => { expect(next).toHaveBeenCalledOnce() }) + // The ladder (stdin end, SIGTERM, SIGKILL) runs again for the next ask; nothing reuses the root. + it('runs the stop ladder again on a close after an unproven attempt, refusing input meanwhile', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const child = fakeChild() + const next = vi.fn(async (): Promise>> => ({ + value: undefined, + done: true + })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + {}, + () => child, + queryImpl + ) + + await expect(connection.close()).resolves.toBe(false) + await expect(connection.send({ type: 'user' })).rejects.toThrow() + await expect(connection.close()).resolves.toBe(true) + expect(mocks.proveClaudeChildExit).toHaveBeenCalledTimes(2) + }) + + // Whatever holds the output open past a proven exit (a process that escaped the tree) must not + // keep every send and Stop that joins this close waiting. + it('resolves a proven close though the output never ends', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(true) + const child = fakeChild() + const next = vi.fn(() => new Promise>>(() => {})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + {}, + () => child, + queryImpl + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + try { + let closed: boolean | undefined + void connection.close().then((value) => { + closed = value + }) + await vi.advanceTimersByTimeAsync(CLAUDE_READER_DRAIN_AFTER_EXIT_MS - 1) + expect(closed).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(closed).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[claude-stream-json] output still open after the proven exit:', + expect.anything() + ) + } finally { + vi.useRealTimers() + warn.mockRestore() + } + }) + + it('reports a root that exits after its close came back unproven as that close ending', async () => { + mocks.refresh.mockReset() + mocks.proveClaudeChildExit.mockReset() + mocks.refresh.mockResolvedValue(undefined) + mocks.proveClaudeChildExit.mockResolvedValue(false) + const child = fakeChild() + const next = vi.fn(() => new Promise>>(() => {})) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection. + const queryImpl = ((params: Parameters[0]) => { + params.options?.spawnClaudeCodeProcess?.({ + command: 'claude', + args: [], + env: {}, + signal: new AbortController().signal + }) + return { + [Symbol.asyncIterator]: () => ({ next }) + } + }) as unknown as typeof query + const exits: (boolean | undefined)[] = [] + const connection = await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' }, + { onExit: (_error, exit) => exits.push(exit?.expected) }, + () => child, + queryImpl + ) + await expect(connection.close()).resolves.toBe(false) + + child.emit('exit', 0, 'SIGTERM') + + expect(exits).toEqual([true]) + }) + it('waits for the live tree refresh before ending stdin', async () => { const refreshDone = Promise.withResolvers() mocks.refresh.mockReturnValueOnce(refreshDone.promise) diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 6f67e04b36e..d6511a99863 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -15,6 +15,7 @@ import { type ClaudeControlSurface } from './claude-agent-sdk-control-requests' import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof' +import { withTimeout } from '../../shared/promise-timeout-fallback' import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification' import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn' import { @@ -25,6 +26,10 @@ import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-reso export { ClaudeControlRequestError } +/** How long a proven close waits for messages already written before the exit; whatever still + * holds the output open past it is no reason to keep the close unresolved. */ +export const CLAUDE_READER_DRAIN_AFTER_EXIT_MS = 2_000 + /** * The SDK is loaded at the structured-Claude boundary rather than by this module's * import. The ordinary runtime's class graph statically reaches this file, and the @@ -33,6 +38,7 @@ export { ClaudeControlRequestError } * path never opted into, and a missing SDK would fail runtime startup. Memoized, * so a session pays the import once per process rather than once per connection. */ + let claudeAgentSdk: Promise | null = null function loadClaudeAgentSdk(): Promise { @@ -65,7 +71,9 @@ export type ClaudeStreamJsonConnectionHandlers = { onUserDialog?: OnUserDialog /** A transport/process fault that is not itself first-hand root exit proof. */ onFault?: (error: Error) => void - onExit?: (error: Error) => void + /** The root process exited, reported once. `expected`: a close had begun, so it is that close's + * end, even one that ran out of its own escalation first and came back unproven. */ + onExit?: (error: Error, exit?: { expected: boolean }) => void } /** @@ -222,9 +230,9 @@ export async function openClaudeStreamJsonConnection( faultReported = true handlers.onFault?.(terminalError) } - if (!closing && exited && !exitReported) { + if (exited && !exitReported) { exitReported = true - handlers.onExit?.(terminalError) + handlers.onExit?.(terminalError, { expected: closing }) } } @@ -330,7 +338,16 @@ export async function openClaudeStreamJsonConnection( closePromise = null return false } - await readerDone + const drained = await withTimeout( + readerDone.then(() => true), + CLAUDE_READER_DRAIN_AFTER_EXIT_MS, + false + ) + if (!drained) { + console.warn('[claude-stream-json] output still open after the proven exit:', { + pid: spawner.pid + }) + } return true })() return closePromise diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 071cff0430c..03648b4bbb6 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -97,6 +97,32 @@ export async function stopClaudeBackgroundTasks( return { cancelled } } +/** Stops the tasks while `session` is still the one the host asked about, then publishes its child + * work so the host's records follow every acknowledged stop. */ +export async function stopCurrentClaudeBackgroundTasks(input: { + sessions: ReadonlyMap + session: ClaudeSession + sessionId: string + fence: number + taskIds: readonly string[] + timeoutMs: number | undefined + publishChildWork: (session: ClaudeSession) => void +}): Promise<{ cancelled: boolean }> { + const { sessions, session, sessionId, fence } = input + const acquisitionGeneration = session.acquisitionGeneration + const isCurrent = () => + sessions.get(sessionId) === session && + session.fence === fence && + session.acquisitionGeneration === acquisitionGeneration + try { + return await stopClaudeBackgroundTasks(session, input.timeoutMs, isCurrent, input.taskIds) + } finally { + if (isCurrent()) { + input.publishChildWork(session) + } + } +} + export async function answerClaudePrompt( session: ClaudeSession, claim: ClaudePromptClaim, diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 7f68b583865..07e3960c6ed 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -209,3 +209,17 @@ export async function dismissClaudeStructuredPrompt(input: { session.prompts.releaseClaim(claim) } } + +/** An answered or dismissed request frees the child it blocked before the host records the card, + * so no row reads the child waiting beside a closed card; no provider frame says so first. */ +export function settleClaudePromptFreeingChild Promise }>( + input: { request: R; sessions: Map; free: () => void }, + settle: (input: { request: R; sessions: Map }) => Promise +): Promise { + const { request, sessions, free } = input + const commit = async (): Promise => { + free() + await request.commit() + } + return settle({ request: { ...request, commit }, sessions }).finally(free) +} diff --git a/src/main/claude/claude-structured-requested-stop.test.ts b/src/main/claude/claude-structured-requested-stop.test.ts index 993109f4a37..edcaff847bb 100644 --- a/src/main/claude/claude-structured-requested-stop.test.ts +++ b/src/main/claude/claude-structured-requested-stop.test.ts @@ -85,6 +85,7 @@ describe('a requested stop of a structured Claude chat', () => { expect(turns.at(-1)).toMatchObject({ turnId: 'turn-1', state: 'interrupted' }) const ended = events.filter((event) => event.type === 'ended') expect(ended).toEqual([expect.objectContaining({ reason: 'claude session closed' })]) - expect(ended[0]).not.toHaveProperty('cause') + // The host ends the child's record on it, as the close Orca asked for, never as a crash. + expect(ended[0]).toMatchObject({ cause: 'requested-close' }) }) }) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 404787620e9..39d2e0cdbe2 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -153,12 +153,9 @@ export async function acquireClaudeSession({ settle() } } - const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ - sessionId, - prompts, - emit: (event) => - callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) - }) + const emit = (event: Parameters[2]): void => + callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) + const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, prompts, emit }) try { const launch = await resolveClaudeAcquisitionLaunch({ @@ -200,7 +197,12 @@ export async function acquireClaudeSession({ childEnded ??= error initProof.reject(error) }, - onExit: (error) => { + onExit: (error, exit) => { + if (exit?.expected) { + // The end of a close Orca began; that close settles it, or finishes it now. + callbacks.finishClose(sessionId, attempt) + return + } // The child exited on its own; marked in place, as the fault report may hold this error. withObservedProviderExit(error) childEnded ??= error @@ -218,8 +220,6 @@ export async function acquireClaudeSession({ deps.now ? { now: deps.now } : {} ) acquisitions.assertCurrent(sessionId, attempt) - const emit = (event: Parameters[2]): void => - callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event)) if (connection.pid === undefined) { // A pid-less spawn always reports its error next; surface that, not the missing pid. await initProof.promise diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 7dae79b408f..0e3176a1813 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -5,7 +5,7 @@ import type { StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' +import { stopCurrentClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { claudeHoldsDispatch } from './claude-command-lifecycle' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' @@ -26,7 +26,11 @@ import { type ClaudeStructuredSessionAdapterDeps, type ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { closeAllClaudeSessions, closeClaudeSession } from './claude-structured-session-close' +import { + closeAllClaudeSessions, + closeClaudeSession, + finishClaudeCloseAfterExit +} from './claude-structured-session-close' import { claudeStoppedRequestEndWait } from './claude-request-end-wait' import { drainClaudeObservedExits, @@ -40,7 +44,8 @@ import { claudePromptCardWritten, drainClaudeChildWork } from './claude-child-wo import { answerClaudeStructuredPrompt, cancelClaudeStructuredTurn, - dismissClaudeStructuredPrompt + dismissClaudeStructuredPrompt, + settleClaudePromptFreeingChild } from './claude-structured-prompt-ownership' import { claudePromptCancelRoute } from './claude-structured-prompt-replies' @@ -95,6 +100,14 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda emit: (session, _events, event) => this.emit(session, event), handleExit: (sessionId, attempt, error) => observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error), + finishClose: (sessionId, attempt) => + finishClaudeCloseAfterExit({ + sessions: this.sessions, + sessionId, + connection: attempt.connection, + deps: this.deps, + afterClose: (close) => this.afterClose(sessionId, close) + }), settleExit: (sessionId, exit) => settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit) } @@ -204,44 +217,23 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda awaitStoppedRequestEnd = claudeStoppedRequestEndWait(this.sessions) routePromptCancel = claudePromptCancelRoute dismissPrompt: NonNullable = (request) => - this.freeingAsker(request, (freeing) => - dismissClaudeStructuredPrompt({ request: freeing, sessions: this.sessions }) - ) - /** An answered or dismissed request frees the child it blocked before the host records the card, - * so no row reads the child waiting beside a closed card; no provider frame says so first. */ - private freeingAsker = Promise }>( - request: R, - settle: (request: R) => Promise - ): Promise => { - const free = () => this.publishChildWork(request.sessionId) - const commit = async (): Promise => { - free() - await request.commit() - } - return settle({ ...request, commit }).finally(free) - } + settleClaudePromptFreeingChild(this.asker(request), dismissClaudeStructuredPrompt) + /** The request with what frees the child it blocked (`settleClaudePromptFreeingChild`). */ + private asker = (request: R) => ({ + request, + sessions: this.sessions, + free: () => this.publishChildWork(request.sessionId) + }) stopBackgroundTasks: NonNullable = async ( input - ) => { - const session = this.session(input.sessionId) - const acquisitionGeneration = session.acquisitionGeneration - const isCurrent = () => - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration - try { - return await stopClaudeBackgroundTasks( - session, - this.deps.requestTimeoutMs, - isCurrent, - input.taskIds - ) - } finally { - if (isCurrent()) { - this.publishChildWork(input.sessionId, session) - } - } - } + ) => + stopCurrentClaudeBackgroundTasks({ + ...input, + sessions: this.sessions, + session: this.session(input.sessionId), + timeoutMs: this.deps.requestTimeoutMs, + publishChildWork: (session) => this.publishChildWork(input.sessionId, session) + }) /** The tracker's own roster, for the tests that compare it with the host's child records. No * production code reads it: what runs, what a Stop reaches and what blocks a command are all * read from the host's child records. */ @@ -261,9 +253,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda return session ? claudeHoldsDispatch(session) : false } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => - this.freeingAsker(request, (freeing) => - answerClaudeStructuredPrompt({ request: freeing, sessions: this.sessions }) - ) + settleClaudePromptFreeingChild(this.asker(request), answerClaudeStructuredPrompt) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => setClaudeStructuredSessionOption( this.session(input.sessionId), @@ -327,7 +317,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), - ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}), + ...(this.deps.logger ? { logger: this.deps.logger } : {}) }) closeAll = (): Promise => diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 8881c6ae74b..3d61bbb7357 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -126,7 +126,9 @@ describe('Claude published session close lifecycle', () => { ).sessions.get('session-1') const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') - await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) + // The exit is proven; the failed cursor write after it never reads as an unproven exit. + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await vi.waitFor(() => expect(persistHandle).toHaveBeenCalledOnce()) // The child is provably dead; a failed cursor write may not suppress the end. expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) @@ -134,10 +136,9 @@ describe('Claude published session close lifecycle', () => { // A close Orca asked for stops the child still running, then the host hears the session end. expect(childWork).toEqual(['live', 'ended', 'session-ended']) + // Nothing of the dead child stays indexed for a retry. await expect(adapter.closeSession('session-1')).resolves.toBe(true) - expect(persistHandle).toHaveBeenCalledTimes(2) - // The retry persists the same cursor without a second lifecycle end. - expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + expect(persistHandle).toHaveBeenCalledOnce() expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(disposeTranslator).toHaveBeenCalledOnce() }) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 68f3dbbae1f..903f49f052e 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -7,6 +7,7 @@ import type { } from './claude-structured-session-state' import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' import { + AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, AgentSessionPreSpawnError @@ -18,6 +19,7 @@ import { closeProcessRegistry } from '../../shared/child-process/close-process-r import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { settleClaudeTurnEndWaiters } from './claude-request-end-wait' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' /** The root's own exit was seen first-hand. The lease follows the root, so a descendant * left unverified or seen alive does not hold it. */ @@ -85,6 +87,7 @@ type CloseClaudePublishedSessionInput = { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger } async function finalizeClaudePublishedSession( @@ -122,23 +125,10 @@ async function finalizeClaudePublishedSession( } session.childWork.clear() session.backgroundTasks.clear() - const leafUuid = await settledClaudeTurnEndLeaf(session) - const persistence = - session.closePersistence ?? - (session.closePersistence = (async () => { - await input.persistHandle?.({ - sessionId: input.sessionId, - providerSessionId: session.providerSessionId, - leafUuid, - fence: session.fence - }) - })()) - const ended = { - type: 'ended', - sessionId: input.sessionId, - reason: 'claude session closed', - observedAt: Date.now() - } as const + // The exit is proven, so the session ends now. Saving its resume point is bookkeeping that + // follows, reported on failure; it never holds the close or reads as an unproven exit. + session.closeFinalized = true + input.sessions.delete(input.sessionId) let callbackError: unknown let callbackThrew = false const deliver = (event: ClaudeStructuredSessionEvent): void => { @@ -149,31 +139,18 @@ async function finalizeClaudePublishedSession( callbackError ??= error } } - let persistenceError: unknown - try { - await persistence - session.closeFinalized = true - input.sessions.delete(input.sessionId) - deliver({ - type: 'handle', - sessionId: input.sessionId, - providerSessionId: session.providerSessionId, - leafUuid, - fence: session.fence - }) - } catch (error) { - // Keep the closed session indexed so a retry can persist the same cursor. - // Removing it first would turn a durable-write failure into a no-op retry. - if (session.closePersistence === persistence) { - session.closePersistence = undefined - } - persistenceError = error - } - // The connection already proved the child dead, so the session has ended - // whatever the durable write did: withholding it would strand the renderer on - // a session nothing re-drives. Emitted once, so a retry only re-persists. if (!session.closeEnded) { session.closeEnded = true + const ended = { + type: 'ended', + sessionId: input.sessionId, + reason: 'claude session closed', + // The host ends the child's record on it, whoever was still waiting on the close. + cause: 'requested-close', + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt: Date.now() + } as const try { try { session.translator?.handle(ended) @@ -186,18 +163,41 @@ async function finalizeClaudePublishedSession( session.translator?.dispose() } } - if (persistenceError) { - throw persistenceError - } - if (callbackThrew) { - throw callbackError - } + session.closePersistence ??= persistClosedClaudeSession(input, session) if (rootExitVerdict) { throw rootExitVerdict } + if (callbackThrew) { + // The exit is proven; only what followed it failed, which the caller reports. + throw new AgentSessionAcquisitionExitProvenError(callbackError) + } return true } +/** The resume point a closed session leaves for the next start, after the close already ended. */ +async function persistClosedClaudeSession( + input: CloseClaudePublishedSessionInput, + session: ClaudeSession +): Promise { + try { + const leafUuid = await settledClaudeTurnEndLeaf(session) + const handle = { + sessionId: input.sessionId, + providerSessionId: session.providerSessionId, + leafUuid, + fence: session.fence + } + await input.persistHandle?.(handle) + input.onEvent?.({ type: 'handle', ...handle }) + } catch (error) { + input.logger?.warn("saving a closed Claude session's resume point failed", { + scope: 'claude-close-resume-point', + sessionId: input.sessionId, + error + }) + } +} + export async function closeClaudePublishedSession( input: CloseClaudePublishedSessionInput ): Promise { @@ -233,11 +233,37 @@ export function closeClaudePublishedSessionForDeps( fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger } ): Promise { return closeClaudePublishedSession({ sessions, sessionId, ...deps }) } +/** The root exited after a close came back unproven: joins a close still running, or finishes that + * one for this exact child, through `afterClose`, which publishes the session's child work like + * any close. What failed after the exit is reported. */ +export function finishClaudeCloseAfterExit(input: { + sessions: Map + sessionId: string + connection: ClaudeStreamJsonConnection | null + deps: Parameters[2] + afterClose: (close: () => Promise) => Promise +}): void { + const { sessions, sessionId, deps } = input + if (sessions.get(sessionId)?.connection !== input.connection) { + return + } + void input + .afterClose(() => closeClaudePublishedSessionForDeps(sessions, sessionId, deps)) + .catch((error: unknown) => + deps.logger?.warn('finishing a Claude close after its process exited reported', { + scope: 'claude-close-after-exit', + sessionId, + error + }) + ) +} + export async function closeClaudeSession(input: { sessionId: string sessions: Map @@ -249,6 +275,7 @@ export async function closeClaudeSession(input: { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger }): Promise { const attempt = input.acquisitions.get(input.sessionId) if (!(await cancelClaudeAcquisitionAttempt(attempt))) { diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 71a7ac9ce31..a91b1d7c193 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -95,13 +95,16 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { ).sessions.get('session-1') const disposeTranslator = vi.spyOn(session!.translator!, 'dispose') - await expect(adapter.closeSession('session-1')).rejects.toBe(callbackError) - expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + // The handle follows the proven close as bookkeeping; its callback's failure is reported. + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await vi.waitFor(() => + expect(events.filter((event) => event.type === 'handle')).toHaveLength(1) + ) expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(disposeTranslator).toHaveBeenCalledOnce() }) - it('retains a closed session until its durable cursor persistence succeeds', async () => { + it('does not keep a dead child indexed over a failed resume-point write', async () => { const claude = fakeClaude() const persistenceError = new Error('store unavailable') const persistHandle = vi @@ -111,10 +114,11 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const adapter = adapterFor(claude, {}, [], [], undefined, persistHandle) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - await expect(adapter.closeSession('session-1')).rejects.toBe(persistenceError) - expect(persistHandle).toHaveBeenCalledTimes(1) + // The exit is proven, so the close ends the session; the write after it is bookkeeping. await expect(adapter.closeSession('session-1')).resolves.toBe(true) - expect(persistHandle).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(persistHandle).toHaveBeenCalledOnce()) + await expect(adapter.closeSession('session-1')).resolves.toBe(true) + expect(persistHandle).toHaveBeenCalledOnce() }) it('persists the last completed turn message before graceful close', async () => { @@ -141,6 +145,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { await adapter.closeSession('session-1') + await vi.waitFor(() => expect(persistedHandles).toHaveLength(1)) expect(persistedHandles).toEqual([ { sessionId: 'session-1', @@ -149,7 +154,9 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { fence: 7 } ]) - expect(events.at(-2)).toEqual({ + // Written after the close already ended the session: its end comes first. + expect(events.at(-2)).toMatchObject({ type: 'ended', cause: 'requested-close' }) + expect(events.at(-1)).toEqual({ type: 'handle', sessionId: 'session-1', providerSessionId: PROVIDER_SESSION_ID, diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index d61df04ac75..876090c07e4 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { @@ -115,6 +116,8 @@ export type ClaudeStructuredSessionAdapterDeps = { leafUuid: string | null fence: number }) => Promise + /** Where bookkeeping a close or exit does after the child is gone reports a failure. */ + logger?: StructuredAgentSessionLogger /** Advance the durable resume point in place at a turn end; bookkeeping, never a turn failure. */ persistResumePoint?: (input: { sessionId: string @@ -345,5 +348,7 @@ export type ClaudeAcquireCallbacks = { event: ClaudeStructuredSessionEvent ) => void handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void + /** The root exited during a close Orca began: finishes that close for this exact child. */ + finishClose: (sessionId: string, attempt: ClaudeAcquisitionAttempt) => void settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise } diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index df3f40ac39a..bf33bc5d0b8 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -8,7 +8,9 @@ export type CodexAppServerConnectionHandlers = { onNotification?: (method: string, params: unknown) => void onServerRequest?: (request: CodexAppServerServerRequest) => void onUnhandledFrame?: (kind: string, payload: unknown) => void - onExit?: (error: Error) => void + /** The root process exited, reported once. `expected`: a close had begun, so it is that close's + * end, even one that gave up first and came back unproven. */ + onExit?: (error: Error, exit?: { expected: boolean }) => void /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ onSpawned?: (pid: number) => Promise } @@ -30,4 +32,6 @@ export type CodexAppServerConnection = { resumeReading?: () => void /** Resolves true only after the child emitted `exit` or `close`; false is unproven. */ close: () => Promise + /** The root exited, but the forced kill of its process tree could not prove the tree gone. */ + readonly processTreeUnproven?: boolean } diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index d36cfac47a4..797dd838eaf 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -542,6 +542,33 @@ describe('openCodexAppServerConnection', () => { await expect(connection.close()).resolves.toBe(true) }) + // A root that outlived one kill is killed again by the next ask, which then proves it gone. + it('kills the root again on a close after an unproven attempt', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + const first = connection.close() + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) + await expect(first).resolves.toBe(false) + child.kill.mockImplementation((signal) => { + if (signal === 'SIGKILL') { + setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10) + } + return true + }) + + const second = connection.close() + await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500) + await expect(second).resolves.toBe(true) + expect(child.kill.mock.calls.filter(([signal]) => signal === 'SIGKILL')).toHaveLength(2) + }) + it.each([1_090_188, 2_900_090])( 'accepts a realistic %i-byte escaped command completion and keeps processing', async (frameBytes) => { @@ -828,14 +855,14 @@ describe('openCodexAppServerConnection', () => { await connection.close() }) - it('keeps a graceful close quiet when stdin breaks during the reap', async () => { + it('reports a graceful close as expected when stdin breaks during the reap', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) answerInitialize(child) - const exits: string[] = [] + const exits: (boolean | undefined)[] = [] const connection = await openCodexAppServerConnection( { command: 'codex', args: ['app-server'] }, - { onExit: (error) => exits.push(error.message) }, + { onExit: (_error, exit) => exits.push(exit?.expected) }, spawnImpl ) child.stdin.on('finish', () => child.stdin.emit('error', new Error('write EPIPE'))) @@ -858,7 +885,8 @@ describe('openCodexAppServerConnection', () => { await expect(closing).resolves.toBe(true) expect((await inFlight).message).toContain('EPIPE') - expect(exits).toHaveLength(0) + // The root's exit is the close's own end, never an unexpected death. + expect(exits).toEqual([true]) expect(vi.getTimerCount()).toBe(0) }) }) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 3e7d16ea1bb..070d3e3c301 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -79,6 +79,7 @@ export async function openCodexAppServerConnection( let exitObserved = false let closing = false let exitReported = false + let processTreeUnproven = false const exitProof = new RetryableProcessExitProof() /** First terminal cause, or null while the transport is still usable. Set once: * a child that dies reaches us through several listeners, and the specific @@ -126,9 +127,9 @@ export async function openCodexAppServerConnection( // Transport/protocol failures make the connection unusable immediately so // callers do not hang, but recovery must not treat that as a child exit // until the execution host has observed `exit`/`close`. - if (exitObserved && !closing && !exitReported) { + if (exitObserved && !exitReported) { exitReported = true - handlers.onExit?.(terminalError) + handlers.onExit?.(terminalError, { expected: closing }) } } @@ -257,11 +258,10 @@ export async function openCodexAppServerConnection( ) if (!exited) { const treeExited = await terminateProcessTree() - if (!treeExited) { - dispatcher.failPending(new Error('codex app-server process-tree exit was not proven')) - return false - } await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS) + // The lease follows the root, which is gone: a child left behind is reported by the + // owner, and blocks nothing. + processTreeUnproven = !treeExited && exitObserved } } dispatcher.failPending(new Error('codex app-server connection closed')) @@ -276,6 +276,9 @@ export async function openCodexAppServerConnection( get closed() { return closing || exited || terminalError !== null }, + get processTreeUnproven() { + return processTreeUnproven + }, request, notify, respond: (id, result) => writeResponse({ id, result }), diff --git a/src/main/codex/codex-close-turn-end.test.ts b/src/main/codex/codex-close-turn-end.test.ts index 98ae89a7347..dae441a3526 100644 --- a/src/main/codex/codex-close-turn-end.test.ts +++ b/src/main/codex/codex-close-turn-end.test.ts @@ -58,7 +58,6 @@ function sessionWithRunningTurn() { }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index 454c3ccd4a0..8652919924f 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,7 +1,6 @@ import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' @@ -12,28 +11,19 @@ import type { CodexSession } from './codex-structured-session-state' afterEach(() => vi.useRealTimers()) describe('requested-close durable turn timing', () => { + // The exit is observed, so a refused terminal row is the host's to settle, never a reason to + // keep the dead child indexed for a retry. it.each([true, false])( - 'keeps the first exit receipt when retry requestedClose=%s', + 'ends the session with its exit receipt though its terminal row is refused (requestedClose=%s)', async (requestedClose) => { vi.useFakeTimers() vi.setSystemTime(1_000) - const terminalBodies: AgentJournalItemBody[] = [] - let refuseSettlement = true + // Backpressure refuses the terminal row the close's end would write. const sink: StructuredAgentSessionEventSink = { appendItem: () => {}, appendTombstone: () => {}, publish: () => {}, - tryAppendLifecycleBatch: (_id, mutations) => { - if (refuseSettlement) { - return { accepted: false, reason: 'backpressure' } - } - for (const mutation of mutations) { - if (mutation.kind === 'item') { - terminalBodies.push(mutation.body) - } - } - return { accepted: true } - } + tryAppendLifecycleBatch: () => ({ accepted: false, reason: 'backpressure' }) } const translator = createCodexJournalTranslator({ sink, @@ -63,7 +53,6 @@ describe('requested-close durable turn timing', () => { }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', @@ -79,22 +68,11 @@ describe('requested-close durable turn timing', () => { const onEvent = vi.fn() vi.setSystemTime(2_000) - await expect(closeCodexPublishedSession(sessions, 'session-1')).resolves.toBe(false) - expect(sessions.get('session-1')).toBe(session) - expect(session.ended).toBe(false) - - refuseSettlement = false - vi.setSystemTime(60_000) - await expect( - closeCodexPublishedSession(sessions, 'session-1', onEvent, { - expectedAcquisitionGeneration: 'replacement-generation' - }) - ).resolves.toBe(false) - expect(onEvent).not.toHaveBeenCalled() await expect( closeCodexPublishedSession(sessions, 'session-1', onEvent, { requestedClose }) ).resolves.toBe(true) expect(sessions.has('session-1')).toBe(false) + expect(session.ended).toBe(true) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ cause: requestedClose ? 'requested-close' : 'unexpected-exit', @@ -102,12 +80,6 @@ describe('requested-close durable turn timing', () => { observedAt: 2_000 }) ) - expect(terminalBodies.find((body) => body.kind === 'turn')).toMatchObject({ - kind: 'turn', - state: 'interrupted', - startedAt: 1_000, - completedAt: 2_000 - }) } ) }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index ae965795f7d..1b704fa3946 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -111,7 +111,14 @@ export async function acquireCodexStructuredSession(input: { previous: previousAttempt }) acquisitions.assertCurrent(sessionId, attempt) - if (!(await closeCodexPublishedSession(sessions, sessionId, deps.onEvent))) { + if ( + !(await closeCodexPublishedSession( + sessions, + sessionId, + deps.onEvent, + deps.logger ? { logger: deps.logger } : {} + )) + ) { throw new Error(`codex app-server for session ${sessionId} could not be stopped`) } acquisitions.assertCurrent(sessionId, attempt) @@ -163,13 +170,16 @@ export async function acquireCodexStructuredSession(input: { Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), onSpawned: spawnIdentity.onSpawned, - onExit: (error) => { + onExit: (error, exit) => { try { handleCodexSessionExit({ sessions, sessionId, connection: acquisition.connection, error, + // The end of a close Orca began, even one that came back unproven before it. + ...(exit?.expected ? { closedByOrca: true as const } : {}), + ...(deps.logger ? { logger: deps.logger } : {}), prompts: acquisition.prompts, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) }) diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index f2b4e102c35..37d434ef1a4 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -50,7 +50,7 @@ export function fakeCodex(routes: Record = {}): { routes: Record } { const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, @@ -67,15 +67,21 @@ export function fakeCodex(routes: Record = {}): { notify: () => {}, respond: (id, result) => connection.replies.push({ id, result }), respondWithError: (id, code, message) => connection.replies.push({ id, code, message }), + // As the real connection: the root's exit is reported, once, inside the close that ends it. close: async () => { connection.closeCount += 1 - connection.closed = true + if (!connection.closed) { + connection.closed = true + handlers.onExit?.(new Error('codex app-server connection ended: killed'), { + expected: true + }) + } return true } } connections.push(connection) return connection - }) as typeof openCodexAppServerConnection + } routes['thread/start'] ??= () => ({ thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' }, model: 'gpt-live', diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 6f67b68f8e6..288e08b9e34 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -73,6 +73,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessions: this.sessions, acquisitions: this.acquisitions, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), + ...(deps.logger ? { logger: deps.logger } : {}), forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null) }) } diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 4906b45a529..54cebde991e 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -96,7 +96,7 @@ describe('Codex structured session close lifecycle', () => { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose, + ...(requestedClose ? { orcaClose: { requested: true, reason: new Error('closed') } } : {}), fence: 7, acquisitionGeneration: 'generation-1', threadId: THREAD, @@ -134,7 +134,7 @@ describe('Codex structured session close lifecycle', () => { expect(translator.handle).toHaveBeenCalledOnce() }) - it("ends a Stop's wait for its turn to open when a requested close cannot publish its end yet", async () => { + it("ends the session and a Stop's wait for its turn to open when a requested close cannot publish its end", async () => { const { connection, prompts, session, sessions } = backpressuredSession(true) let released = false void session.turnOpenWaits.wait('turn-1', 60_000).then(() => { @@ -150,10 +150,10 @@ describe('Codex structured session close lifecycle', () => { closedByOrca: true, prompts }) - ).toBe(false) + ).toBe(true) await Promise.resolve() - // Left for the retry, but the child is gone: nothing waits on a turn it would open. - expect(session.ended).toBe(false) + // The exit is observed: the refused row is the host's to settle, and nothing waits on a turn. + expect(session.ended).toBe(true) expect(released).toBe(true) }) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index fe6c0d67673..465d7801fec 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -8,18 +8,18 @@ import { type CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' export function handleCodexSessionExit(input: { sessions: Map sessionId: string connection: CodexAppServerConnection | null error: Error - /** Set by Orca's own close. Absent only from the connection's onExit, which the connection - * withholds while Orca is closing the child. */ + /** Set for the end of a close Orca began: by that close, or by the root's own exit report. */ closedByOrca?: true prompts?: CodexSession['prompts'] - allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger }): boolean { const session = input.sessions.get(input.sessionId) if (!session || session.connection !== input.connection || session.ended) { @@ -32,23 +32,27 @@ export function handleCodexSessionExit(input: { const event: StructuredAgentSessionEndedEvent = { type: 'ended', sessionId: input.sessionId, - reason: input.error.message, + // The connection reports the exit inside the close it ends; the close's own reason is the why. + reason: ((input.closedByOrca && session.orcaClose?.reason) || input.error).message, // Only the child's own exit blames Codex; a close Orca made, for any reason, is Orca's. failure: input.closedByOrca ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), - cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', + cause: session.orcaClose?.requested ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt } as const // A synchronous sink rejection (usually backpressure) leaves the terminal rows to the host's - // exit settlement, which writes its own bounded fallback. + // exit settlement, which writes its own bounded fallback. The exit itself is observed, so the + // session ends either way: holding a dead child as unproven over a refused row would strand it. const admission = session.translator?.handle(event) ?? { accepted: true } - // The connection invokes onExit exactly once, so an unexpected exit is forwarded even when - // admission is backpressured; waiting for a second callback would strand the lease. - if (!admission.accepted && event.cause !== 'unexpected-exit' && !input.allowFailedSettlement) { - return false + if (!admission.accepted) { + input.logger?.warn("Codex's final rows were refused; the host settles the turn instead", { + scope: 'codex-exit-rows', + sessionId: input.sessionId, + reason: admission.reason + }) } session.ended = true // Nothing can echo for this child any more; the journal's pending-submission @@ -69,7 +73,7 @@ export async function closeCodexPublishedSession( sessionId: string, onEvent?: (event: CodexStructuredSessionEvent) => void, options?: { - allowFailedSettlement?: boolean + logger?: StructuredAgentSessionLogger requestedClose?: boolean expectedFence?: number expectedAcquisitionGeneration?: string @@ -89,7 +93,10 @@ export async function closeCodexPublishedSession( } // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. - session.requestedClose = options?.requestedClose ?? true + session.orcaClose = { + requested: options?.requestedClose ?? true, + reason: options?.unexpectedReason ?? new Error('codex session closed') + } // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. const exited = await session.connection.close() @@ -97,21 +104,16 @@ export async function closeCodexPublishedSession( return false } if (!session.ended) { - const handled = handleCodexSessionExit({ + handleCodexSessionExit({ sessions, sessionId, connection: session.connection, - error: options?.unexpectedReason ?? new Error('codex session closed'), + error: session.orcaClose.reason, closedByOrca: true, prompts: session.prompts, - ...(options?.allowFailedSettlement ? { allowFailedSettlement: true } : {}), - ...(onEvent ? { onEvent } : {}) + ...(onEvent ? { onEvent } : {}), + ...(options?.logger ? { logger: options.logger } : {}) }) - // Keep the closed session indexed when terminal settlement admission was - // rejected; a later close attempt retries the same stable lifecycle event. - if (!handled) { - return false - } } sessions.delete(sessionId) return true @@ -121,7 +123,8 @@ export async function closeCodexSession( sessionId: string, sessions: Map, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void + onEvent?: (event: CodexStructuredSessionEvent) => void, + logger?: StructuredAgentSessionLogger ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt))) { @@ -130,7 +133,7 @@ export async function closeCodexSession( if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent) + return closeCodexPublishedSession(sessions, sessionId, onEvent, logger ? { logger } : {}) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index ad4935e5d4b..a5f0c1fa69d 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -53,7 +53,6 @@ function storeSession( }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 4df25a9477c..fe30a2f42f9 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -27,7 +27,6 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, - requestedClose: false, fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 6ddc11ac1f6..f70517668b5 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -77,6 +78,8 @@ export type CodexStructuredSessionAdapterDeps = { /** Host capability seam; production uses the native Windows process table. */ isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void + /** Where bookkeeping a close or exit does after the child is gone reports a failure. */ + logger?: StructuredAgentSessionLogger /** What the session's child work did, delivered after the journal handled the frame. */ onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void /** A send admitted earlier: its identity once Codex echoes it, or its rejection when the turn @@ -105,7 +108,9 @@ export type CodexSession = { ended: boolean /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number - requestedClose: boolean + /** The close Orca began for this child: asked for, or forced as a death, and why. Whatever ends + * the child after it (that close, or the exit the connection reports meanwhile) keeps this. */ + orcaClose?: { requested: boolean; reason: Error } fence: number acquisitionGeneration: string threadId: string @@ -145,13 +150,9 @@ export function mintCodexAcquisitionGeneration(deps: CodexStructuredSessionAdapt export function codexSessionLifecycle( fence: number, acquisitionGeneration: string -): Pick< - CodexSession, - 'ended' | 'requestedClose' | 'fence' | 'acquisitionGeneration' | 'turnOpenWaits' -> { +): Pick { return { ended: false, - requestedClose: false, fence, acquisitionGeneration, turnOpenWaits: createCodexTurnOpenWaits() diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index 80b06ab1c36..e7395fb3c84 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -4,6 +4,8 @@ // session owns are cleared exactly once, and only when the child was actually // proven stopped — a refused close leaves the session indexed for a retry. +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { closeAllCodexSessions, closeCodexPublishedSession, @@ -19,6 +21,7 @@ export type CodexStructuredSessionTeardownDeps = { sessions: Map acquisitions: CodexAcquisitionRegistry onEvent?: (event: CodexStructuredSessionEvent) => void + logger?: StructuredAgentSessionLogger forgetNotificationRetries: (sessionId: string) => void } @@ -26,13 +29,24 @@ export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} close = async (sessionId: string): Promise => { - const closed = await closeCodexSession( + const connection = this.deps.sessions.get(sessionId)?.connection + const closed = this.settled( sessionId, - this.deps.sessions, - this.deps.acquisitions, - this.deps.onEvent + await closeCodexSession( + sessionId, + this.deps.sessions, + this.deps.acquisitions, + this.deps.onEvent, + this.deps.logger + ) ) - return this.settled(sessionId, closed) + if (closed && connection?.processTreeUnproven) { + // The root exited, so the close is proven; its owner reports the children left unconfirmed. + throw new AgentSessionAcquisitionRootExitObservedError( + new Error('codex app-server exited, but its process tree was not proven gone') + ) + } + return closed } forceClose = async (sessionId: string): Promise => { @@ -40,7 +54,7 @@ export class CodexStructuredSessionTeardown { this.deps.sessions, sessionId, this.deps.onEvent, - { allowFailedSettlement: true, requestedClose: false } + { requestedClose: false } ) return this.settled(sessionId, closed) } @@ -63,7 +77,6 @@ export class CodexStructuredSessionTeardown { return Promise.resolve(false) } return closeCodexPublishedSession(this.deps.sessions, sessionId, this.deps.onEvent, { - allowFailedSettlement: true, requestedClose: false, expectedFence: fence, expectedAcquisitionGeneration: acquisitionGeneration, diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index 84ff9775022..2b4e4a0819a 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -232,8 +232,9 @@ describe('quit', () => { const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const database = openTestJournalHostDatabase(root) const installed = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `flushAllStreamedEvents` on the host. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. host: { + stopDelivery: () => {}, flushAllStreamedEvents: async () => { // A child's last row, delivered while quit is draining its sink. await new Promise((resolve) => setTimeout(resolve, 10)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b3f91aea700..1b4a4e95b8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -773,9 +773,10 @@ describe('an eviction between acceptance and handover', () => { }) }) -// A close abandons what is queued before it stops the child, so a release that then fails still -// leaves every queued message rejected as closed, never blamed on the provider. -describe('a close that stops the child and then fails', () => { +// A close abandons what is queued before it stops the child, so a wind-down step that then fails +// (reported, never the close's failure) still leaves every queued message rejected as closed, +// never blamed on the provider. +describe('a close that stops the child and then a wind-down step fails', () => { const END_CHILD = { evict: () => host.close(SESSION, 'evict') } satisfies Partial Promise>> @@ -803,8 +804,7 @@ describe('a close that stops the child and then fails', () => { const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - await expect(END_CHILD[end]()).rejects.toThrow() - expect(host.hasSession(SESSION)).toBe(true) + await expect(END_CHILD[end]()).resolves.toBeUndefined() started.resolve() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index d2ef237aa1b..ec1289d401d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -29,9 +29,10 @@ import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-s import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { - finishOwedStructuredAgentSessionWindDownUnderSerialize, - type StructuredAgentSessionLifetimeContext -} from './structured-agent-session-host-lifetime' + joinClosingStructuredAgentSessionChild, + releaseLeaseOfEndedStructuredAgentSessionChild +} from './structured-agent-session-child-close' +export { joinClosingStructuredAgentSessionChild } import { structuredAgentSessionResumeOperationId, structuredAgentSessionResumeParams @@ -52,48 +53,6 @@ export type StructuredAgentSessionResumeOutcome = /** The attach's caller key: the ledger row a start settles is Orca's own. */ const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' -/** - * Every operation that reaches the provider finishes a stop an earlier attempt left owed first: the - * child that stop could not prove gone takes no input, and none may start beside it. Still - * unproven, the operation is refused with the exit `unverifiable`, never assumed `exited`. - */ -export async function finishOwedStructuredAgentSessionStop( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): Promise { - if (await finishOwedStructuredAgentSessionWindDownUnderSerialize(context, sessionId)) { - return { ok: true } - } - return { - ok: false, - refusal: refuse( - 'agent_session_ownership_unknown', - { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' }, - "Orca could not prove this chat's previous agent process exited." - ) - } -} - -/** The same, for an operation the running child performs, which starts none: only an exit still - * unproven, its child still on record, holds it back. A proven exit owes only bookkeeping, which - * gates no such operation: its retry was reported, and the operation goes on as with none owed. */ -export async function finishOwedStructuredAgentSessionStopForProviderWrite( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): Promise { - const settled = await finishOwedStructuredAgentSessionStop(context, sessionId) - return settled.ok || context.sessions.get(sessionId)?.child ? settled : { ok: true } -} - -export function isStructuredAgentSessionPreviousExitUnverifiable( - refusal: AgentSessionWireRefusal -): boolean { - return ( - refusal.code === 'agent_session_ownership_unknown' && - refusal.details?.reason === 'previousExitUnverifiable' - ) -} - /** * Gives the session a provider child if it has none, for a caller inside its serialize — which is * what makes "if it has none" exact: two askers run this in turn, and the second finds the first @@ -104,9 +63,11 @@ export async function ensureStructuredAgentSessionAgent( sessionId: string, startedFor?: string ): Promise { - const settled = await finishOwedStructuredAgentSessionStop(context, sessionId) - if (!settled.ok) { - return settled + // A child a stop began closing takes no input, and none may start beside it: the start waits on + // that close, and is refused while its exit stays unverifiable. + const closed = await joinClosingStructuredAgentSessionChild(context, sessionId) + if (!closed.ok) { + return closed } if (context.sessions.get(sessionId)?.child) { return { ok: true } @@ -158,6 +119,9 @@ async function startStructuredAgentSessionAgent( return { ok: false, refusal: unreconciled } } await context.runtimeState.resolveRecovery(sessionId) + // A start needs the lease released; a release the exit's own wind-down could not write is + // re-derived from this host's proof of that exit, never refused on. + await releaseLeaseOfEndedStructuredAgentSessionChild(context, sessionId) const record = context.deps.store.getRecord(sessionId) if (!record) { return refuseResume( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index fe99ad90bc3..12c2f54a179 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -13,6 +13,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import type { StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' @@ -33,6 +34,9 @@ export type StructuredAgentSessionAttachContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus: (sessionId: string) => void + /** Joining a stop's close ends the child's record through the one exit handler. */ + endExitedChild: StructuredAgentSessionLifetimeContext['endExitedChild'] + wakeDelivery?: StructuredAgentSessionLifetimeContext['wakeDelivery'] /** The conversation's one open journal, opened when closed; see `conversation-open`. */ openConversation: ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index b55ebbda2d7..834d4f569fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -85,6 +85,21 @@ export function mutateWithChatStop( ) ) const child = context.sessions.get(ctx.sessionId)?.child + if (child?.close) { + // A close an earlier stop began: this Stop joins it, retrying the exit's proof, rather + // than asking a child that takes no input to stop again. Its event, issued ahead of that + // retry, records only the withdrawal. + const effect = hadQueued ? tookEffect() : Promise.resolve() + await stopChild().catch((error: unknown) => + context.deps.logger.warn('ending the agent process on Stop failed', { + scope: 'stop-child', + sessionId, + error + }) + ) + await effect + return { ok: true, value: { ...named, cancelled: await withdrew } } + } if (child?.phase === 'starting') { // A start that may never land is the one thing here Stop has to end; the chat stays. // The event is issued first and lands behind the withdrawal, in the journal's queue order. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts new file mode 100644 index 00000000000..0022df7c156 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -0,0 +1,137 @@ +// A provider child's close, which every stop, start and provider write that meets it joins. +// +// The close lives on the child (`child.close`) and ends with it, so nothing outlives the process +// it is about. Joining it is asking the adapter to close: the adapter runs one close per child at +// a time and bounds it by its own kill escalation, so every caller waits on the same attempt, and +// a caller that comes after an attempt ended unproven runs it again. The verdict is the root's +// exit alone. Proven, the one exit handler ends the record; a proof that lands with no caller +// waiting reaches that handler as the adapter's report of the exit. + +import { refuse } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' +import { releaseStoredStructuredAgentSessionOwnerAfterExit } from './structured-agent-session-lease-release' +import { isSurfaceReleasableAgentSessionRecord } from '../../runtime/agent-session-surface-release-transition' + +/** What a caller learned about the child's exit: proven, or not. A root still there after the + * close's kill reads `unverifiable`, never `exited`. */ +export type StructuredAgentSessionChildCloseVerdict = 'exited' | 'unverifiable' + +/** Joins the child's close and, once its root's exit is proven, ends the record. */ +export async function joinStructuredAgentSessionChildClose( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + child: StructuredAgentSessionProviderChild +): Promise { + if (!(await closeProviderRoot(context, sessionId))) { + return 'unverifiable' + } + await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca' }) + context.restartWitness?.stopped(sessionId) + return 'exited' +} + +/** The refusal of an operation that met a child whose close is still unproven. */ +export function previousExitUnverifiableRefusal(): AgentSessionWireRefusal { + return refuse( + 'agent_session_ownership_unknown', + { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' }, + "Orca could not prove this chat's previous agent process exited." + ) +} + +/** For an operation that reaches the provider: a child a stop began closing takes no input and + * none may start beside it, so the operation joins that close and is refused while it is still + * unproven. */ +export async function joinClosingStructuredAgentSessionChild( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise<{ ok: true } | { ok: false; refusal: AgentSessionWireRefusal }> { + const child = context.sessions.get(sessionId)?.child + if (!child?.close) { + return { ok: true } + } + if ((await joinStructuredAgentSessionChildClose(context, sessionId, child)) === 'exited') { + return { ok: true } + } + // A stop reports this through its own failure; here the refusal is the only trace. + context.deps.logger.warn("the agent's process did not exit after Orca stopped and killed it", { + scope: 'provider-close-unproven', + sessionId + }) + return { ok: false, refusal: previousExitUnverifiableRefusal() } +} + +function closeProviderRoot( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + const { adapter, logger } = context.deps + // An adapter with no close has nothing to stop; anything else must PROVE the exit. + const stop = adapter.disposeSession ?? adapter.closeSession + if (!stop) { + return Promise.resolve(true) + } + return stopAgentSessionProviderRoot( + () => stop.call(adapter, sessionId), + // The exit is proven; a child process left behind, or a write after it, is reported only. + (error) => + logger.warn("the agent's process exited, but its close did not finish cleanly", { + scope: 'provider-close-after-exit', + sessionId, + error + }) + ).catch((error: unknown) => { + logger.warn("closing the agent's process did not prove it exited", { + scope: 'provider-close', + sessionId, + error + }) + return false + }) +} + +/** Whether the lease still names the child this host last proved gone, unreleased: only that + * in-memory proof lets this host release it without a probe, so the handle carrying it stays. */ +export function structuredAgentSessionEndedChildHoldsLease( + context: Pick, + sessionId: string +): boolean { + const ended = context.sessions.get(sessionId)?.lastEndedChild + const record = context.deps.store.getRecord(sessionId) + return ( + ended?.rootGone === true && + record !== null && + isSurfaceReleasableAgentSessionRecord(record) && + record.lease.runtimeFence === ended.fence + ) +} + +/** Writes the release a proven exit allows when the exit handler could not: a start and the + * handle's close re-derive it, and a failure is reported, never anyone's refusal. Resolves + * whether the lease still names that child. */ +export async function releaseLeaseOfEndedStructuredAgentSessionChild( + context: Pick, + sessionId: string +): Promise { + const ended = context.sessions.get(sessionId)?.lastEndedChild + if (!ended || !structuredAgentSessionEndedChildHoldsLease(context, sessionId)) { + return false + } + await releaseStoredStructuredAgentSessionOwnerAfterExit({ + store: context.deps.store, + sessionId, + expectedFence: ended.fence, + now: context.now(), + ...(ended.reason ? { exitReason: ended.reason } : {}) + }).catch((error: unknown) => + context.deps.logger.warn("releasing an exited agent's lease failed", { + scope: 'ended-child-lease-release', + sessionId, + error + }) + ) + return structuredAgentSessionEndedChildHoldsLease(context, sessionId) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts new file mode 100644 index 00000000000..c606a73e310 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -0,0 +1,219 @@ +// The one handler that ends a provider child's record, whether its exit was expected (a close this +// host asked for) or not (the child died on its own). The exit was seen or proven first-hand, so +// every step after it is bookkeeping: each is attempted and reported, none keeps the child on +// record, and the record ends in `finally`. `expected` changes only what the chat is told. + +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild +} from './structured-agent-session-host-types' +import { endProviderChild } from './structured-agent-session-provider-child' +import { + releaseStoredStructuredAgentSessionOwnerAfterExit, + type StructuredAgentSessionLeaseStore +} from './structured-agent-session-lease-release' +import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' +import { + captureUnfinishedStructuredAgentSessionWork, + settleStructuredAgentSessionDeadGeneration, + type DeadGenerationJournal, + unfinishedStructuredAgentSessionWorkWasInterrupted +} from './structured-agent-session-dead-generation-settlement' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { evictStructuredAgentSession } from './structured-agent-session-eviction' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' + +/** How the child's root went: the close this host asked for, or a death of its own. */ +export type StructuredAgentSessionChildExit = { + expected: boolean + /** Log text only; the chat's words come from `failure`. */ + reason: string + failure?: SubmissionRejectionFact + /** Host receipt of the exit: the end time of a turn it interrupted. */ + observedAt?: number + startupUnproven?: true +} + +export type StructuredAgentSessionChildExitSession = Pick< + StructuredAgentSessionHostSession, + 'child' | 'lastEndedChild' +> & { journal: DeadGenerationJournal & Pick } + +export type StructuredAgentSessionChildExitContext< + TSession extends StructuredAgentSessionChildExitSession = StructuredAgentSessionHostSession +> = { + store: StructuredAgentSessionLeaseStore + sessions: Map + flushLifecycle: (sessionId: string) => Promise + publishFence: (sessionId: string, session: TSession) => void + publishStatus?: (sessionId: string) => void + /** The delivery loop hands over whatever is queued once the child is off the record. */ + wakeDelivery?: (sessionId: string) => void + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number + logger: StructuredAgentSessionLogger + /** Lets the child's sink and the adapter's route for it go; absent leaves both to the next attach. */ + route?: { + runtimeState: Pick + acknowledgeRelease: (sessionId: string) => Promise | void + } +} + +/** An adapter's report of a child's exit, for the child still on record with its identity. */ +export function settleStructuredAgentSessionChildExit< + TSession extends StructuredAgentSessionChildExitSession +>( + context: StructuredAgentSessionChildExitContext, + event: StructuredAgentSessionEndedEvent +): Promise { + return context.serialize(event.sessionId, async () => { + const child = context.sessions.get(event.sessionId)?.child + if (!child || child.fence !== event.fence || child.generation !== event.acquisitionGeneration) { + return + } + await endExitedStructuredAgentSessionChildUnderSerialize(context, event.sessionId, child, { + expected: event.cause === 'requested-close', + reason: event.reason, + ...(event.failure ? { failure: event.failure } : {}), + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), + ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) + }) + }) +} + +/** Ends the record of a child whose exit this host saw or proved; a no-op once it has left. */ +export async function endExitedStructuredAgentSessionChildUnderSerialize< + TSession extends StructuredAgentSessionChildExitSession +>( + context: StructuredAgentSessionChildExitContext, + sessionId: string, + child: StructuredAgentSessionProviderChild, + exit: StructuredAgentSessionChildExit +): Promise { + const session = context.sessions.get(sessionId) + if (!session || session.child !== child) { + return + } + const { expected } = exit + const close = child.close + // Receipt of the exit is the one end time the host may record for a running turn. + const observedAt = exit.observedAt ?? context.now() + // The host's own phase decides, so a provider that omits the flag still gets a start that + // failed told as one: the row says so. + const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + const endChild = (): void => { + endProviderChild(session, { + generation: child.generation, + fence: child.fence, + // A close keeps the cause of the stop that asked for it, and ends where it was asked. + cause: expected ? (close?.cause ?? 'evict') : 'exit', + reason: expected ? (close?.reason ?? null) : exit.reason, + ...(!expected && exit.failure ? { failure: exit.failure } : {}), + duringStartup: exitedDuringStartup, + // The adapter publishes an exit only once it saw the root go, first-hand or proven. + rootGone: true, + ...(expected && close ? { endedAt: close.requestedAt } : {}) + }) + context.publishStatus?.(sessionId) + } + const record = context.store.getRecord(sessionId) + if (!record || record.lease.handoffStage !== null) { + // An acquisition or recovery already owns this lease's transition. + endChild() + context.wakeDelivery?.(sessionId) + return + } + try { + // The exited child's own writes land first: its dead generation is settled from all of them. + try { + const barrier = await context.flushLifecycle(sessionId) + if (!barrier.ok) { + logExitFailure(context, sessionId, 'exit-lifecycle-barrier', barrier.error) + } + } catch (error) { + logExitFailure(context, sessionId, 'exit-lifecycle-barrier', error) + } + const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) + // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). + await close?.recorded + const generation = child.generation ?? 'unknown' + const settled = await settleStructuredAgentSessionDeadGeneration({ + journal: session.journal, + sessionId, + fence: child.fence, + settlementId: `${expected ? 'expected-close:' : PROVIDER_EXIT_ROW_PREFIX}${sessionId}:${child.fence}:${generation}`, + pendingSubmissionReason: expected + ? 'provider_closed_before_acknowledgement' + : 'provider_exited_before_acknowledgement', + // Only a turn no adapter settled. Whether a close ended a person's turn is the Stop event's to + // say (`turnEndAfterStop`); a death of its own interrupted it. + verdict: { state: 'interrupted', completedAt: expected ? context.now() : observedAt }, + failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), + // A failed start always says why: no response was running to carry the reason. + showUnexpectedExitOutcome: + !expected && + (exitedDuringStartup || + unfinishedStructuredAgentSessionWorkWasInterrupted( + unfinishedWork, + session.journal, + observedAt + )), + ...(!expected && exit.failure ? { exitFailure: exit.failure } : {}), + ...(!expected && exitedDuringStartup && child.generation + ? { exitedDuringStartup: { generation: child.generation } } + : {}) + }) + if (!settled.ok) { + logExitFailure(context, sessionId, 'exit-settlement', settled.error) + } + } finally { + // The root's exit was observed, so the owner is released even when terminal settlement could + // not be durably accepted. Bare cause: whatever this settlement could not write is settled from + // it later (the settle recording it queues, or the next open or acquire). + let released = false + try { + await releaseStoredStructuredAgentSessionOwnerAfterExit({ + store: context.store, + sessionId, + expectedFence: child.fence, + now: context.now(), + exitObservedAt: observedAt, + exitReason: exit.reason + }) + released = true + } catch (error) { + logExitFailure(context, sessionId, 'exit-owner-release', error) + } + if (context.route) { + const { runtimeState, acknowledgeRelease } = context.route + await evictStructuredAgentSession({ + sessionId, + eventSink: runtimeState.eventSinkFor(sessionId), + logger: context.logger, + discardSink: () => runtimeState.discardEventSink(sessionId), + acknowledgeRelease: () => acknowledgeRelease(sessionId) + }) + } + endChild() + // A reader re-baselines on a death of the child's own; a close Orca asked for moves no fence a + // reader holds, as a client resends a message when its fence moves. + if (released && !expected) { + context.publishFence(sessionId, session) + } + context.wakeDelivery?.(sessionId) + } +} + +function logExitFailure( + context: Pick, + sessionId: string, + scope: string, + error: unknown +): void { + context.logger.warn('settling a provider exit did not finish', { scope, sessionId, error }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index f09fe672b5e..bf473bfc6df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -15,6 +15,7 @@ import { } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { endExitedStructuredAgentSessionChildUnderSerialize } from './structured-agent-session-child-exit' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -135,7 +136,23 @@ describe('Claude root-exit stop', () => { runtimeState, sessions, now: () => NOW + 30 * 60_000, - publishStatus + publishStatus, + endExitedChild: (sessionId, child, exit) => + endExitedStructuredAgentSessionChildUnderSerialize( + { + store, + sessions, + flushLifecycle: (id) => runtimeState.lifecycleBarrier(id), + publishFence: () => undefined, + publishStatus, + serialize: (_sessionId, task) => task(), + now: () => NOW + 30 * 60_000, + logger: deps.logger + }, + sessionId, + child, + exit + ) }, 'session-1', { cause: 'evict' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts new file mode 100644 index 00000000000..cfc2c27ca6b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -0,0 +1,617 @@ +// A Claude child's close and its exit, on the shipping adapter and host. A Stop begins the child's +// close; every later stop, start and provider write joins it; the exit, whenever it is proven, ends +// the child's record; and a close still unverifiable fails what needed the child, holding nothing. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let log: ReturnType +let persistHandle: ReturnType Promise>> +let childWork: string[] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-exit-ends-record-')) + resetHostTestOperationIds() + log = recordingStructuredAgentSessionLogger() + persistHandle = vi.fn(async () => undefined) + childWork = [] + claude = fakeClaude({ + replayUuid: null, + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: () => persistHandle(), + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence) => { + childWork.push(...evidence.map((edge) => edge.type)) + host.publishChildWorkEvidence(sessionId, evidence) + }, + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // Ticked by hand only, and with no idle window: a tick reaps whatever is at rest. + idleSweep: { intervalMs: 3_600_000, idleMs: 0 }, + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: + | 'agentSession.send' + | 'agentSession.cancel' + | 'agentSession.setOption' + | 'agentSession.queuedMessageSend', + // The fingerprint's own field shape, as the sibling host tests type it. + fields: Parameters[0]['fields'] +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) + } + return sent.value.clientMessageId +} + +async function submission(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function wrote(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} + +async function openTurn(connection: FakeConnection): Promise { + const text = 'Write a long reply.' + const clientMessageId = await send(text) + await eventually(() => expect(wrote(connection, text)).toBe(true)) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const written = connection.sent.at(-1)! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => + expect((await submission(clientMessageId))?.dispatchState).toBe('accepted') + ) + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).not.toBeNull() +} + +function stop() { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +/** As the real connection: once a close begins it refuses every write, proven or not. */ +function closeUnprovenFor(connection: FakeConnection, failures: number): void { + const close = connection.close + let left = failures + connection.close = async () => { + if (left === 0) { + return close() + } + left -= 1 + connection.closeCount += 1 + connection.closed = true + return false + } + refuseWritesOnceClosed(connection) +} + +function refuseWritesOnceClosed(connection: FakeConnection): void { + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +const INTERRUPTED_RESULT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' +} + +function child() { + return host['sessions'].get(SESSION)?.child ?? null +} + +function lease() { + return store.getRecord(SESSION)?.lease +} + +/** Stops a running turn; its child's close cannot prove the exit `failures` times. */ +async function stopWithUnprovenClose(failures: number): Promise { + const connection = claude.connections[0]! + await openTurn(connection) + closeUnprovenFor(connection, failures) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + expect(connection.closeCount).toBe(1) + // The child stays on record with its close begun, never as a stored obligation beside it. + expect(child()?.close).toMatchObject({ cause: 'user-stop' }) + return connection +} + +async function resumedWith(connection: FakeConnection, text: string): Promise { + return eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(connection) + expect(wrote(started, text)).toBe(true) + return started + }) +} + +function setModel(model: string) { + const fields = { key: 'model', value: model } + return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) +} + +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function scopes(): unknown[] { + return log.entries.map((entry) => entry.fields.scope) +} + +it('joins the close a Stop could not prove before the next message, then sends it to a resumed child', async () => { + const connection = await stopWithUnprovenClose(1) + + await send('Carry on.') + await resumedWith(connection, 'Carry on.') + + expect(connection.closeCount).toBe(2) + expect(wrote(connection, 'Carry on.')).toBe(false) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ + cause: 'user-stop', + rootGone: true + }) +}) + +it('has a second Stop join the close the first could not prove, retrying its kill', async () => { + const connection = await stopWithUnprovenClose(1) + + // Nothing was queued, so it withdrew nothing and records no event of its own. + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + expect(connection.closeCount).toBe(2) + expect(child()).toBeNull() + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ + cause: 'user-stop', + rootGone: true + }) + expect(connection.calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(1) +}) + +it('has a send made while the close still runs wait for that close, not start another', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + const proof = deferred() + const close = connection.close + let attempts = 0 + connection.close = async () => { + attempts += 1 + connection.closed = true + await proof.promise + return close() + } + refuseWritesOnceClosed(connection) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + + // Accepted on the chat's queue behind the Stop's close, which nothing abandons meanwhile. + const sent = send('Carry on.') + await eventually(() => expect(attempts).toBe(1)) + expect(claude.connections).toHaveLength(1) + proof.resolve() + await sent + + await resumedWith(connection, 'Carry on.') + // One close of the old child, which the send waited on rather than starting its own. + expect(attempts).toBe(1) + expect(connection.closeCount).toBe(1) +}) + +// The verdict is the root's exit; the resume point written after it is bookkeeping. +it('ends the record at the proven exit though the resume-point write after it never settles', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + refuseWritesOnceClosed(connection) + persistHandle.mockImplementationOnce(() => new Promise(() => {})) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + + await eventually(() => expect(child()).toBeNull()) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) + await send('Carry on.') + await resumedWith(connection, 'Carry on.') +}) + +it('ends the record when the root exits after its close gave up, with no one asking again', async () => { + const connection = claude.connections[0]! + // A background task the old agent was running when the Stop's close came back unproven. + frame(connection, { + type: 'system', + subtype: 'task_started', + uuid: 'task-start', + task_id: 'background-1', + task_type: 'local_agent', + is_backgrounded: true + }) + await stopWithUnprovenClose(1) + childWork.length = 0 + + // The connection reports the root's exit as the end of the close Orca began. + connection.handlers.onExit?.(new Error('claude exited'), { expected: true }) + + await eventually(() => expect(child()).toBeNull()) + await eventually(() => expect(lease()?.claimStatus).toBe('released')) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) + expect(connection.closeCount).toBe(2) + expect(claude.connections).toHaveLength(1) + // The child work ends with the session, so nothing is left shown running for a dead agent. + await eventually(() => expect(childWork).toContain('session-ended')) +}) + +it('sends after a proven exit whose resume-point write and lease release both failed', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + refuseWritesOnceClosed(connection) + persistHandle.mockRejectedValueOnce(new Error('resume point not written')) + const transition = store.transitionHandoff.bind(store) + vi.spyOn(store, 'transitionHandoff') + .mockImplementationOnce(async () => { + throw new Error('store unavailable') + }) + .mockImplementation(transition) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await eventually(() => expect(child()).toBeNull()) + // Both reported; neither kept the dead child on record. + await eventually(() => + expect(scopes()).toEqual( + expect.arrayContaining(['claude-close-resume-point', 'exit-owner-release']) + ) + ) + expect(lease()?.claimStatus).toBe('live') + + // The start writes the release from this host's proof of that exit. + await send('Carry on.') + await resumedWith(connection, 'Carry on.') +}) + +// A crash's reason can carry kilobytes of stderr; the release a start re-derives must still land. +it('sends after a crash with a long reason whose own lease release failed', async () => { + const connection = claude.connections[0]! + const transition = store.transitionHandoff.bind(store) + vi.spyOn(store, 'transitionHandoff') + .mockImplementationOnce(async () => { + throw new Error('store unavailable') + }) + .mockImplementation(transition) + + connection.handlers.onExit?.( + new Error(`claude stream-json exited (code 1): ${'stack frame\n'.repeat(700)}`) + ) + await eventually(() => expect(child()).toBeNull()) + await eventually(() => expect(scopes()).toContain('exit-owner-release')) + expect(lease()?.claimStatus).toBe('live') + + await send('Carry on.') + await resumedWith(connection, 'Carry on.') + expect(scopes()).not.toContain('ended-child-lease-release') +}) + +it('rejects a message whose start meets a close still unverifiable, and starts nothing beside it', async () => { + const connection = await stopWithUnprovenClose(2) + + const next = await send('Carry on.') + + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('rejected')) + const rejected = (await submission(next))! + expect(rejected.reason).toBe( + "Couldn't stop Claude from before. Send your message again to try once more." + ) + expect(rejected.rejection).toMatchObject({ + kind: 'restartFailed', + refusal: { details: { reason: 'previousExitUnverifiable' } } + }) + // Rejected, never held: no waiting note, and the old child still on record. + const notes = (await host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'previousExitUnverifiable' + ) + expect(notes).toEqual([]) + expect(connection.closeCount).toBe(2) + expect(claude.connections).toHaveLength(1) + expect(wrote(connection, 'Carry on.')).toBe(false) + expect(child()?.close).toBeDefined() + expect(scopes()).toContain('provider-close-unproven') +}) + +it('refuses an option change while the close stays unverifiable, never writing to the old child', async () => { + const connection = await stopWithUnprovenClose(2) + + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' } + } + }) + expect(connection.closeCount).toBe(2) + expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) + + // Joined again once the exit is proven, the pick is the chat's at rest, for the next start. + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(child()).toBeNull() + expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) +}) + +it('keeps an option change at rest when the Stop proved the exit and only its drain failed', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + // The Stop reads the journal without a drain, so the exit's wind-down is the only drain to fail. + vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'lifecycleBarrier').mockResolvedValueOnce({ + ok: false, + error: new Error('drain barrier lost') + }) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + await eventually(() => expect(child()).toBeNull()) + expect(scopes().filter((scope) => scope === 'exit-lifecycle-barrier')).toHaveLength(1) + + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) + expect(connection.closeCount).toBe(1) + expect(claude.connections).toHaveLength(1) +}) + +it('reports a descendant left behind by a proven root exit, and blocks nothing', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + refuseWritesOnceClosed(connection) + connection.close = async () => { + connection.closeCount += 1 + connection.closed = true + connection.exitVerdict = { root: 'exited', tree: 'live' } + return false + } + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + + await eventually(() => expect(child()).toBeNull()) + expect(scopes()).toContain('provider-close-after-exit') + await send('Carry on.') + await resumedWith(connection, 'Carry on.') +}) + +it('has quit join a close still unverifiable, and hand the lease back once it proves', async () => { + const connection = await stopWithUnprovenClose(1) + + await host.flushAllStreamedEvents() + + expect(connection.closeCount).toBe(2) + expect(lease()).toMatchObject({ claimStatus: 'released', ownerProcess: null }) +}) + +it('has the idle reaper join a close still unverifiable, ending the record with the Stop it finishes', async () => { + const connection = await stopWithUnprovenClose(1) + + await host['lifetime'].idleSweep.tick() + + expect(connection.closeCount).toBe(2) + expect(lease()).toMatchObject({ claimStatus: 'released', ownerProcess: null }) + expect(host.hasSession(SESSION)).toBe(false) +}) + +it("ends a Claude journal-sink failure as Orca's own fault, never a quiet rest", async () => { + const connection = claude.connections[0]! + await openTurn(connection) + + host['eventRecovery'].recoverAfterSinkFailure(SESSION, new Error('journal write failed')) + + await eventually(() => expect(child()).toBeNull()) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ + cause: 'exit', + failure: { kind: 'hostFault' } + }) + expect(connection.closeCount).toBe(1) +}) + +it('delivers a message accepted after a tab close was asked for, when the late exit lands first', async () => { + const connection = claude.connections[0]! + closeUnprovenFor(connection, 1) + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + const closing = child()! + expect(closing.close).toMatchObject({ cause: 'user-close' }) + + // The message is accepted, then the exit report ends the child, before the delivery step runs. + const held = deferred() + const holding = host['tasks'].serialize(SESSION, () => held.promise) + const sent = send('Carry on.') + const ended = host['tasks'].serialize(SESSION, () => + host['eventRecovery'].endExitedChildUnderSerialize(SESSION, closing, { + expected: true, + reason: 'claude session closed' + }) + ) + held.resolve() + await holding + const next = await sent + await ended + + // The close was asked for before the message, so its end closes nothing the message carries. + await resumedWith(connection, 'Carry on.') + expect(await submission(next)).not.toMatchObject({ dispatchState: 'rejected' }) +}) + +it('fails only the message its own refusal is about, never one accepted while that start was refused', async () => { + const connection = claude.connections[0]! + await openTurn(connection) + // The Stop's close and the first send's join both come back unproven; the second's proves. + const close = connection.close + const joined = deferred() + let attempts = 0 + connection.close = async () => { + attempts += 1 + connection.closeCount += 1 + connection.closed = true + if (attempts === 1) { + return false + } + return attempts === 2 ? joined.promise : close() + } + refuseWritesOnceClosed(connection) + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + + const first = await send('First.') + await eventually(() => expect(attempts).toBe(2)) + // Accepted while the first send's join still runs, then that join comes back unproven. + const second = send('Second.') + joined.resolve(false) + + const secondId = await second + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) + await resumedWith(connection, 'Second.') + expect(await submission(secondId)).not.toMatchObject({ dispatchState: 'rejected' }) +}) + +it('runs the stop again for every ask after a close that came back unproven', async () => { + const connection = await stopWithUnprovenClose(3) + + await send('First.') + await eventually(() => expect(connection.closeCount).toBe(2)) + await laneDrained() + await send('Second.') + await eventually(() => expect(connection.closeCount).toBe(3)) + await laneDrained() + + // Each ask asked again; the third proves the exit. + await send('Third.') + await resumedWith(connection, 'Third.') + expect(connection.closeCount).toBe(4) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts deleted file mode 100644 index 65dcfa42073..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ /dev/null @@ -1,569 +0,0 @@ -// A Claude Stop whose close could not prove the child gone, then the user's next message, on the -// shipping adapter and host. That child takes no input, so the send retries the stop first; still -// unproven, the message waits with its reason until a later retry proves the exit. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' -import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' -import { - fakeClaude, - PROVIDER_SESSION_ID, - type FakeConnection -} from '../../claude/claude-structured-session-test-support' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' - -const CALLER = { callerKey: 'client-1' } -const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] - -let root: string -let host: StructuredAgentSessionHost -let adapter: ClaudeStructuredSessionAdapter -let store: AgentSessionRecordStore -let claude: ReturnType -let log: ReturnType - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-claude-unproven-stop-send-')) - resetHostTestOperationIds() - log = recordingStructuredAgentSessionLogger() - claude = fakeClaude({ - replayUuid: null, - routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } - }) - const lifecycle: Promise[] = [] - adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: root, - claudeConfigDir: join(root, 'claude-home'), - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, - continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 - }), - onEvent: (event) => { - const mapped = structuredClaudeLifecycleEvent(event) - if (mapped) { - lifecycle.push(host.handleAdapterEvent(mapped)) - } - }, - onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => NOW - }) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - store, - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - logger: log.logger, - now: () => NOW - }) - const params = hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } - }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter.awaitStarted(SESSION) - await Promise.all(lifecycle) -}) - -afterEach(async () => { - await adapter.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function eventually(assertion: () => T | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -function envelope( - method: - | 'agentSession.send' - | 'agentSession.cancel' - | 'agentSession.setOption' - | 'agentSession.queuedMessageSend', - // The fingerprint's own field shape, as the sibling host tests type it. - fields: Parameters[0]['fields'] -) { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -async function send(text: string): Promise { - const body = hostTestMessage(text) - const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - if (!sent.ok) { - throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) - } - return sent.value.clientMessageId -} - -async function submission(clientMessageId: string) { - await host.flushStreamedEvents(SESSION) - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - ) -} - -function frame(connection: FakeConnection, message: Record): void { - connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) -} - -function wrote(connection: FakeConnection, text: string): boolean { - return connection.sent.some((message) => JSON.stringify(message).includes(text)) -} - -async function openTurn(connection: FakeConnection): Promise { - const text = 'Write a long reply.' - const clientMessageId = await send(text) - await eventually(() => expect(wrote(connection, text)).toBe(true)) - frame(connection, { - type: 'system', - subtype: 'init', - uuid: 'init-1', - model: 'claude-sonnet-5', - capabilities: CAPABILITIES - }) - const written = connection.sent.at(-1)! - frame(connection, { ...written, uuid: written.uuid }) - frame(connection, { - type: 'assistant', - uuid: 'stopped-turn-leaf', - parent_tool_use_id: null, - message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } - }) - await eventually(async () => - expect((await submission(clientMessageId))?.dispatchState).toBe('accepted') - ) - expect( - activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) - ).not.toBeNull() -} - -function stop() { - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) -} - -function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) -} - -/** A commit queues a serialized wake, and the wake queues its delivery step behind that. */ -async function commitSettled(): Promise { - await laneDrained() - await laneDrained() -} - -/** As the real connection: once a close begins it refuses every write, proven or not. */ -function closeUnprovenFor(connection: FakeConnection, failures: number): void { - const close = connection.close - let left = failures - connection.close = async () => { - if (left === 0) { - return close() - } - left -= 1 - connection.closeCount += 1 - connection.closed = true - return false - } - const write = connection.send - connection.send = (message, beforeDispatch) => - connection.closed - ? Promise.reject( - claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) - ) - : write(message, beforeDispatch) -} - -const INTERRUPTED_RESULT = { - type: 'result', - subtype: 'error_during_execution', - is_error: true, - terminal_reason: 'aborted_streaming', - uuid: 'interrupted-result' -} - -/** Stops a turn whose child's close cannot prove the exit `failures` times, and lets the Stop's - * second step fail on it. */ -async function stopWithUnprovenClose(failures: number): Promise { - const connection = claude.connections[0]! - await openTurn(connection) - closeUnprovenFor(connection, failures) - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - frame(connection, INTERRUPTED_RESULT) - await laneDrained() - expect(connection.closeCount).toBe(1) - expect(owedWindDown()).toBeDefined() - return connection -} - -function owedWindDown() { - return host['sessions'].get(SESSION)?.owesProviderChildWindDown -} - -async function waitRows() { - await host.flushStreamedEvents(SESSION) - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.failure?.kind === 'previousExitUnverifiable' - ? [item.body] - : [] - ) -} - -async function resumedWith(connection: FakeConnection, text: string): Promise { - return eventually(() => { - const started = claude.connections.at(-1)! - expect(started).not.toBe(connection) - expect(wrote(started, text)).toBe(true) - return started - }) -} - -it('retries the close a Stop could not prove before the next message, then sends it to a resumed child', async () => { - const connection = await stopWithUnprovenClose(1) - - const next = await send('Carry on.') - const resumed = await resumedWith(connection, 'Carry on.') - - expect(connection.closeCount).toBe(2) - expect(wrote(connection, 'Carry on.')).toBe(false) - expect(resumed.closed).toBe(false) - expect(owedWindDown()).toBeUndefined() - expect((await submission(next))?.dispatchState).toBe('pending') - expect(await waitRows()).toEqual([]) -}) - -it('holds the message with its reason while the exit stays unverifiable, and sends it once a later retry proves it', async () => { - const connection = await stopWithUnprovenClose(3) - - // Never refused: the send is accepted and returns at once. - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(await waitRows()).toEqual([ - { - kind: 'status', - tone: 'warning', - text: 'Claude from before may still be running. Your messages will send once it stops.', - failure: { kind: 'previousExitUnverifiable' } - } - ]) - // Still queued, drawn below the chat; never written to the child the Stop could not end. - expect(await submission(next)).toMatchObject({ dispatchState: 'pending' }) - expect((await submission(next))?.handedOverAt).toBeUndefined() - expect(claude.connections).toHaveLength(1) - expect(wrote(connection, 'Carry on.')).toBe(false) - expect(owedWindDown()).toBeDefined() - // The Stop's attempt and the send's one retry: the row's own commit retries nothing. - expect(connection.closeCount).toBe(2) - - // A second message retries once more, and waits under the same row. - const second = await send('And this.') - await eventually(() => expect(connection.closeCount).toBe(3)) - await laneDrained() - expect(await submission(second)).toMatchObject({ dispatchState: 'pending' }) - expect(await waitRows()).toHaveLength(1) - - // The sweep's next tick retries the stop; the exit is proven and both messages go out. - await host['lifetime'].idleSweep.tick() - const resumed = await resumedWith(connection, 'Carry on.') - await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) - expect(connection.closeCount).toBe(4) - expect(owedWindDown()).toBeUndefined() - expect(await waitRows()).toHaveLength(1) -}) - -it('lets a held message be withdrawn with Stop, and the owed stop still ends on the next retry', async () => { - const connection = await stopWithUnprovenClose(2) - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - expect(await submission(next)).toMatchObject({ dispatchState: 'rejected' }) - expect(owedWindDown()).toBeDefined() - - await host['lifetime'].idleSweep.tick() - expect(connection.closeCount).toBe(3) - expect(owedWindDown()).toBeUndefined() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - // Nothing was waiting, so no child starts. - expect(claude.connections).toHaveLength(1) - expect(wrote(connection, 'Carry on.')).toBe(false) -}) - -function setModel(model: string) { - const fields = { key: 'model', value: model } - return host.setOption(CALLER, { envelope: envelope('agentSession.setOption', fields), ...fields }) -} - -it('refuses an option change with the exit unverifiable after retrying the stop, never writing to the old child', async () => { - const connection = await stopWithUnprovenClose(2) - - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - details: { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' } - } - }) - expect(connection.closeCount).toBe(2) - expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) - expect(owedWindDown()).toBeDefined() - - // Trying again retries the stop; proven, the pick is the chat's at rest, for the next start. - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(connection.closeCount).toBe(3) - expect(owedWindDown()).toBeUndefined() - expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) - expect(claude.connections).toHaveLength(1) -}) - -function stopBackgroundTasks() { - const fields = { scope: 'background-tasks' as const } - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) -} - -/** Holds the user's next message on a Stop whose exit stays unproven through the send's retry. */ -async function heldAfterStop(): Promise { - const connection = await stopWithUnprovenClose(2) - await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(connection.closeCount).toBe(2) - return connection -} - -it('sends a held message once an option change proves the exit: the stop that lands hands it over', async () => { - const connection = await heldAfterStop() - - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(connection.closeCount).toBe(3) - const resumed = await resumedWith(connection, 'Carry on.') - expect(resumed.closed).toBe(false) - expect(owedWindDown()).toBeUndefined() - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) -}) - -it('sends a held message once a background-task stop proves the exit, though that stop finds no agent', async () => { - const connection = await heldAfterStop() - - // Proven gone, the old agent has no tasks left to stop, and the message still goes out. - await expect(stopBackgroundTasks()).resolves.toMatchObject({ - ok: false, - refusal: { details: { reason: 'noLiveOwner' } } - }) - expect(connection.closeCount).toBe(3) - await resumedWith(connection, 'Carry on.') - expect(owedWindDown()).toBeUndefined() -}) - -it('sends a message held after a tab close whose exit was unproven, rather than rejecting it as closed', async () => { - const connection = claude.connections[0]! - closeUnprovenFor(connection, 2) - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() - expect(owedWindDown()).toMatchObject({ cause: 'user-close' }) - - // The chat stays open on the host; the user sends again, and the message waits on that close. - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(connection.closeCount).toBe(2) - - // The close was asked for before this message, so the retry that lands ends nothing it waited on. - await host['lifetime'].idleSweep.tick() - await laneDrained() - expect(await submission(next)).not.toMatchObject({ dispatchState: 'rejected' }) - await resumedWith(connection, 'Carry on.') - expect(owedWindDown()).toBeUndefined() -}) - -it('never stops a live child for a wind-down another, earlier child still owes', async () => { - const connection = claude.connections[0]! - const session = host['sessions'].get(SESSION)! - session.owesProviderChildWindDown = { - generation: 'an-earlier-child', - fence: session.child!.fence, - cause: 'user-stop', - requestedAt: session.journal.cursor() - } - - await send('Carry on.') - await eventually(() => expect(wrote(connection, 'Carry on.')).toBe(true)) - await laneDrained() - await host['lifetime'].idleSweep.tick() - expect(connection.closeCount).toBe(0) - expect(claude.connections).toHaveLength(1) -}) - -it('retries once per new message: commits after a second waiting message retry nothing', async () => { - const connection = await stopWithUnprovenClose(6) - await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - await send('And this.') - await eventually(() => expect(connection.closeCount).toBe(3)) - await laneDrained() - - // Any journal commit wakes the delivery loop while a message is queued. - const session = host['sessions'].get(SESSION)! - for (const n of [1, 2, 3]) { - await session.journal.appendItem( - { provider: 'orca', clientMessageId: `unrelated-${n}` }, - { kind: 'status', text: `Unrelated ${n}.` }, - { fence: store.getRecord(SESSION)!.lease.runtimeFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await commitSettled() - } - expect(connection.closeCount).toBe(3) - expect(claude.connections).toHaveLength(1) - - // The sweep still retries, one pass a tick, until the exit is proven; then both go out. - for (const _tick of [1, 2, 3, 4]) { - await host['lifetime'].idleSweep.tick() - } - expect(connection.closeCount).toBe(7) - const resumed = await resumedWith(connection, 'Carry on.') - await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) -}) - -it('queues a follow-up as a draft by default while a message waits, and Steer retries the stop once for it', async () => { - const connection = await stopWithUnprovenClose(3) - await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - - // Queueing follow-ups is the default: the waiting message reads as working, so this is a draft. - const body = hostTestMessage('And this.') - const delivery = 'queue-if-active' as const - const queued = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body, delivery }), - body, - delivery - }) - expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) - await commitSettled() - expect(connection.closeCount).toBe(2) - - // Steer makes it a waiting message, which retries once and waits under the same note. - const messageId = queued.ok && 'queued' in queued.value ? queued.value.queued.messageId : '' - await expect( - host.queuedMessageSend(CALLER, { - envelope: envelope('agentSession.queuedMessageSend', { messageId }), - messageId - }) - ).resolves.toMatchObject({ ok: true }) - await eventually(() => expect(connection.closeCount).toBe(3)) - await commitSettled() - expect(connection.closeCount).toBe(3) - expect(claude.connections).toHaveLength(1) - expect(await waitRows()).toHaveLength(1) - - await host['lifetime'].idleSweep.tick() - const resumed = await resumedWith(connection, 'Carry on.') - await eventually(() => expect(wrote(resumed, 'And this.')).toBe(true)) -}) - -it('rejects as closed a message a second tab close closed, though that close could not reject it itself', async () => { - const connection = claude.connections[0]! - closeUnprovenFor(connection, 3) - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() - const next = await send('Carry on.') - await eventually(async () => expect(await waitRows()).toHaveLength(1)) - await laneDrained() - expect(connection.closeCount).toBe(2) - - // The second close's own rejection of what is queued fails; its stop is a new ask all the same. - const session = host['sessions'].get(SESSION)! - vi.spyOn(session.journal, 'rejectQueuedSubmissions').mockRejectedValueOnce(new Error('disk full')) - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() - expect(connection.closeCount).toBe(3) - expect(await submission(next)).toMatchObject({ dispatchState: 'pending' }) - - // The retry that proves the exit closes what that second close closed. - await host['lifetime'].idleSweep.tick() - await commitSettled() - expect(connection.closeCount).toBe(4) - expect(await submission(next)).toMatchObject({ dispatchState: 'rejected' }) - expect(claude.connections).toHaveLength(1) -}) - -it('notes why a message waits when another operation failed its retry before the message was delivered', async () => { - const connection = await stopWithUnprovenClose(2) - - // The send is accepted, then the option change's failed retry runs before the send's delivery. - const sent = send('Carry on.') - const option = setModel('claude-opus-5') - await expect(option).resolves.toMatchObject({ - ok: false, - refusal: { details: { reason: 'previousExitUnverifiable' } } - }) - await sent - await commitSettled() - expect(connection.closeCount).toBe(2) - expect(await waitRows()).toHaveLength(1) - expect(claude.connections).toHaveLength(1) -}) - -it('keeps an option change at rest when the Stop proved the exit and only its bookkeeping keeps failing', async () => { - const connection = claude.connections[0]! - await openTurn(connection) - // The close proves the exit; draining what the old agent wrote fails for the Stop. The Stop reads - // the journal without a drain, so its close is the first and only drain to fail here. - const barrierLost = { ok: false as const, error: new Error('drain barrier lost') } - const drained = vi - .spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained') - .mockResolvedValueOnce(barrierLost) - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - frame(connection, INTERRUPTED_RESULT) - await laneDrained() - expect(connection.closeCount).toBe(1) - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(owedWindDown()).toBeDefined() - - // The option change's retry of that bookkeeping fails again: reported, and the pick still lands. - drained.mockResolvedValueOnce(barrierLost) - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(owedWindDown()).toBeDefined() - expect(log.scopes().filter((scope) => scope === 'owed-stop-retry')).toHaveLength(1) - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) - expect(connection.closeCount).toBe(1) - expect(claude.connections).toHaveLength(1) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index b1922b913ed..4ac8e1e35ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -263,7 +263,7 @@ describe('a turn cut short by closing its provider', () => { expect(notices).toEqual(ONE_NOTICE) }) - it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { + it("keeps the user's cancellation when a close's drain fails after the provider settled", async () => { await runningTurn() const sink = host['runtimeState'].eventSinkFor(SESSION) const drained = sink.drained.bind(sink) @@ -275,10 +275,9 @@ describe('a turn cut short by closing its provider', () => { } return drained() }) - // The adapter settled the turn, then the close aborted at the drain after it. - await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + // The adapter settled the turn, then the drain after it failed: reported, and the close ends. + await expect(host.close(SESSION, 'user-close')).resolves.toBeUndefined() expect(closeCalls).toBe(1) - await host.close(SESSION, 'user-close') const { turn } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) @@ -287,7 +286,7 @@ describe('a turn cut short by closing its provider', () => { ['user-close', { outcome: 'cancellation' }], ['evict', { outcome: undefined }] ] as const)( - "keeps a %s's cause when the idle sweep finishes a wind-down it could not", + "keeps a %s's cause when the close's drain fails before the host settles its turn", async (cause, verdict) => { providerEnd = null await runningTurn() @@ -301,10 +300,8 @@ describe('a turn cut short by closing its provider', () => { } return drained() }) - // The provider is proven gone, then the close aborts before the host settles its turn. - await expect(host.close(SESSION, cause)).rejects.toThrow() - - await host.collaboratorsForTests().lifetime.idleSweep.tick() + // The provider is proven gone, then the drain fails: the host still settles the turn. + await expect(host.close(SESSION, cause)).resolves.toBeUndefined() expect(closeCalls).toBe(1) const { turn, notices } = await settledTurn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7f828a48468..1dbbac5282d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -27,7 +27,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const CALLER = { callerKey: 'client-1' } @@ -38,6 +38,7 @@ let codex: ReturnType let notify: (method: string, params: unknown) => void /** Read at each start, so a test can say what the next start resumes. */ let launch: { resumeThreadId?: string | null } +let log: ReturnType let disposeSession: MockInstance> beforeEach(async () => { @@ -58,19 +59,19 @@ beforeEach(async () => { return {} } const store = await openTestAgentSessionRecordStore(root) - // The runtime's wiring: an echo accepts its send, and an exit reaches the host. + // The runtime's wiring: an echo accepts its send, and every exit reaches the host. launch = {} const adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { + if (event.type === 'ended' && 'cause' in event) { void host.handleAdapterEvent(event) } } }) disposeSession = vi.spyOn(adapter, 'disposeSession') host = new StructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), + logger: (log = recordingStructuredAgentSessionLogger()).logger, store, adapter: Object.assign(adapter, { supportsCreate: () => true }), journalDatabase: openTestJournalHostDatabase(root), @@ -432,16 +433,17 @@ describe('a Codex Stop whose interrupt failed', () => { }) describe('a message after a Codex Stop whose exit was unproven', () => { - it('retries that stop first, then goes to a fresh Codex, never to the old one', async () => { + /** A Stop whose interrupt fails ends the child, whose close cannot prove the exit `failures` + * times. As the real connection, it refuses every request once a close begins. */ + async function stopWithUnprovenClose(failures: number) { await runningTurn() codex.routes['turn/interrupt'] = () => { throw interruptFailure('internal error') } - // As the real connection: once a close begins it refuses every request, proven or not. const old = codex.connections.at(-1)! const close = old.close const request = old.request - let unproven = 1 + let unproven = failures old.close = async () => { old.closed = true if (unproven === 0) { @@ -454,28 +456,81 @@ describe('a message after a Codex Stop whose exit was unproven', () => { old.closed ? Promise.reject(new Error('codex app-server is closing')) : request(method, params) - await stop() await host.flushStreamedEvents(SESSION) - expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeDefined() + return old + } + + const startedWith = (connection: (typeof codex.connections)[number], text: string) => + connection.calls.some( + (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes(text) + ) + + it('rejects the message while the close stays unverifiable, and starts no Codex beside it', async () => { + const old = await stopWithUnprovenClose(2) + + const sent = await send('carry on') + if (!sent.ok || !('submission' in sent.value)) { + throw new Error(JSON.stringify(sent)) + } + const id = sent.value.submission.clientMessageId + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "Couldn't stop Codex from before. Send your message again to try once more." + }) + ) + expect(codex.connections).toHaveLength(1) + expect(startedWith(old, 'carry on')).toBe(false) + expect(host['sessions'].get(SESSION)?.child?.close).toBeDefined() + }) + + it('ends the record when the root exits after its close gave up, with no one asking again', async () => { + const old = await stopWithUnprovenClose(1) + const handled = vi.spyOn(host, 'handleAdapterEvent') + + old.handlers.onExit?.(new Error('codex app-server exited'), { expected: true }) + + // The close Orca began, ended: Orca's, never blamed on Codex as a crash. + expect(handled).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'requested-close', failure: { kind: 'hostFault' } }) + ) + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) + expect(codex.connections).toHaveLength(1) + }) + + it('joins that close first, then goes to a fresh Codex, never to the old one', async () => { + const old = await stopWithUnprovenClose(1) + expect(host['sessions'].get(SESSION)?.child?.close).toMatchObject({ cause: 'user-stop' }) // The next start resumes the chat's thread, as the runtime's launch resolves it from the record. launch.resumeThreadId = THREAD const sent = await send('carry on') expect(sent).toMatchObject({ ok: true }) await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) - await vi.waitFor(() => - expect( - codex.connections[1]!.calls.some( - (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') - ) - ).toBe(true) - ) - expect( - old.calls.some( - (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') - ) - ).toBe(false) - expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeUndefined() + await vi.waitFor(() => expect(startedWith(codex.connections[1]!, 'carry on')).toBe(true)) + expect(startedWith(old, 'carry on')).toBe(false) + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ cause: 'user-stop' }) + }) + + it('reports a process tree left unproven by a proven root exit, and ends the record anyway', async () => { + await runningTurn() + codex.routes['turn/interrupt'] = () => { + throw interruptFailure('internal error') + } + const old = codex.connections.at(-1)! + // The forced kill saw the root exit but could not prove the rest of the tree gone. + Object.defineProperty(old, 'processTreeUnproven', { get: () => old.closed }) + + await stop() + + await vi.waitFor(() => expect(host['sessions'].get(SESSION)?.child).toBeNull()) + expect(log.entries.map((entry) => entry.fields.scope)).toContain('provider-close-after-exit') + expect(host['sessions'].get(SESSION)?.lastEndedChild).toMatchObject({ rootGone: true }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 2dac4cacb7d..0133fbc9fed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -19,7 +19,6 @@ import { sweepOnce, type RestTestRig } from './structured-agent-session-rest-test-rig' -import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { hostTestAttachParams } from './structured-agent-session-host-test-data' let rig: RestTestRig @@ -57,9 +56,8 @@ describe('a stop that fails', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(openSession()?.owesProviderChildWindDown).toMatchObject({ - generation: expect.any(String) - }) + // The child stays on record with its close unproven, and the next tick joins it. + expect(openSession()?.child?.close).toMatchObject({ cause: 'evict' }) await sweepOnce(rig.host) expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) @@ -67,23 +65,21 @@ describe('a stop that fails', () => { expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() }) - it('finishes its wind-down on the next tick, whatever its own writes did to the clock (P2-11 b)', async () => { + it('re-derives a release its wind-down could not write before the handle closes (P2-11 b)', async () => { await foundRestTestChat(rig) rig.clock.now += IDLE_MS + 1 - // The child is proven gone and its work settled, then handing the lease back fails. + // The child is proven gone and its work settled, then handing the lease back fails once. const transition = vi .spyOn(rig.store, 'transitionHandoff') .mockRejectedValueOnce(new Error('store unavailable')) await sweepOnce(rig.host) - expect(transition).toHaveBeenCalledOnce() - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - // Five minutes later, not thirty. - rig.clock.now += 5 * 60_000 - await sweepOnce(rig.host) + // Reported, never retried as a stop: the handle's close writes the release from the proof. + expect(transition).toHaveBeenCalledTimes(2) expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') expect(rig.adapter.closeSession).toHaveBeenCalledOnce() + expect(rig.host.hasSession(SESSION)).toBe(false) }) }) @@ -287,50 +283,3 @@ describe('a start that never finishes (P2-15)', () => { await expect(sweepOnce(rig.host)).resolves.toBeUndefined() }) }) - -describe('the wind-down retry with a message queued (P2-31)', () => { - it('waits for the delivery rather than rejecting a message accepted after the failed stop', async () => { - const queued = { clientMessageId: 'm', dispatchState: 'pending', handoverRecorded: true } - const session = { - journal: { - submissions: () => [queued], - pendingSubmissions: () => [queued], - snapshot: () => ({ items: [] }) - }, - child: null, - owesProviderChildWindDown: { generation: 'generation-1', fence: 1 } - } - const stopAgent = vi.fn(async () => undefined) - const finishOwedWindDown = vi.fn(async () => true) - const sweep = new StructuredAgentSessionIdleSweep({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. - sessions: Object.assign(new Map([[SESSION, session as never]]), { - lastActivityAt: () => 0, - touch: () => undefined - }), - serialize: (_id, task) => task(), - now: () => IDLE_MS + 1, - isDisposed: () => false, - deliveryActive: () => true, - childWork: () => undefined, - hasOpenDispatch: () => false, - providerHoldsDispatch: () => false, - stopAgent, - stopStartingAgent: stopAgent, - finishOwedWindDown, - closeConversation: vi.fn(async () => false), - // A failed step fails the test. - logger: { - warn: (_message, fields) => { - throw fields.error - }, - error: (_message, fields) => { - throw fields.error - } - } - }) - await sweep.tick() - expect(stopAgent).not.toHaveBeenCalled() - expect(finishOwedWindDown).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 19af6d048d4..3a35b4b138a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -12,10 +12,10 @@ import { import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' import { abandonQueuedStructuredAgentSessionMessages, closeStructuredAgentSessionConversationUnderSerialize, - finishOwedStructuredAgentSessionWindDownUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, type StructuredAgentSessionLifetimeContext, @@ -57,11 +57,13 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) - const finishOwedWindDown = (sessionId: string) => - finishOwedStructuredAgentSessionWindDownUnderSerialize(host.context(), sessionId) - const closeConversation = (sessionId: string): Promise => - closeStructuredAgentSessionConversationUnderSerialize( + const closeConversation = async (sessionId: string): Promise => { + // The handle carries the proof that releases a lease its child's wind-down could not. + if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { + return false + } + return closeStructuredAgentSessionConversationUnderSerialize( { sessions, closeStatus: (id) => { @@ -72,6 +74,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, sessionId ) + } const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, @@ -87,7 +90,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), - finishOwedWindDown, // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 6f03cc1345c..4f8679578cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -311,9 +311,10 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + // Bookkeeping after the proven exit: reported, never the Stop's failure. expect(log.entries).toContainEqual( expect.objectContaining({ - fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) + fields: expect.objectContaining({ scope: 'exit-wind-down', sessionId: SESSION }) }) ) expect((await submission(id))?.dispatchState).toBe('rejected') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 83ab0e8e6fe..b9601ed1a8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -26,10 +26,7 @@ import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' -import { - isStructuredAgentSessionPreviousExitUnverifiable, - type StructuredAgentSessionResumeOutcome -} from './structured-agent-session-agent-start' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild, @@ -42,10 +39,6 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' -import { - recordStructuredAgentSessionWindDownWait, - structuredAgentSessionWindDownWaitHolds -} from './structured-agent-session-wind-down-wait-row' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -121,16 +114,8 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - const { refusal, diagnostic } = prepared - // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. - const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 - const cause = { - refusal, - ...(diagnostic ? { diagnostic } : {}), - ...(newSession ? { newSession: true as const } : {}) - } await this.deps.serialize(sessionId, () => - this.fail(sessionId, { startKey: null, cause }) + this.fail(sessionId, this.refusedStart(sessionId, prepared)) ) return } @@ -188,23 +173,15 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } - // Already waiting on a stop that could not prove its child gone: a new message retries it, and - // any other retry that lands wakes this loop itself, so the waiting row's own commit does not. - // Another operation's retry may have failed first, so the row is made sure of here too. - if (structuredAgentSessionWindDownWaitHolds(session)) { - await recordStructuredAgentSessionWindDownWait(session, sessionId, this.deps) - return this.stop(sessionId) - } const failedStart = startThatFailedWhileQueued(session, oldest) if (failedStart) { return this.fail(sessionId, failedStart) } const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) - if (!ready.ok && isStructuredAgentSessionPreviousExitUnverifiable(ready.refusal)) { - // The start retried that stop first and still could not prove the exit: the message waits, - // saying why, rather than being refused. The row is written once per unproven child. - await recordStructuredAgentSessionWindDownWait(session, sessionId, this.deps) - return this.stop(sessionId) + if (!ready.ok && ready.refusal.details?.reason === 'previousExitUnverifiable') { + // Failed in the step that was refused: a message accepted, or an exit proven, after it must + // not be failed for a verdict that no longer holds. + return this.fail(sessionId, this.refusedStart(sessionId, ready)) } if (!ready.ok) { return ready @@ -272,6 +249,23 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } + /** A start the session refused, as the failure every queued message it was for is rejected with. */ + private refusedStart( + sessionId: string, + { refusal, diagnostic }: Extract + ): StartFailure { + // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. + const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 + return { + startKey: null, + cause: { + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(newSession ? { newSession: true as const } : {}) + } + } + } + private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { const session = this.deps.sessions.get(sessionId) if (session) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 2b9340859f4..398d04873d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -3,11 +3,18 @@ import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-se import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' -import { settleUnexpectedStructuredAgentSessionExit } from './structured-agent-session-unexpected-exit' +import { + endExitedStructuredAgentSessionChildUnderSerialize, + settleStructuredAgentSessionChildExit, + type StructuredAgentSessionChildExit, + type StructuredAgentSessionChildExitContext +} from './structured-agent-session-child-exit' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -22,13 +29,31 @@ export class StructuredAgentSessionEventRecovery { publishStatus?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number + runtimeState: StructuredAgentSessionHostRuntimeState + wakeDelivery: (sessionId: string) => void } ) {} - private get exitContext() { - return { ...this.context, logger: this.context.deps.logger } + private get exitContext(): StructuredAgentSessionChildExitContext { + const { deps, runtimeState } = this.context + return { + ...this.context, + logger: deps.logger, + route: { + runtimeState, + acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) + } + } } + /** The one exit handler, for a caller inside the session's serialize that proved the exit. */ + endExitedChildUnderSerialize = ( + sessionId: string, + child: StructuredAgentSessionProviderChild, + exit: StructuredAgentSessionChildExit + ): Promise => + endExitedStructuredAgentSessionChildUnderSerialize(this.exitContext, sessionId, child, exit) + recoverAfterSinkFailure(sessionId: string, error: unknown): void { if (this.sinkFailures.has(sessionId)) { return @@ -39,26 +64,17 @@ export class StructuredAgentSessionEventRecovery { const child = this.context.sessions.get(sessionId)?.child const stop = this.context.deps.adapter.forceCloseSession ?? this.context.deps.adapter.closeSession - if (!child || !stop) { - return null + if (!child || !stop || !(await stopAgentSessionProviderRoot(() => stop(sessionId)))) { + return } - const { fence, generation: acquisitionGeneration } = child - const stopped = await stopAgentSessionProviderRoot(() => stop(sessionId)) - if (!stopped || !acquisitionGeneration) { - return null - } - return { - type: 'ended', - sessionId, + // Ended in the same step as the stop, so no report of that close can end it first as a + // quiet rest: Orca stopped the provider because its own journal failed. + await this.endExitedChildUnderSerialize(sessionId, child, { + expected: false, reason: `journal sink failure: ${error instanceof Error ? error.message : String(error)}`, - // Orca stopped the provider because its own journal failed. - failure: agentSessionFailureFact('hostFault'), - cause: 'unexpected-exit', - fence, - acquisitionGeneration - } as const + failure: agentSessionFailureFact('hostFault') + }) }) - .then((event) => (event ? this.handle(event) : undefined)) .catch((error: unknown) => this.context.deps.logger.warn( 'stopping a provider after its journal failed did not finish', @@ -72,12 +88,13 @@ export class StructuredAgentSessionEventRecovery { .finally(() => this.sinkFailures.delete(sessionId)) } - /** An exit is settled and shown; nothing restarts the child. The next send does, through the - * delivery loop, which also owns any message still queued. */ + /** Every child's exit ends its record here, expected or not. An exit is settled and shown; + * nothing restarts the child. The next send does, through the delivery loop, which also owns any + * message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } - await settleUnexpectedStructuredAgentSessionExit(this.exitContext, event) + await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts deleted file mode 100644 index f34caba3593..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts +++ /dev/null @@ -1,51 +0,0 @@ -// A bound on how long teardown may take, expressed as a wrapper around the eviction steps rather -// than a change to them. -// -// The step list is ordered and abort-on-failure for reasons that have nothing to do with time, and -// a deadline must not disturb either. Wrapping each step's `run` keeps the order, and a timeout -// surfaces as that step failing — which is exactly the behavior wanted here: the rest of the -// eviction aborts, the session stays indexed, and the child stays LOADED. A stuck app-server that -// is still holding a conversation is a better outcome than one killed out from under it; the next -// close retries. - -import type { StructuredAgentSessionEvictionStep } from './structured-agent-session-eviction' - -export const STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS = 10_000 - -export class StructuredAgentSessionEvictionTimeoutError extends Error { - constructor( - readonly step: string, - readonly timeoutMs: number - ) { - super(`agent session eviction step "${step}" did not finish within ${timeoutMs}ms`) - this.name = 'StructuredAgentSessionEvictionTimeoutError' - } -} - -export function withStructuredAgentSessionEvictionDeadline( - steps: readonly StructuredAgentSessionEvictionStep[], - timeoutMs = STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS -): readonly StructuredAgentSessionEvictionStep[] { - return steps.map((step) => ({ - name: step.name, - run: async (context) => { - let timer: ReturnType | undefined - try { - await Promise.race([ - Promise.resolve(step.run(context)), - new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new StructuredAgentSessionEvictionTimeoutError(step.name, timeoutMs)), - timeoutMs - ) - timer.unref?.() - }) - ]) - } finally { - if (timer) { - clearTimeout(timer) - } - } - } - })) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 6cd06bbd61e..4da10e213f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -5,16 +5,12 @@ import { STRUCTURED_AGENT_SESSION_EVICTION_STEPS, type StructuredAgentSessionEvictionContext } from './structured-agent-session-eviction' -import { - AgentSessionAcquisitionRootExitObservedError, - AgentSessionPreSpawnError -} from './structured-agent-session-adapter' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { withJournalQueueMembers } from './structured-agent-session-journal-double-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -function context(): StructuredAgentSessionEvictionContext & { order: string[] } { +function context(closeError?: Error): StructuredAgentSessionEvictionContext & { order: string[] } { const order: string[] = [] return { order, @@ -24,26 +20,19 @@ function context(): StructuredAgentSessionEvictionContext & { order: string[] } unbind: vi.fn(() => order.push('unbind')), drained: vi.fn(async () => { order.push('drained') - return { ok: true } + return { ok: true as const } }), - close: vi.fn(() => order.push('close')) - } as unknown as StructuredAgentSessionEvictionContext['eventSink'], - adapter: { - closeSession: vi.fn(async () => { - order.push('closeSession') - return true + close: vi.fn(() => { + order.push('close') + if (closeError) { + throw closeError + } }) - } as unknown as StructuredAgentSessionEvictionContext['adapter'], + }, acknowledgeRelease: vi.fn(() => { order.push('acknowledgeRelease') }), - discardSink: vi.fn(() => order.push('discardSink')), - settleWork: vi.fn(async () => { - order.push('settleWork') - }), - releaseLease: vi.fn(async () => { - order.push('releaseLease') - }) + discardSink: vi.fn(() => order.push('discardSink')) } } @@ -56,177 +45,34 @@ function runtimeState(): StructuredAgentSessionHostRuntimeState { } as never) } -describe('structured agent session eviction', () => { - it('stops the child before it lets the sink go, then acknowledges the release', async () => { +describe("the route release after a child's exit", () => { + it('lets the sink go before it acknowledges the release', async () => { const ctx = context() await evictStructuredAgentSession(ctx) - expect(ctx.order).toEqual([ - 'closeSession', - 'drained', - 'settleWork', - 'unbind', - 'close', - 'discardSink', - 'releaseLease', - 'acknowledgeRelease' - ]) + expect(ctx.order).toEqual(['unbind', 'close', 'discardSink', 'acknowledgeRelease']) }) - it('uses disposal rather than handoff close when the chat is removed', async () => { - const ctx = context() - const closeSession = vi.fn(async () => { - throw new Error('resume cursor unavailable') - }) - const disposeSession = vi.fn(async () => true) - ctx.adapter = { ...ctx.adapter, closeSession, disposeSession } - - await evictStructuredAgentSession(ctx) - - expect(disposeSession).toHaveBeenCalledWith('session-1') - expect(closeSession).not.toHaveBeenCalled() - }) - - it('names every step, so a half-finished eviction says which one failed', () => { + it('names every step, so a failure says which one it was', () => { expect(STRUCTURED_AGENT_SESSION_EVICTION_STEPS.map((step) => step.name)).toEqual([ - 'snapshot-before-stop', - 'stop-provider-child', - 'drain-published', - 'settle-dead-generation', 'stop-publishing', 'close-sink', 'discard-sink', - 'release-lease', 'acknowledge-release' ]) }) - it('still stops the child when the pre-stop snapshot cannot drain the sink', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - try { - const ctx = context() - const snapshot = vi.fn() - ctx.beforeProviderChildStop = snapshot - // A journal write that never settles, then a healthy sink once the child is stopped. - vi.mocked(ctx.eventSink.drained).mockReturnValueOnce(new Promise(() => {})) + // Bookkeeping for a process already gone: none of it may keep the child on record. + it('reports a failed step with its name and still runs every step after it', async () => { + const recorded = recordingStructuredAgentSessionLogger() + const ctx = { ...context(new Error('sink already gone')), logger: recorded.logger } - const eviction = evictStructuredAgentSession(ctx) - await vi.advanceTimersByTimeAsync(1_000) + await expect(evictStructuredAgentSession(ctx)).resolves.toBeUndefined() - expect(snapshot).toHaveBeenCalledOnce() - expect(ctx.adapter.closeSession).toHaveBeenCalledOnce() - await eviction - } finally { - vi.useRealTimers() - } - }) - - it('aborts after a failed drain barrier without unbinding or acknowledging the release', async () => { - const ctx = context() - ctx.eventSink.drained = vi.fn(async () => { - ctx.order.push('drained') - return { ok: false, error: new Error('append failed') } - }) as unknown as StructuredAgentSessionEvictionContext['eventSink']['drained'] - - await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ - step: 'drain-published' - }) - expect(ctx.eventSink.unbind).not.toHaveBeenCalled() - expect(ctx.eventSink.close).not.toHaveBeenCalled() - expect(ctx.discardSink).not.toHaveBeenCalled() - expect(ctx.releaseLease).not.toHaveBeenCalled() - expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() - expect(ctx.order).toEqual(['closeSession', 'drained']) - }) -}) - -// Closing the codex child is not silent: the adapter emits its `ended` event and flushes coalesced -// text as it shuts down, and those rows are what clear the running-turn marker. If the sink is -// already closed the journal keeps claiming the agent is working, forever. -describe('rows the provider emits while closing', () => { - it('still reach the journal', async () => { - const state = runtimeState() - const sessionId = 'session-closing-rows' - const sink = state.eventSinkFor(sessionId) - const published: string[] = [] - sink.bind({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these rows are publications only, which reach nothing on the journal but its in-order read. - journal: withJournalQueueMembers({ appendItem: async () => ({}) }) as never, - fence: 1, - publish: () => published.push('final-flush') - }) - - await evictStructuredAgentSession({ - sessionId, - logger: recordingStructuredAgentSessionLogger().logger, - eventSink: sink, - adapter: { - closeSession: async () => { - // What codex-structured-session-close does on its way out. - sink.sink.publish() - return true - } - } as never, - discardSink: () => state.discardEventSink(sessionId), - releaseLease: async () => {}, - acknowledgeRelease: () => {} - }) - - expect(published).toEqual(['final-flush']) - }) -}) - -// `closeSession` returning false means the adapter could not prove the child exited and has kept -// the session indexed on purpose so a retry can reach it. -describe('a child that will not stop', () => { - it.each([ - new AgentSessionAcquisitionRootExitObservedError(new Error('root exited')), - new AgentSessionPreSpawnError(new Error('spawn failed')) - ])('continues eviction after an actionable provider verdict', async (error) => { - const ctx = context() - ctx.adapter.closeSession = vi.fn(async () => { - throw error - }) - const stopped = vi.fn() - ctx.onProviderChildStopped = stopped - - await evictStructuredAgentSession(ctx) - - // The host ends its child on the one reading of the verdict, not a second one of its own. - expect(stopped).toHaveBeenCalledWith({ rootGone: true }) - expect(ctx.order).toEqual([ - 'drained', - 'settleWork', - 'unbind', - 'close', - 'discardSink', - 'releaseLease', - 'acknowledgeRelease' + expect(ctx.order).toEqual(['unbind', 'close', 'discardSink', 'acknowledgeRelease']) + expect(recorded.entries.map((entry) => entry.fields.error)).toEqual([ + expect.objectContaining({ step: 'close-sink' }) ]) - }) - - it('aborts without acknowledging the release, so the next stop is a real retry', async () => { - const ctx = context() - ctx.adapter.closeSession = vi.fn(async () => false) - - await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ - step: 'stop-provider-child' - }) - expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() - expect(ctx.discardSink).not.toHaveBeenCalled() - expect(ctx.order).toEqual([]) - }) - - it('reports the failing step and leaves the sink usable for the retry', async () => { - const ctx = context() - ctx.adapter.closeSession = vi.fn(async () => { - throw new Error('child would not stop') - }) - - await expect(evictStructuredAgentSession(ctx)).rejects.toBeInstanceOf( - StructuredAgentSessionEvictionError - ) - expect(ctx.eventSink.close).not.toHaveBeenCalled() - expect(ctx.acknowledgeRelease).not.toHaveBeenCalled() + expect(recorded.entries[0]?.fields.error).toBeInstanceOf(StructuredAgentSessionEvictionError) }) }) @@ -241,9 +87,7 @@ describe('eviction against the real sink cache', () => { sessionId, logger: recordingStructuredAgentSessionLogger().logger, eventSink: state.eventSinkFor(sessionId), - adapter: { closeSession: async () => true } as never, discardSink: () => state.discardEventSink(sessionId), - releaseLease: async () => {}, acknowledgeRelease: () => {} }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index dcc31c451d2..2ecd885242c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -1,54 +1,28 @@ -// Stopping one structured session's provider child and handing its lease back. +// What a host does with a provider child's route once its exit has ended the child's record: the +// sink it wrote through and the adapter's index of it. Settling its work and handing the lease back +// are the exit handler's (`structured-agent-session-child-exit`); these come after. // // Teardown is a DATA list, not a method body, for the reason this file exists at all: the host // tracked which sessions were live in a map, and tore them down at three unrelated call sites // (app quit, handoff to a TUI, and error cleanup). Closing a chat was never wired to any of them, // so a provider child outlived the chat that owned it for the whole app session. // -// ORDER. The provider child stops FIRST. Closing it is not silent: the codex adapter emits its -// `ended` event and flushes coalesced text as part of shutting down, and those are the rows that -// clear the running-turn marker. Draining or closing the sink ahead of that drops them, which -// leaves the durable journal claiming the agent is still working — a worse outcome than the leak -// this teardown exists to fix. So: stop the child, drain what it emitted on its way out, then let -// the sink go. The one step ahead of the stop only reads, for quit's resume offer. -// -// FAILURE. A step that fails ABORTS the rest. `closeSession` returning false means the child's -// exit was not proven and the adapter has deliberately kept the session indexed so a retry can -// reach it; forgetting it anyway stranded the process forever and reported success. Leaving the -// session in place is what makes the next close a real retry instead of a no-op. +// FAILURE. Each step is bookkeeping for a process already gone, so a step that fails is reported +// and the rest still run: none of them may keep a dead child on record or gate the next send. -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionEvictionContext = { sessionId: string - hasProviderChild?: boolean - eventSink: DeferredStructuredAgentSessionEventSink - adapter: StructuredAgentSessionAdapter + eventSink: Pick logger: StructuredAgentSessionLogger /** Tells the adapter the released lease is done with, so it drops this child's route and index. * The conversation stays: stopping the agent never closes its journal. */ acknowledgeRelease: () => Promise | void /** Drops the cached sink so a later attach mints a fresh one. */ discardSink: () => void - /** Fires right before the stop, while the child's turn and background roster are still live. A - * throw is logged, never allowed to abort the stop. */ - beforeProviderChildStop?: () => void - /** Fires with the stop's verdict once `stopAgentSessionProviderRoot` read the root gone, so host - * bookkeeping stops claiming a child. */ - onProviderChildStopped?: (verdict: StructuredAgentSessionStopVerdict) => void - /** Whether this host still owes the child's wind-down. Distinct from `hasProviderChild`, which a - * proven exit retires mid-run: the two disagree for exactly the steps a retry has to repeat. */ - owesProviderChildWindDown?: boolean - /** Settles work owned by the child after its final callbacks have drained. */ - settleWork?: () => Promise - /** Hands the lease back now that this host's child is proven gone. No-ops when the record is - * not this host's to release. */ - releaseLease: () => Promise } /** The resume offer is advisory; a stalled sink must not hold the child's stop behind it. */ @@ -59,58 +33,25 @@ export type StructuredAgentSessionEvictionStep = { run: (context: StructuredAgentSessionEvictionContext) => Promise | void } +/** Quit's resume offer: what the sidebar shows, read while the child is still running. Events the + * provider already delivered are part of what it showed at the stop. A throw is logged. */ +export async function snapshotBeforeStructuredAgentSessionStop( + context: Pick, + snapshot: () => void +): Promise { + await withTimeout(context.eventSink.drained(), SNAPSHOT_DRAIN_TIMEOUT_MS, null) + try { + snapshot() + } catch { + context.logger.warn('capturing a recovery witness before a stop failed', { + scope: 'recovery-witness', + sessionId: context.sessionId + }) + } +} + export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSessionEvictionStep[] = [ - { - // Quit's resume offer: what the sidebar shows, read while the child is still running. - name: 'snapshot-before-stop', - run: async (context) => { - if (context.hasProviderChild === false || !context.beforeProviderChildStop) { - return - } - // Events the provider already delivered are part of what the sidebar showed at the stop. - await withTimeout(context.eventSink.drained(), SNAPSHOT_DRAIN_TIMEOUT_MS, null) - try { - context.beforeProviderChildStop() - } catch { - context.logger.warn('capturing a recovery witness before a stop failed', { - scope: 'recovery-witness', - sessionId: context.sessionId - }) - } - } - }, - { - name: 'stop-provider-child', - run: async (context) => { - if (context.hasProviderChild === false) { - return - } - // An adapter with no close has nothing to stop; anything else must PROVE the exit. - const stop = context.adapter.disposeSession ?? context.adapter.closeSession - const rootGone = stop - ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) - : true - if (!rootGone) { - throw new Error('provider child exit was not proven') - } - context.onProviderChildStopped?.({ rootGone }) - } - }, - { - name: 'drain-published', - run: async (context) => { - const barrier = await context.eventSink.drained() - if (!barrier.ok) { - throw barrier.error - } - } - }, - { - name: 'settle-dead-generation', - run: (context) => - context.owesProviderChildWindDown === false ? undefined : context.settleWork?.() - }, { name: 'stop-publishing', run: (context) => context.eventSink.unbind() }, { name: 'close-sink', run: (context) => context.eventSink.close() }, // Why: the runtime caches one sink per session id and hands the SAME instance to the next @@ -118,11 +59,6 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // accepts every provider event and publishes none. Attach's own failure path already pairs // these two; eviction has to as well. { name: 'discard-sink', run: (context) => context.discardSink() }, - // Why here and not last: the durable lease still names a process this host just stopped, and a - // record left claiming a live owner is one nothing can resume — the next send would find a - // session it may not acquire. Placed BEFORE the acknowledgement so a release that cannot be - // written aborts while the adapter still routes the session, which is what makes the retry real. - { name: 'release-lease', run: (context) => context.releaseLease() }, { name: 'acknowledge-release', run: (context) => context.acknowledgeRelease() } ] @@ -137,11 +73,8 @@ export class StructuredAgentSessionEvictionError extends Error { } } -/** - * Runs the eviction steps in order, stopping at the first failure. The step name travels with the - * error because the caller's only useful response is to retry, and a retry is only safe when the - * session is still indexed — which is exactly what aborting preserves. - */ +/** Runs every wind-down step in order. A failure is reported with the step that failed, and the + * rest still run. */ export async function evictStructuredAgentSession( context: StructuredAgentSessionEvictionContext, steps: readonly StructuredAgentSessionEvictionStep[] = STRUCTURED_AGENT_SESSION_EVICTION_STEPS @@ -150,7 +83,11 @@ export async function evictStructuredAgentSession( try { await step.run(context) } catch (error) { - throw new StructuredAgentSessionEvictionError(step.name, context.sessionId, error) + context.logger.warn('a wind-down step after the agent exited failed', { + scope: 'exit-wind-down', + sessionId: context.sessionId, + error: new StructuredAgentSessionEvictionError(step.name, context.sessionId, error) + }) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 2c312ef17e8..5e8d7e2097f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -13,26 +13,18 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { - evictStructuredAgentSession, - STRUCTURED_AGENT_SESSION_EVICTION_STEPS, - type StructuredAgentSessionEvictionContext + snapshotBeforeStructuredAgentSessionStop, + StructuredAgentSessionEvictionError } from './structured-agent-session-eviction' -import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' +import { joinStructuredAgentSessionChildClose } from './structured-agent-session-child-close' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, - StructuredAgentSessionOwedWindDown, - StructuredAgentSessionProviderChildIdentity + StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' -import { - endProviderChild, - pendingProviderChildWindDown, - sameProviderChild, - structuredAgentSessionConversationFence -} from './structured-agent-session-provider-child' -import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' -import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -50,6 +42,13 @@ export type StructuredAgentSessionLifetimeContext = { publishStatus?: (sessionId: string) => void /** Hands the delivery loop what is queued; for a caller inside the session's serialize. */ wakeDelivery?: (sessionId: string) => void + /** The one exit handler (`structured-agent-session-child-exit`), for a caller inside the + * session's serialize that proved its child's exit. */ + endExitedChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChild, + exit: StructuredAgentSessionChildExit + ) => Promise /** Quit-only snapshot taken immediately before the provider child is stopped. */ restartWitness?: { beforeStop: (sessionId: string) => void @@ -90,43 +89,12 @@ export async function abandonQueuedStructuredAgentSessionMessages( ) } -/** The wind-down this host owes for the session's child. A live child always owes one, whatever a - * previous childless eviction recorded: a remembered tombstone must never outrank the child in - * front of it. */ -function owedProviderChildWindDown( - session: StructuredAgentSessionHostSession -): StructuredAgentSessionProviderChildIdentity | undefined { - return session.child - ? { generation: session.child.generation, fence: session.child.fence } - : session.owesProviderChildWindDown -} - -/** The stop this pass owes. A retry continues the one already asked for, keeping where it was asked; - * any other stop is a new ask, even one with the same cause: a second close closes what came since. */ -function owedStop( - session: StructuredAgentSessionHostSession, - cause: StructuredAgentSessionStopCause, - retry: boolean -): StructuredAgentSessionOwedWindDown | undefined { - const owed = owedProviderChildWindDown(session) - if (!owed) { - return undefined - } - const asked = session.owesProviderChildWindDown - const continues = retry && asked !== undefined && sameProviderChild(asked, owed) - return { - generation: owed.generation, - fence: owed.fence, - cause, - requestedAt: continues ? asked.requestedAt : session.journal.cursor() - } -} - /** - * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step - * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next - * stop is a real retry. `ending` is how the child's end is told: a user's Stop, the host stopping it - * for a cause (with its text), or an eviction the conversation's close follows. + * The agent goes to rest; the conversation stays. Begins the child's close, or joins the one + * already begun, and waits for the exit's proof as long as a caller may. Still unproven, it throws + * and the close keeps running: a later proof, or the next asker's attempt, ends the record. + * `ending` is how the child's end is told: a user's Stop, the host stopping it for a cause (with + * its text), or an eviction the conversation's close follows. The first stop's ending decides. */ export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, @@ -134,159 +102,69 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ending: StructuredAgentSessionStopEnding ): Promise { const session = context.sessions.get(sessionId) - if (!session) { + const child = session?.child + if (!session || !child) { return } - // Judged before the kill: a stop that ends nothing writes nothing. - const recorded = (await stopEndsWork(context, sessionId, session, ending)) - ? recordStopEvent(context, sessionId, session, ending) - : Promise.resolve(null) - // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, - // so a step that aborts after that point would otherwise leave the retry reading "no child - // here" and skipping the settlement and the lease release it still owes. - const asked = 'recorded' in ending ? ending.recorded : ending.cause - // A retry finishes the stop that ended the child, so the child's end keeps that stop's cause. - const cause = session.child ? asked : (session.owesProviderChildWindDown?.cause ?? asked) - const owed = owedStop(session, cause, ending.retry === true) - session.owesProviderChildWindDown = owed - const stopping = session.child - const eviction: StructuredAgentSessionEvictionContext = { - sessionId, - // The retry must not re-stop a child the adapter already proved gone, so this stays honest. - hasProviderChild: stopping !== null, - owesProviderChildWindDown: owed !== undefined, - eventSink: context.runtimeState.eventSinkFor(sessionId), - adapter: context.deps.adapter, - logger: context.deps.logger, - ...(context.restartWitness - ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } - : {}), - // Host state must not disagree with the adapter for the steps in between. - onProviderChildStopped: (verdict) => { - if (stopping) { - endProviderChild(session, { - generation: stopping.generation, - fence: stopping.fence, - cause, - reason: ('reason' in ending ? ending.reason : undefined) ?? null, - duringStartup: stopping.phase === 'starting', - // A later retry that proves the exit still ends the child at the Stop it finishes. - ...(owed ? { endedAt: owed.requestedAt } : {}), - ...verdict - }) - } - context.restartWitness?.stopped(sessionId) - }, - acknowledgeRelease: () => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), - discardSink: () => context.runtimeState.discardEventSink(sessionId), - settleWork: async () => { - // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). - await recorded - const fence = - owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) - const settled = await settleStructuredAgentSessionDeadGeneration({ - journal: session.journal, - sessionId, - fence, - settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, - pendingSubmissionReason: 'provider_closed_before_acknowledgement', - // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was - // a person's Stop is its event's to say (`turnEndAfterStop`). - verdict: { state: 'interrupted', completedAt: context.now() }, - showUnexpectedExitOutcome: false - }) - if (!settled.ok) { - context.deps.logger.warn("settling a closed agent's work failed", { - scope: 'close-settlement', - sessionId, - error: settled.error - }) - // Without the cause the log names the step and nothing else. - throw new Error('dead generation work settlement failed', { cause: settled.error }) - } - }, - releaseLease: async () => { - if (owed) { - await releaseStoredStructuredAgentSessionOwner({ - store: context.deps.store, - sessionId, - hasProviderChild: true, - expectedFence: owed.fence, - now: context.now() - }) - } - session.owesProviderChildWindDown = undefined - // Whatever ended the child, the row belongs to the conversation: it shows not-running, and - // only the conversation's close forgets it. - context.publishStatus?.(sessionId) - // The stop's own end hands over what waited on it, whichever caller's retry landed. - context.wakeDelivery?.(sessionId) + const cause = 'recorded' in ending ? ending.recorded : ending.cause + if (!child.close) { + // Judged before the kill: a stop that ends nothing writes nothing. Its event is issued before + // the kill and never awaited by it; the journal writes rows in order. + const recorded = (await stopEndsWork(context, sessionId, session, ending)) + ? recordStopEvent(context, sessionId, session, ending) + : Promise.resolve(null) + child.close = { + cause, + reason: ('reason' in ending ? ending.reason : undefined) ?? null, + recorded, + requestedAt: session.journal.cursor() } - } - try { - await evictStructuredAgentSession( - eviction, - withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) + } else if (child.close.cause === cause) { + // The same stop asked again, such as a second close of the chat, closes what came since, and + // binds again what its child's end cuts. + child.close.requestedAt = session.journal.cursor() + child.close.recorded = child.close.recorded.then((settle) => + settle ? session.journal.stopMarks.beginSettle() : null ) - } catch (error) { - if (session.owesProviderChildWindDown === owed && owed) { - session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } + } + const { close } = child + try { + if (context.restartWitness) { + await snapshotBeforeStructuredAgentSessionStop( + { + sessionId, + eventSink: context.runtimeState.eventSinkFor(sessionId), + logger: context.deps.logger + }, + () => context.restartWitness?.beforeStop(sessionId) + ) + } + if ((await joinStructuredAgentSessionChildClose(context, sessionId, child)) !== 'exited') { + throw new StructuredAgentSessionEvictionError( + 'stop-provider-child', + sessionId, + new Error('provider child exit was not proven') + ) } - throw error } finally { // A person's close binds what its child's end cut; done, proven or not, it binds no more. - void recorded.then((settle) => session.journal.stopMarks.settled(settle)) + void close?.recorded.then((settle) => session.journal.stopMarks.settled(settle)) } } -/** - * Retries the wind-down an earlier stop left owed, with that stop's own cause: a child it could - * not prove gone takes no input, so nothing may write to it or start beside it until this lands. - * One pass of the stop, bounded by its own step deadline (10 s): a shorter bound would cut a - * supervised Claude's exit proof (up to about 7 s) short. Resolves whether nothing is owed now; a - * failure is reported, never thrown, and leaves the exit unverifiable, never exited. Landing, the - * stop itself hands over what waited on it. - */ -export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): Promise { - const session = context.sessions.get(sessionId) - const owed = session && pendingProviderChildWindDown(session) - if (!owed) { - return true - } - try { - await stopStructuredAgentSessionAgentUnderSerialize( - context, - sessionId, - owed.cause === 'user-stop' - ? { recorded: 'user-stop', retry: true } - : { cause: owed.cause, retry: true } - ) - } catch (error) { - context.deps.logger.warn('retrying an unfinished agent stop failed', { - scope: 'owed-stop-retry', - sessionId, - error - }) - } - return context.sessions.get(sessionId)?.owesProviderChildWindDown === undefined -} - /** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */ export type StructuredAgentSessionCloseCause = Extract< StructuredAgentSessionStopCause, 'user-close' | 'evict' > -/** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing - * queued or waiting on the provider. */ +/** Whether the conversation's handle is only a cache now: no child, and nothing queued or waiting + * on the provider. */ export function structuredAgentSessionConversationClosable( session: StructuredAgentSessionHostSession ): boolean { return ( - owedProviderChildWindDown(session) === undefined && + session.child === null && !session.journal.submissions().some(isQueuedAgentJournalSubmission) && session.journal.pendingSubmissions().length === 0 ) @@ -315,9 +193,7 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( return true } -/** Stops every provider child owned by this host while keeping failed evictions reachable. A - * session whose child is already stopped but whose wind-down aborted is still in scope — that is - * the retry. */ +/** Stops every provider child owned by this host while keeping failed evictions reachable. */ export async function evictOwnedStructuredAgentSessions( context: StructuredAgentSessionLifetimeContext & { serialize: (sessionId: string, task: () => Promise) => Promise @@ -325,7 +201,7 @@ export async function evictOwnedStructuredAgentSessions( retainOnFailure: Set ): Promise { const ownedSessionIds = [...context.sessions] - .filter(([, session]) => owedProviderChildWindDown(session) !== undefined) + .filter(([, session]) => session.child !== null) .map(([sessionId]) => sessionId) // Retained up front and cleared only once a stop settles: the quit phase is bounded, and a // timeout leaves these still running. Closing their journals underneath them is the one outcome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 70cfd07bb7a..1c9e2720aca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -13,7 +13,7 @@ import { sentSinceStop } from './structured-agent-session-queued-stop' /** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its * own step (`recorded` names its reason); any other stop names the reason its event records, with * the host's text for it. Quit writes none: its resume marker's trigger records why. */ -export type StructuredAgentSessionStopEnding = ( +export type StructuredAgentSessionStopEnding = | { recorded: 'user-stop' } | { cause: Exclude @@ -23,10 +23,6 @@ export type StructuredAgentSessionStopEnding = ( * no work its event records. */ resting?: true } -) & { - /** The retry of a stop already owed, set only by that retry: its event, if any, is written. */ - retry?: true -} /** How long a host stop waits for the session's sink before it judges whether the stop ends work. */ const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 @@ -34,8 +30,8 @@ const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** * Whether this stop ends work its event must record: a running turn or an unanswered send, a start's * own included, read once the sink drained what the provider already said. A start that carries - * no send ends nothing. A person's Stop wrote its own event, and quit, the idle sweep's rest and a - * retry of a stop already owed write none. + * no send ends nothing. A person's Stop wrote its own event, and quit and the idle sweep's rest + * write none; a stop that joins a close already begun writes none either. */ export async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -44,7 +40,7 @@ export async function stopEndsWork( ending: StructuredAgentSessionStopEnding ): Promise { const { child, journal } = session - if ('recorded' in ending || ending.quit || ending.resting || ending.retry || !child) { + if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } // A failed drain has nothing more to deliver, so the journal's read as it stands holds. One diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index ae75f476591..7016e91e17c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -11,6 +11,7 @@ import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-re import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' import type { StructuredAgentSessionAdapter, StructuredAgentSessionChildEndCause, @@ -43,13 +44,18 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } -/** A wind-down still owed, with the stop that owes it: a retry finishes that stop. */ -export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { +/** The close a stop began for its child. It lives on the child and ends with it: once begun, the + * child takes no input again, and every later stop, start or provider write joins it. */ +export type StructuredAgentSessionChildClose = { + /** The first stop's, which the child's end keeps however many asks join it. */ readonly cause: StructuredAgentSessionStopCause - /** Where the journal stood when the stop was asked for; the child's end is ordered there. */ - readonly requestedAt: AgentJournalCursor - /** Where it stood once the newest pass failed: a message accepted by then waited through a retry. */ - readonly failedAt?: AgentJournalCursor + readonly reason: string | null + /** The Stop event that stop wrote, folded before the work it ends is settled, with the settle a + * person's close that named no turn opens; a repeated ask reopens it. */ + recorded: Promise + /** Where the journal stood when that stop was asked for: the child's end is ordered there, so a + * message accepted while the exit was being proven came after it. A repeated ask moves it. */ + requestedAt: AgentJournalCursor } /** The provider process behind a conversation. Written only in @@ -61,6 +67,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** The queued message whose delivery started this child, fixed when the start is made; absent * for any other start. In memory only: it tells a restart offer its own start from another. */ readonly startedFor?: string + close?: StructuredAgentSessionChildClose } /** What ending a child established about its provider root. A stop's comes only from @@ -83,8 +90,7 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi duringStartup: boolean startedFor?: string /** Where the conversation's journal stood when the child ended, to order the end against a - * message's acceptance. A stop's end stands where it was asked for: a message accepted while - * retries proved the exit waited on it, and came after it. */ + * message's acceptance. A close's end stands where its stop was asked for. */ endedAt: AgentJournalCursor } @@ -97,10 +103,6 @@ export type StructuredAgentSessionHostSession = { * has none — so it may not be evicted to free a child, nor have its lease released as an * observed exit. */ child: StructuredAgentSessionProviderChild | null - /** The wind-down this host still owes for a child it started: settling that generation's work - * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the - * exit — an eviction that aborts after that point must still finish it on the next close. */ - owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown lastEndedChild?: StructuredAgentSessionEndedChild } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 8256f90e53c..59599c5adfe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -151,7 +151,9 @@ export class StructuredAgentSessionHost { ), publishStatus: this.clientDelivery.publishStatusAndSettlement, serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), - now: () => this.now() + now: () => this.now(), + runtimeState: this.runtimeState, + wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -186,7 +188,8 @@ export class StructuredAgentSessionHost { sessions: this.sessions, now: () => this.now(), publishStatus: this.clientDelivery.publishStatus, - wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId) + wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId), + endExitedChild: this.eventRecovery.endExitedChildUnderSerialize } satisfies StructuredAgentSessionLifetimeContext } @@ -249,8 +252,11 @@ export class StructuredAgentSessionHost { // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. + /** Quit: no exit or recovery settled after this starts a child or hands a message over. */ + stopDelivery = (): void => this.conversationDelivery.dispose() + async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { - this.conversationDelivery.dispose() + this.stopDelivery() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), idleSweep: this.lifetime, @@ -272,11 +278,8 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), - finishOwedStop: (sessionId) => - agentStart.finishOwedStructuredAgentSessionStopForProviderWrite( - this.attachContext(), - sessionId - ), + joinChildClose: (sessionId) => + agentStart.joinClosingStructuredAgentSessionChild(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 7823f65b535..9cb6dbab4ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -319,7 +319,6 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, - finishOwedWindDown: vi.fn(async () => true), closeConversation, // A failed step fails the test. logger: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 96a5f59cf84..1b67f259ed9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -15,7 +15,6 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import { pendingProviderChildWindDown } from './structured-agent-session-provider-child' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 @@ -37,8 +36,6 @@ export type StructuredAgentSessionIdleSweepDeps = { providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise - /** Retries a stop that did not finish; landing, it hands over what waited on it. */ - finishOwedWindDown: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise closeConversation: (sessionId: string) => Promise logger: StructuredAgentSessionLogger @@ -111,12 +108,6 @@ export class StructuredAgentSessionIdleSweep { if (!session || this.deps.isDisposed()) { return } - // A stop that did not finish: retry it now, before the idle test, so the rows its settlement - // wrote cannot push the retry out. A running delivery step retries it itself. - if (pendingProviderChildWindDown(session) && !this.deps.deliveryActive(sessionId)) { - await this.deps.finishOwedWindDown(sessionId) - return - } // Owed work is activity, read every tick, so the agent gets a full window once it ends: a child // can read done before the lead's wake-up turn writes anything. if (session.child && session.child.phase !== 'starting' && this.owesWork(sessionId, session)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts index fe584a50fce..63e3add288e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts @@ -1,9 +1,9 @@ -// Handing the durable lease back after eviction stopped this host's child. +// Handing the durable lease back once this host's own provider child is gone. // -// Guarded on `hasProviderChild` for a reason that is not bookkeeping: a session restored only for -// reading, or one a TUI owns, names an owner process this host never started and may still be -// alive. Writing `exit-observed` against that record would release a lease out from under a running -// process and let a second writer in. +// Only an exit this host saw or proved, of the child it started at this fence, may release: a +// session restored only for reading, or one a TUI owns, names an owner process this host never +// started and may still be alive. Writing `exit-observed` against that record would release a lease +// out from under a running process and let a second writer in. import { isSurfaceReleasableAgentSessionRecord, @@ -17,45 +17,16 @@ export type StructuredAgentSessionLeaseStore = Pick< 'getRecord' | 'transitionHandoff' > -export async function releaseStoredStructuredAgentSessionOwner(input: { - store: StructuredAgentSessionLeaseStore - sessionId: string - hasProviderChild: boolean - expectedFence: number - now: number -}): Promise { - if (!input.hasProviderChild) { - return - } - const record = input.store.getRecord(input.sessionId) - if ( - !record || - record.lease.runtimeFence !== input.expectedFence || - !isSurfaceReleasableAgentSessionRecord(record) - ) { - return - } - await releaseStoredAgentSessionOwnerAfterSurfaceClose(input.store, { - sessionId: input.sessionId, - expectedFence: input.expectedFence, - now: input.now - }) -} - -/** Releases only the exact provider child whose exit the adapter positively observed. */ -export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit(input: { +/** Releases the lease of the child whose exit this host observed, with that exit's evidence. + * Throws `agent_session_checkpoint_stale` when the record no longer names that child. */ +export async function releaseStoredStructuredAgentSessionOwnerAfterExit(input: { store: StructuredAgentSessionLeaseStore sessionId: string expectedFence: number - expectedAcquisitionGeneration: string - acquisitionGeneration: string | null now: number exitObservedAt?: number exitReason?: string }): Promise { - if (input.acquisitionGeneration !== input.expectedAcquisitionGeneration) { - throw new Error('agent_session_checkpoint_stale') - } const record = input.store.getRecord(input.sessionId) if ( !record || @@ -68,7 +39,7 @@ export async function releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExi sessionId: input.sessionId, expectedFence: input.expectedFence, now: input.now, - exitObservedAt: input.exitObservedAt, + ...(input.exitObservedAt === undefined ? {} : { exitObservedAt: input.exitObservedAt }), ...(input.exitReason ? { exitReason: input.exitReason } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index e77975dc5c2..216a952f830 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -32,9 +32,9 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise - /** Finishes a stop an earlier attempt left owed, for an operation that starts no child; inside - * the caller's serialize. */ - finishOwedStop: (sessionId: string) => Promise + /** Joins a close a stop began on the session's child, for an operation that starts no child; + * inside the caller's serialize. */ + joinChildClose: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 5785cfc53e1..d4236f395da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -11,7 +11,6 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, - StructuredAgentSessionOwedWindDown, StructuredAgentSessionProviderChild, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' @@ -48,7 +47,7 @@ export function markProviderChildStarted( return child !== null } -/** `endedAt` is a stop's ask; an exit ends where the journal stands. */ +/** `endedAt` is a close's ask; an exit of the child's own ends where the journal stands. */ export function endProviderChild( session: ChildBearer, ended: Omit & { @@ -77,15 +76,6 @@ export function failedProviderChildStart( return !session.child && ended?.duringStartup && ended.cause !== 'user-stop' ? ended : null } -/** The owed wind-down an operation reaching the provider finishes first. One owed for another child - * never outranks the child in front of it, which that child's own stop finishes. */ -export function pendingProviderChildWindDown( - session: Pick -): StructuredAgentSessionOwedWindDown | undefined { - const { child, owesProviderChildWindDown: owed } = session - return owed && (!child || sameProviderChild(child, owed)) ? owed : undefined -} - export function sameProviderChild( a: StructuredAgentSessionProviderChildIdentity, b: StructuredAgentSessionProviderChildIdentity diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts index 9d27e570db4..1545a8fa618 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-exit-proof.ts @@ -50,16 +50,24 @@ function provenExitAcquisitionFailure(cause: unknown): unknown { } /** Whether a stop left the provider root gone. The lease follows the root, so a first-hand root - * exit or a processless child ends the session whatever its descendants did; any other - * failure still throws. */ -export async function stopAgentSessionProviderRoot(stop: () => Promise): Promise { + * exit or a processless child ends the session whatever its descendants did. A descendant left + * unconfirmed, or bookkeeping that failed after the proven exit, is only `report`ed. Any other + * failure throws. */ +export async function stopAgentSessionProviderRoot( + stop: () => Promise, + report?: (error: Error) => void +): Promise { try { return (await stop()) === true } catch (error) { if ( error instanceof AgentSessionAcquisitionRootExitObservedError || - isAgentSessionPreSpawnError(error) + error instanceof AgentSessionAcquisitionExitProvenError ) { + report?.(error) + return true + } + if (isAgentSessionPreSpawnError(error)) { return true } throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index e74947054e4..62ac5fc644e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -100,11 +100,12 @@ export function openForWrite( } /** For an operation the running child performs, which starts none: the conversation, then any - * stop an earlier attempt left owed, so it never reaches a child that takes no input. */ + * close a stop began on that child, which it joins, so it never reaches a child that takes no + * input. */ export function openForProviderWrite( context: Pick< StructuredAgentSessionMutationContext, - 'openConversation' | 'finishOwedStop' | 'deps' + 'openConversation' | 'joinChildClose' | 'deps' >, envelope: AgentSessionMutationEnvelope ): () => Promise { @@ -114,7 +115,7 @@ export function openForProviderWrite( envelope, context.deps.logger ) - return opened.ok ? context.finishOwedStop(envelope.sessionId) : opened + return opened.ok ? context.joinChildClose(envelope.sessionId) : opened } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index 8235d0d6705..07f54bc2ab4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -6,10 +6,10 @@ import { } from '../../../shared/agent-session-record.test-fixture' import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { - settleUnexpectedStructuredAgentSessionExit, - type StructuredAgentSessionUnexpectedExitContext, - type StructuredAgentSessionUnexpectedExitSession -} from './structured-agent-session-unexpected-exit' + settleStructuredAgentSessionChildExit, + type StructuredAgentSessionChildExitContext, + type StructuredAgentSessionChildExitSession +} from './structured-agent-session-child-exit' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const SESSION = 'session-1' @@ -17,7 +17,7 @@ const GENERATION = 'generation-1' const REASON = 'claude stream-json exited (code 1): session limit reached' const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' -function startedSession(): StructuredAgentSessionUnexpectedExitSession & { +function startedSession(): StructuredAgentSessionChildExitSession & { journal: { appendLifecycleBatch: ReturnType } } { return { @@ -34,7 +34,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { } } -function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { +function contextFor(session: StructuredAgentSessionChildExitSession) { let record: AgentSessionRecord = agentSessionRecordFixture( agentSessionLeaseFixture({ sessionId: SESSION, @@ -47,7 +47,7 @@ function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { unreconciled: false }) ) - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { logger: recordingStructuredAgentSessionLogger().logger, store: { getRecord: () => record, @@ -78,7 +78,7 @@ describe('a provider that ends before it finished starting', () => { it('tells the user why, even with no response in progress', async () => { const session = startedSession() - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + await settleStructuredAgentSessionChildExit(contextFor(session), { ...ended, // The adapter typed the start's own failure; the host keeps it rather than reword it. failure: { kind: 'notSignedIn' }, @@ -106,7 +106,7 @@ describe('a provider that ends before it finished starting', () => { it('keeps an ordinary idle exit silent', async () => { const session = startedSession() - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleStructuredAgentSessionChildExit(contextFor(session), ended) expect(session.child).toBeNull() expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() @@ -118,7 +118,7 @@ describe('a provider that ends before it finished starting', () => { child: { generation: GENERATION, fence: 7, phase: 'starting' as const } } - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + await settleStructuredAgentSessionChildExit(contextFor(session), { ...ended, failure: { kind: 'providerExited', detail: { text: REASON, audience: 'log' } } }) @@ -157,7 +157,7 @@ describe('a provider that ends before it finished starting', () => { } } - await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleStructuredAgentSessionChildExit(contextFor(session), ended) const text = 'Claude stopped before it finished starting. Run /compact again.' expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index a91ed7c8cbd..65da28ca6b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -131,6 +131,9 @@ async function mailTurn(): Promise { async function evictedAt(): Promise { let atClose: JournalStopEvent[] = [] rig.closeSession.mockImplementationOnce(async () => { + // The event is issued before the kill, never awaited by it: the journal writes it ahead of + // anything the kill makes the child write. + await new Promise((resolve) => setImmediate(resolve)) atClose = stopEvents() return true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index dd0acf82b37..1d7781c9886 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -45,6 +45,9 @@ function stopEvents(): JournalStopEvent[] { function stopEventsAtClose(): { events: JournalStopEvent[] | null } { const seen: { events: JournalStopEvent[] | null } = { events: null } rig.closeSession.mockImplementationOnce(async () => { + // The event is issued before the kill, never awaited by it: the journal writes it ahead of + // anything the kill makes the child write. + await new Promise((resolve) => setImmediate(resolve)) seen.events = stopEvents() return true }) @@ -286,9 +289,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) }) - // The idle sweep finishes a stop whose exit was unproven: the same stop, so its event stands alone. + // A later stop joins a close whose exit was unproven: the same close, so its event stands alone. it.each([['user-close' as const], ['evict' as const]])( - 'writes one event for a close (%s) whose exit was unproven, and none for its retry', + 'writes one event for a close (%s) whose exit was unproven, and none for a stop that joins it', async (cause) => { rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) await runningTurn() @@ -296,13 +299,14 @@ describe('every Stop entry writes its event, with its reason, before it ends the const session = () => rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! await rig.host.close(HOST_TEST_SESSION, cause).catch(() => undefined) - expect(session().child).not.toBeNull() - expect(session().owesProviderChildWindDown).toMatchObject({ cause }) + expect(session().child?.close).toMatchObject({ cause }) expect(stopEvents()).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) - await idleSweep().tick() + await rig.host['tasks'].serialize(HOST_TEST_SESSION, () => + rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'host-stop' }) + ) - expect(session().owesProviderChildWindDown).toBeUndefined() + expect(session().child).toBeNull() expect(stopEvents().map((event) => event.reason)).toEqual([cause]) expect(session().lastEndedChild?.cause).toBe(cause) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index 14e62854bbc..a3fee6b2ed4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -194,4 +194,22 @@ describe("a person's close pressed before its send's turn showed", () => { expect(later).toMatchObject({ state: 'interrupted' }) expect(later).not.toHaveProperty('outcome') }) + + // The first close's kill is unproven and its settle closes with it; asking again re-kills, so a + // turn that ask's end cuts is the person's again. + it('binds the turn a repeated close cuts after a first close that came back unproven', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + const close = () => rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + rig.closeSession.mockResolvedValueOnce(false) + await expect(close()).rejects.toThrow() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + await close() + + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 5a1ad69f613..c681369ce27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -337,7 +337,7 @@ describe('a chat that closes', () => { expect(closeSession).toHaveBeenCalledOnce() }) - it('settles and releases on the retry when a step after the child stopped aborts', async () => { + it('reports a failed drain after the child stopped, and still settles and releases', async () => { await attach() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('pending across an aborted eviction') @@ -351,12 +351,9 @@ describe('a chat that closes', () => { failEvictionDrain() const settled = captureSettledSubmissions() - await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) - // The child is proven gone, but the wind-down it owes is not done: nothing settled, no release. - expect(session!.child).toBeNull() - expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') - + // The child is proven gone: the failed drain is bookkeeping, reported, and the rest still runs. await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() + expect(session!.child).toBeNull() expect(closeSession).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -675,7 +672,7 @@ describe('an unexpected provider exit', () => { unsubscribe() }) - it('keeps a requested close out of recovery', async () => { + it('ends the record of a requested close as a close, never as a crash to recover from', async () => { await attach() const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -689,7 +686,9 @@ describe('an unexpected provider exit', () => { }) expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + expect(host['sessions'].get(SESSION)?.child).toBeNull() + expect(host['sessions'].get(SESSION)?.lastEndedChild).not.toHaveProperty('failure') + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { @@ -842,19 +841,16 @@ describe('an unexpected provider exit', () => { }) }) -describe('a quit over an eviction that never got its retry', () => { - // Nothing calls `close` a second time when the user quits instead of reopening the chat, so the - // quit sweep is the last thing that can hand the lease back — and it only reaches the session if - // it still counts a stopped child's unfinished wind-down as owed. - it('finishes the wind-down the aborted close left behind', async () => { +describe('a quit after a close whose drain failed', () => { + // The close's own wind-down hands the lease back past a failed drain, so quit finds nothing owed. + it('has nothing of that child left to finish', async () => { await attach() - await sendPending('pending across an abandoned eviction') + await sendPending('pending across a failed drain') const settled = captureSettledSubmissions() failEvictionDrain() - await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() + expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 6337d65f553..3a0e43d2328 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -101,9 +101,9 @@ export type StructuredAgentSessionStopWindDown = { /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent - * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. The next - * operation that reaches the agent retries the wind-down it leaves owed, and so does the idle - * sweep's next tick. + * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. A close + * it could not prove keeps the child on record, and the next operation that reaches the agent + * joins that close. */ export async function endStoppedStructuredAgentSession( ctx: Pick, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 4f4e414657d..e5222eae1b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -9,10 +9,10 @@ import { import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { - settleUnexpectedStructuredAgentSessionExit, - type StructuredAgentSessionUnexpectedExitContext, - type StructuredAgentSessionUnexpectedExitSession -} from './structured-agent-session-unexpected-exit' + settleStructuredAgentSessionChildExit, + type StructuredAgentSessionChildExitContext, + type StructuredAgentSessionChildExitSession +} from './structured-agent-session-child-exit' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' const exitOutcome = (agent: string): string => @@ -105,7 +105,7 @@ describe('provider-exit settlement', () => { } } as unknown as StructuredAgentSessionHostSession - await settleUnexpectedStructuredAgentSessionExit( + await settleStructuredAgentSessionChildExit( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit settlement reads only these members of its context; the store double is partial. { logger: recordingStructuredAgentSessionLogger().logger, @@ -190,7 +190,7 @@ describe('provider-exit settlement', () => { transitionHandoff: async () => ({ lease: { runtimeFence: 8 } }) } - await settleUnexpectedStructuredAgentSessionExit( + await settleStructuredAgentSessionChildExit( { store, sessions: new Map([[SESSION, session]]), @@ -285,7 +285,7 @@ describe('provider-exit settlement', () => { epoch: 'epoch-1', sequence: 3 })) - const session: StructuredAgentSessionUnexpectedExitSession = { + const session: StructuredAgentSessionChildExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), @@ -298,7 +298,7 @@ describe('provider-exit settlement', () => { } const { store } = mutableStore() - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), @@ -316,7 +316,7 @@ describe('provider-exit settlement', () => { serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1_234 } - await settleUnexpectedStructuredAgentSessionExit(context, { + await settleStructuredAgentSessionChildExit(context, { type: 'ended', sessionId: SESSION, reason: 'provider exited after completing the turn', @@ -344,7 +344,7 @@ describe('provider-exit settlement', () => { it('settles a submission the dead child never acknowledged', async () => { const markPendingSubmissionsUnknown = vi.fn(async () => ['client-1']) - const session: StructuredAgentSessionUnexpectedExitSession = { + const session: StructuredAgentSessionChildExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), @@ -358,7 +358,7 @@ describe('provider-exit settlement', () => { } const { store } = mutableStore() - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { logger: recordingStructuredAgentSessionLogger().logger, store, sessions: new Map([[SESSION, session]]), @@ -367,7 +367,7 @@ describe('provider-exit settlement', () => { serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } - await settleUnexpectedStructuredAgentSessionExit(context, { + await settleStructuredAgentSessionChildExit(context, { type: 'ended', sessionId: SESSION, reason: 'provider exited', @@ -397,7 +397,7 @@ describe('provider-exit settlement', () => { }) it('releases without offering a restart while terminal settlement is failing', async () => { - const session: StructuredAgentSessionUnexpectedExitSession = { + const session: StructuredAgentSessionChildExitSession = { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), @@ -423,7 +423,7 @@ describe('provider-exit settlement', () => { acquisitionGeneration: GENERATION } const { store } = mutableStore() - const context: StructuredAgentSessionUnexpectedExitContext = { + const context: StructuredAgentSessionChildExitContext = { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: false, error: new Error('sink failed') }), @@ -432,7 +432,7 @@ describe('provider-exit settlement', () => { now: () => 1, logger: log.logger } - await settleUnexpectedStructuredAgentSessionExit(context, event) + await settleStructuredAgentSessionChildExit(context, event) expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts deleted file mode 100644 index 5a0b2f6de30..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { endProviderChild } from './structured-agent-session-provider-child' -import { - releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit, - type StructuredAgentSessionLeaseStore -} from './structured-agent-session-lease-release' -import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { - captureUnfinishedStructuredAgentSessionWork, - MAX_UNEXPECTED_EXIT_REASON_CHARS, - settleStructuredAgentSessionDeadGeneration, - type DeadGenerationJournal, - unfinishedStructuredAgentSessionWorkWasInterrupted -} from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' -import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' - -type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { - cause: 'unexpected-exit' -} - -export type StructuredAgentSessionUnexpectedExitSession = Pick< - StructuredAgentSessionHostSession, - 'child' | 'lastEndedChild' -> & { journal: DeadGenerationJournal & Pick } - -export type StructuredAgentSessionUnexpectedExitContext< - TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession -> = { - store: StructuredAgentSessionLeaseStore - sessions: Map - flushLifecycle: (sessionId: string) => Promise - publishFence: (sessionId: string, session: TSession) => void - publishStatus?: (sessionId: string) => void - serialize: (sessionId: string, task: () => Promise) => Promise - now: () => number - logger: StructuredAgentSessionLogger -} - -export async function settleUnexpectedStructuredAgentSessionExit< - TSession extends StructuredAgentSessionUnexpectedExitSession ->( - context: StructuredAgentSessionUnexpectedExitContext, - event: StructuredAgentSessionEndedEvent -): Promise { - if (event.cause !== 'unexpected-exit') { - return - } - const unexpectedEvent = event as UnexpectedExitLifecycleEvent - // Receipt of the exit is the one end time the host may record for a running turn. - const observedAt = event.observedAt ?? context.now() - return context.serialize(unexpectedEvent.sessionId, async () => { - const session = context.sessions.get(unexpectedEvent.sessionId) - const child = session?.child - if ( - !session || - !child || - child.fence !== unexpectedEvent.fence || - child.generation !== unexpectedEvent.acquisitionGeneration - ) { - return - } - // The host's own phase decides, so a provider that omits the flag still gets a start that - // failed told as one: the row says so. - const exitedDuringStartup = - unexpectedEvent.startupUnproven === true || child.phase === 'starting' - const endChild = (): void => { - endProviderChild(session, { - generation: child.generation, - fence: child.fence, - cause: 'exit', - reason: unexpectedEvent.reason, - ...(unexpectedEvent.failure ? { failure: unexpectedEvent.failure } : {}), - duringStartup: exitedDuringStartup, - // The adapter publishes an exit only once it saw the root go, first-hand or proven. - rootGone: true - }) - context.publishStatus?.(unexpectedEvent.sessionId) - } - const record = context.store.getRecord(unexpectedEvent.sessionId) - if (!record || record.lease.handoffStage !== null) { - // An acquisition or recovery already owns this lease's transition. - endChild() - return - } - - const stableSettlementId = providerExitSettlementId(unexpectedEvent) - try { - // The exited child's own writes land first: its dead generation is settled from all of them. - try { - const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) - if (!barrier.ok) { - logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', barrier.error) - } - } catch (error) { - logExitFailure(context, unexpectedEvent, 'exit-lifecycle-barrier', error) - } - const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) - await retryUnexpectedExitSettlement({ - context, - event: unexpectedEvent, - journal: session.journal, - fence: child.fence, - stableSettlementId, - verdict: { state: 'interrupted', completedAt: observedAt }, - exitedDuringStartup, - failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), - // A failed start always says why: no response was running to carry the reason. - showUnexpectedExitOutcome: - exitedDuringStartup || - unfinishedStructuredAgentSessionWorkWasInterrupted( - unfinishedWork, - session.journal, - observedAt - ) - }) - } finally { - // Provider exit was positively observed, so release the owner even when - // terminal settlement could not be durably accepted. - let released: Awaited< - ReturnType - > | null = null - try { - released = await releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit({ - store: context.store, - sessionId: unexpectedEvent.sessionId, - expectedFence: unexpectedEvent.fence, - expectedAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, - acquisitionGeneration: child.generation, - now: context.now(), - exitObservedAt: observedAt, - // Bare cause: whatever this settlement could not write is settled from it later (the - // settle recording it queues, or the next open or acquire); `exit-observed` says the rest. - exitReason: unexpectedEvent.reason.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - }) - } catch (error) { - logExitFailure(context, unexpectedEvent, 'exit-owner-release', error) - } finally { - endChild() - if (released) { - context.publishFence(unexpectedEvent.sessionId, session) - } - } - } - }) -} - -async function retryUnexpectedExitSettlement(input: { - context: Pick - event: UnexpectedExitLifecycleEvent - journal: DeadGenerationJournal - fence: number - stableSettlementId: string - verdict: StructuredAgentSessionTurnVerdict - exitedDuringStartup: boolean - failureTextContext: AgentSessionFailureWordsContext - showUnexpectedExitOutcome?: boolean -}): Promise { - const settled = await settleStructuredAgentSessionDeadGeneration({ - journal: input.journal, - sessionId: input.event.sessionId, - fence: input.fence, - settlementId: input.stableSettlementId, - verdict: input.verdict, - pendingSubmissionReason: 'provider_exited_before_acknowledgement', - showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, - ...(input.event.failure ? { exitFailure: input.event.failure } : {}), - failureTextContext: input.failureTextContext, - ...(input.exitedDuringStartup - ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } - : {}) - }) - if (!settled.ok) { - logExitFailure(input.context, input.event, 'exit-settlement', settled.error) - } -} - -function logExitFailure( - context: Pick, - event: UnexpectedExitLifecycleEvent, - scope: string, - error: unknown -): void { - context.logger.warn('settling a provider exit did not finish', { - scope, - sessionId: event.sessionId, - error - }) -} - -function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string { - return `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts deleted file mode 100644 index 5f1d03a7ead..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wind-down-wait-row.ts +++ /dev/null @@ -1,83 +0,0 @@ -// The row a message waiting on an unfinished stop leaves in the chat: why it has not gone out. - -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' -import type { - StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChildIdentity -} from './structured-agent-session-host-types' -import { pendingProviderChildWindDown } from './structured-agent-session-provider-child' - -/** Keyed by the child the stop could not prove gone, so every send that waits on it shares a row. */ -export function structuredAgentSessionWindDownWaitIdentity( - owed: StructuredAgentSessionProviderChildIdentity -): AgentJournalItemIdentity { - return { - provider: 'orca', - clientMessageId: `wind-down-wait:${owed.fence}:${owed.generation ?? 'unknown'}` - } -} - -type WaitingSession = Pick< - StructuredAgentSessionHostSession, - 'journal' | 'child' | 'owesProviderChildWindDown' -> - -function windDownWaitRow( - session: WaitingSession, - owed: StructuredAgentSessionProviderChildIdentity -) { - const itemId = agentJournalItemKey(structuredAgentSessionWindDownWaitIdentity(owed)) - return session.journal.snapshot().items.find((item) => item.itemId === itemId) -} - -/** Every queued message already waited through a retry of the owed stop: it was accepted before the - * newest pass failed. Only a new message retries again, and a retry that lands wakes the loop - * itself, so no other commit, each of which wakes the delivery loop, retries. */ -export function structuredAgentSessionWindDownWaitHolds(session: WaitingSession): boolean { - const failedAt = pendingProviderChildWindDown(session)?.failedAt - if (!failedAt || failedAt.epoch !== session.journal.cursor().epoch) { - return false - } - return session.journal - .submissions() - .every( - (submission) => - !isQueuedAgentJournalSubmission(submission) || - (submission.acceptedSequence ?? 0) <= failedAt.sequence - ) -} - -/** Written once per child, so every message that waits on it shares the row. */ -export async function recordStructuredAgentSessionWindDownWait( - session: WaitingSession, - sessionId: string, - input: { - conversationFence: (sessionId: string) => number - failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext - } -): Promise { - const owed = pendingProviderChildWindDown(session) - if (!owed || windDownWaitRow(session, owed)) { - return - } - const identity = structuredAgentSessionWindDownWaitIdentity(owed) - const words = agentSessionFailureWords(agentSessionFailureFact('previousExitUnverifiable'), { - ...input.failureTextContext(sessionId), - surface: 'row' - }) - await session.journal.appendItem( - identity, - { kind: 'status', tone: 'warning', ...words }, - { fence: input.conversationFence(sessionId), turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index fe98a870524..1099e8d6c04 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -9,7 +9,10 @@ // holding the stopped owner's fence is refused as stale rather than acting on its successor. import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + MAX_AGENT_SESSION_DEATH_DETAIL_CHARS, + type AgentSessionRecord +} from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -50,7 +53,10 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { lastRenewedAt: args.now, deathEvidence: { kind: 'exit-observed', - detail: args.exitReason ?? 'the last surface holding this session released it', + // A provider's exit reason can carry kilobytes of stderr; a longer detail fails the write. + detail: args.exitReason + ? args.exitReason.slice(0, MAX_AGENT_SESSION_DEATH_DETAIL_CHARS) + : 'the last surface holding this session released it', observedAt: args.exitObservedAt ?? args.now, ownerFence: record.lease.runtimeFence } diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts new file mode 100644 index 00000000000..b236c81d594 --- /dev/null +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' + +function ended( + sessionId: string, + cause: 'unexpected-exit' | 'requested-close' +): StructuredAgentSessionLifecycleEvent { + return { + type: 'ended', + sessionId, + reason: 'exited', + cause, + fence: 1, + acquisitionGeneration: 'generation-1' + } +} + +describe('provider exits reaching the host', () => { + // A close the host asked for ends on its own session's lane; another chat's crash recovery, + // which can run a whole reacquisition, must not hold it up. + it('ends a requested close without waiting behind another chat in recovery', async () => { + const handled: string[] = [] + let releaseRecovery = (): void => {} + const recovery = new Promise((resolve) => { + releaseRecovery = resolve + }) + const delivery = createStructuredAgentSessionLifecycleDelivery({ + handle: async (event) => { + if (event.type === 'ended' && event.cause === 'unexpected-exit') { + await recovery + } + handled.push(event.sessionId) + }, + logger: recordingStructuredAgentSessionLogger().logger, + drainObservedExits: async () => {} + }) + + delivery.deliver(ended('crashed', 'unexpected-exit')) + delivery.deliver(ended('closed', 'requested-close')) + await new Promise((resolve) => setImmediate(resolve)) + + expect(handled).toEqual(['closed']) + releaseRecovery() + await delivery.drain() + expect(handled).toEqual(['closed', 'crashed']) + }) +}) diff --git a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts index ccf026de359..ba307c2cfb0 100644 --- a/src/main/runtime/structured-agent-session-lifecycle-delivery.ts +++ b/src/main/runtime/structured-agent-session-lifecycle-delivery.ts @@ -3,8 +3,9 @@ // Exit recovery runs on one chain so teardown can drain it: exit callbacks arrive from child // process tasks, and a fire-and-forget one could otherwise append after the host flushed and // removed its journal directory. That chain orders nothing across sessions, and a recovery on it -// can run a whole reacquisition, so `started` stays off it: it takes only its own session's -// serialized step, and is tracked here so the same drain still waits for it. +// can run a whole reacquisition, so `started` and the end of a close the host asked for stay off +// it: each takes only its own session's serialized step, and is tracked here so the same drain +// still waits for it. import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -34,8 +35,8 @@ export function createStructuredAgentSessionLifecycleDelivery(input: { } return { deliver: (event) => { - if (event.type === 'started') { - // Called now, so the step is queued on its session ahead of any later exit of that child. + if (event.type === 'started' || event.cause === 'requested-close') { + // Called now, so the step is queued on its own session's lane, behind nothing of another's. const settling = settle(event) settlingStarts.add(settling) void settling.finally(() => settlingStarts.delete(settling)) diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.test.ts b/src/main/runtime/structured-agent-session-runtime-teardown.test.ts new file mode 100644 index 00000000000..767d0716c44 --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime-teardown.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest' +import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' + +// An exit that settles while teardown drains recovery wakes delivery; a fresh child started then +// would only be killed by the teardown after it. +it('stops delivery before it drains the recovery of exits already observed', async () => { + const order: string[] = [] + const installed: InstalledRuntime = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. + host: { + stopDelivery: () => order.push('stop-delivery'), + flushAllStreamedEvents: async () => { + order.push('flush') + } + } as never, + adapter: { closeAll: async () => undefined }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close` on the database. + journalDatabase: { close: () => order.push('close-database') } as never, + waitForRecovery: async () => { + order.push('wait-for-recovery') + } + } + + await tearDownRuntime(installed, 'quit') + + expect(order.slice(0, 2)).toEqual(['stop-delivery', 'wait-for-recovery']) +}) diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 47d93e02830..3275939a4fa 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -35,6 +35,9 @@ export async function tearDownRuntime( installed: InstalledRuntime, trigger: AgentSessionResumeTrigger ): Promise { + // An exit settled while recovery drains wakes delivery, which would start a fresh child for + // teardown to kill; queued messages wait for the next launch instead. + installed.host.stopDelivery() // Drain an in-flight recovery before stopping children; recovery may still // be writing lifecycle rows or acquiring a replacement child. await installed.waitForRecovery() diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5c..b5efd11efad 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -270,8 +270,10 @@ async function installOnJournal( host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, + logger: deps.logger, onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { + // Every exit, expected or not: the host ends that child's record. + if (event.type === 'ended' && 'cause' in event) { lifecycle.deliver(event) } } @@ -293,6 +295,7 @@ async function installOnJournal( } : {}), onLifecycleEvent: (event) => lifecycle.deliver(event), + logger: deps.logger, onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 9024c65b503..732515cdde6 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -38,6 +38,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] + logger?: ClaudeStructuredSessionAdapterDeps['logger'] } /** The adapter events the host's lifecycle handler consumes, in the host's vocabulary. */ @@ -47,9 +48,10 @@ export function structuredClaudeLifecycleEvent( if (event.type === 'started') { return event } + // Every exit of a child with an identity, expected or not: the host ends that child's record. if ( event.type === 'ended' && - event.cause === 'unexpected-exit' && + event.cause !== undefined && event.fence !== undefined && event.acquisitionGeneration ) { @@ -131,6 +133,7 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), + ...(deps.logger ? { logger: deps.logger } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), ...(deps.modelCatalog ? { modelCatalog: deps.modelCatalog } : {}) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 34dab4ab94f..59c4947c8b4 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -27,6 +27,11 @@ const PIECES: Record translate('components.native-chat.failureWords.sendToTryAgain', COPY.sendToTryAgain), + sendAgainToTryOnceMore: () => + translate( + 'components.native-chat.failureWords.sendAgainToTryOnceMore', + COPY.sendAgainToTryOnceMore + ), couldNotStart: (values) => translate('components.native-chat.failureWords.couldNotStart', COPY.couldNotStart, values), couldNotRestart: (values) => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 3cc1ed29e59..ff122b73905 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2785,7 +2785,7 @@ "notDelivered": "This message was not delivered.", "notDeliveredSendAgain": "This message was not delivered. Send it again to continue.", "notSignedIn": "{{agent}} is not signed in for the selected account.", - "previousExitUnverifiable": "{{agent}} from before may still be running. Your messages will send once it stops.", + "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", "providerExitedRejection": "{{agent}} stopped before this message was sent.", "providerExitedRow": "{{agent}} stopped while this response was in progress. You can continue in this conversation.", "providerRateLimited": "{{agent}} is rate-limited and retrying.", @@ -2797,6 +2797,7 @@ "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", "runCommandAgain": "Run /{{command}} again.", "sendToTryAgain": "Send your message to try again.", + "sendAgainToTryOnceMore": "Send your message again to try once more.", "signInFirst": "Sign in first.", "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25087a71ea2..40c50ff3b5c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17706,6 +17706,7 @@ "providerStartFailed": "{{agent}} stopped before it finished starting.", "runCommandAgain": "Run /{{command}} again.", "sendToTryAgain": "Send your message to try again.", + "sendAgainToTryOnceMore": "Send your message again to try once more.", "couldNotStart": "{{agent}} couldn't start.", "couldNotRestart": "{{agent}} couldn't restart.", "notSignedIn": "{{agent}} is not signed in for the selected account.", @@ -17759,7 +17760,7 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "{{agent}} from before may still be running. Your messages will send once it stops." + "previousExitUnverifiable": "Couldn't stop {{agent}} from before." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ec2d25bbb34..0d836852f59 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17484,6 +17484,7 @@ "providerStartFailed": "{{agent}} se detuvo antes de terminar de iniciarse.", "runCommandAgain": "Vuelva a ejecutar /{{command}}.", "sendToTryAgain": "Envíe su mensaje para volver a intentarlo.", + "sendAgainToTryOnceMore": "Vuelva a enviar su mensaje para intentarlo de nuevo.", "couldNotStart": "{{agent}} no pudo iniciarse.", "couldNotRestart": "{{agent}} no pudo reiniciarse.", "notSignedIn": "{{agent}} no tiene la sesión iniciada con la cuenta seleccionada.", @@ -17537,7 +17538,7 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "Es posible que {{agent}} siga en ejecución desde antes. Sus mensajes se enviarán cuando se detenga." + "previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 333d821d346..0b7490532bb 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17583,6 +17583,7 @@ "providerStartFailed": "{{agent}} s'est arrêté avant d'avoir fini de démarrer.", "runCommandAgain": "Relancez /{{command}}.", "sendToTryAgain": "Envoyez votre message pour réessayer.", + "sendAgainToTryOnceMore": "Renvoyez votre message pour réessayer.", "couldNotStart": "{{agent}} n'a pas pu démarrer.", "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", "notSignedIn": "{{agent}} n'est pas connecté avec le compte sélectionné.", @@ -17636,7 +17637,7 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "L'exécution précédente de {{agent}} est peut-être toujours en cours. Vos messages seront envoyés dès qu'elle s'arrêtera." + "previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5d1678b46d0..f83c99f8972 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17519,6 +17519,7 @@ "providerStartFailed": "{{agent}} は起動が完了する前に停止しました。", "runCommandAgain": "/{{command}} をもう一度実行してください。", "sendToTryAgain": "もう一度試すには、メッセージを送信してください。", + "sendAgainToTryOnceMore": "メッセージをもう一度送信して、再度お試しください。", "couldNotStart": "{{agent}} を起動できませんでした。", "couldNotRestart": "{{agent}} を再起動できませんでした。", "notSignedIn": "{{agent}} は選択したアカウントでサインインしていません。", @@ -17572,7 +17573,7 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "以前の {{agent}} がまだ実行中の可能性があります。停止するとメッセージが送信されます。" + "previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5cd0371e7be..27247862e4b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17519,6 +17519,7 @@ "providerStartFailed": "{{agent}}이(가) 시작을 마치기 전에 중지되었습니다.", "runCommandAgain": "/{{command}}을(를) 다시 실행하세요.", "sendToTryAgain": "다시 시도하려면 메시지를 보내세요.", + "sendAgainToTryOnceMore": "메시지를 다시 보내 한 번 더 시도하세요.", "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", "notSignedIn": "{{agent}}이(가) 선택한 계정으로 로그인되어 있지 않습니다.", @@ -17572,7 +17573,7 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "이전에 실행된 {{agent}}이(가) 아직 실행 중일 수 있습니다. 중지되면 메시지가 전송됩니다." + "previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3ce2dbdb7ba..63b7188d2a1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17484,6 +17484,7 @@ "providerStartFailed": "{{agent}} 在完成启动前已停止。", "runCommandAgain": "请重新运行 /{{command}}。", "sendToTryAgain": "请发送您的消息以重试。", + "sendAgainToTryOnceMore": "请重新发送您的消息,再试一次。", "couldNotStart": "{{agent}} 无法启动。", "couldNotRestart": "{{agent}} 无法重新启动。", "notSignedIn": "{{agent}} 未使用所选账户登录。", @@ -17537,7 +17538,7 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "之前的 {{agent}} 可能仍在运行。它停止后,您的消息就会发送。" + "previousExitUnverifiable": "无法停止之前的 {{agent}}。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 1f3a58f3bad..1e555c676d6 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -14,6 +14,7 @@ export const AGENT_SESSION_FAILURE_COPY = { providerStartFailed: '{{agent}} stopped before it finished starting.', runCommandAgain: 'Run /{{command}} again.', sendToTryAgain: 'Send your message to try again.', + sendAgainToTryOnceMore: 'Send your message again to try once more.', couldNotStart: "{{agent}} couldn't start.", couldNotRestart: "{{agent}} couldn't restart.", terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, @@ -81,8 +82,7 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRateLimited: '{{agent}} is rate-limited and retrying.', providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', - previousExitUnverifiable: - '{{agent}} from before may still be running. Your messages will send once it stops.' + previousExitUnverifiable: "Couldn't stop {{agent}} from before." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 98273628214..4e6b81b265a 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -151,6 +151,28 @@ describe('the words written beside a failure fact', () => { ).toBe("Codex couldn't start. Start a new chat to continue.") }) + it('says a start refused beside a process Orca could not stop in its own words', () => { + const refused = (context: { command?: 'compact'; retryControl?: boolean } = {}) => + agentSessionFailureSentence( + { + kind: 'restartFailed', + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'previousExitUnverifiable' } + } + }, + 'rejection', + { agentName: 'Claude', ...context } + ) + expect(refused()).toBe( + "Couldn't stop Claude from before. Send your message again to try once more." + ) + expect(refused({ command: 'compact' })).toBe( + "Couldn't stop Claude from before. Run /compact again." + ) + expect(refused({ retryControl: true })).toBe("Couldn't stop Claude from before.") + }) + it('names the exit a row reports differently from the message it left unsent', () => { expect( agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row', agentName: 'Claude' }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index f64ba5cb335..74e6e543b53 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -130,12 +130,13 @@ function withRetryCause(sentence: string, cause: string | undefined): string { /** The next step after a start or restart that failed: the command, or the message, again. */ function startRetry( say: AgentSessionFailureSay, - { command, retryControl }: AgentSessionFailureWordsContext + { command, retryControl }: AgentSessionFailureWordsContext, + sendAgain: 'sendToTryAgain' | 'sendAgainToTryOnceMore' = 'sendToTryAgain' ): string[] { if (retryControl) { return [] } - return [command ? say('runCommandAgain', { command }) : say('sendToTryAgain')] + return [command ? say('runCommandAgain', { command }) : say(sendAgain)] } function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { @@ -145,6 +146,13 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { if (fact.refusal?.details?.reason === 'claimConflicted') { return joinSentences([failed, say('terminalAgentHoldsChat'), say('quitTerminalAgent')]) } + // The previous process may still run, so nothing started: that, never that it exited. + if (fact.refusal?.details?.reason === 'previousExitUnverifiable') { + return joinSentences([ + say('previousExitUnverifiable', agent(say, context)), + ...startRetry(say, context, 'sendAgainToTryOnceMore') + ]) + } const code = fact.refusal?.code return joinSentences( code && !START_REFUSAL_RESUMABLE[code] diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b08c5250a15..a3c97850a1e 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -48,7 +48,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'hostStopped', /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', - /** A message waits on a child a Stop could not prove gone: its exit is unverifiable. */ + /** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */ 'previousExitUnverifiable' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index b8280cf332d..a9d563392f0 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -157,6 +157,8 @@ export type AgentSessionOptionsReplacement = { } const MAX_ID_LENGTH = 512 +/** A death evidence's `detail` past this fails a load, so whoever writes one cuts it here. */ +export const MAX_AGENT_SESSION_DEATH_DETAIL_CHARS = MAX_ID_LENGTH const MAX_PATH_LENGTH = 4096 const MAX_LAUNCH_ENV_ENTRIES = 256 const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536 @@ -290,7 +292,7 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath (evidence.kind === 'exit-observed' || evidence.kind === 'pid-absent' || evidence.kind === 'identity-mismatch') && - isBoundedString(evidence.detail, MAX_ID_LENGTH) && + isBoundedString(evidence.detail, MAX_AGENT_SESSION_DEATH_DETAIL_CHARS) && typeof observedAt === 'number' && Number.isSafeInteger(observedAt) && observedAt >= 0 && diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 70c6121d03f..b56ca5fbeaa 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -67,8 +67,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'notResumable', 'noProviderChild', 'conversationHeldElsewhere', - /** A Stop could not prove its child gone, and a retry could not either: that child takes no - * input and none starts beside it. Sent with `ownerVerdict: 'unverifiable'`. */ + /** The close a stop began could not prove its child gone: that child takes no input and none + * starts beside it. Sent with `ownerVerdict: 'unverifiable'`. */ 'previousExitUnverifiable' ], agent_session_conflict: [ diff --git a/src/shared/agent-session-stop-row-identity.ts b/src/shared/agent-session-stop-row-identity.ts index d314003a3f9..8d9fac96f11 100644 --- a/src/shared/agent-session-stop-row-identity.ts +++ b/src/shared/agent-session-stop-row-identity.ts @@ -4,7 +4,7 @@ // Contract: a new row that explains a stop must carry the providerExited fact or one of these // prefixes, or a client derives a second notice beside it. -/** The exit row the unexpected-exit settle writes, keyed by the child that exited. */ +/** The exit row the child-exit settle writes for an unexpected exit, keyed by the child that exited. */ export const PROVIDER_EXIT_ROW_PREFIX = 'provider-exit:' /** The exit row a reopen or acquire writes for a generation found dead. */ export const STALE_SESSION_ROW_PREFIX = 'stale-session:' From d9cc1d05286ff3d49f33f95ae7ca5291919d45dd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:36:20 -0400 Subject: [PATCH 4/4] feat(activity): filters, matching header, row right-click menu, and stay in the activity view (#25376) * fix(sidebar): stop workspace reveals from leaving the activity view Reveals are skipped while the activity view is showing, and activation no longer lifts workspace-list filters there. Only explicit requests (Go to workspace, reveal-current-workspace, Cmd+J project row, agent send target, Cmd+1-9) switch to the workspace list via showSidebarWorkspaceList. * test(sidebar): keep test harnesses out of the sidebarBody writer census * feat(activity): workspace-origin filters and a matching options trigger Activity view gains its own persisted Hide automation-created, Hide CLI-created and Hide other-client agents toggles, applied through the existing activity scope filter so the selected-row exemption and identity-preserving return keep working. The options trigger now uses the workspace view's SlidersHorizontal icon and shared numeric badge, counting every active activity filter, and Add project stays visible so no header button shifts between views. * feat(activity): right-click menu on activity rows Open, Mark as read/unread, Go to workspace (real workspaces only), Copy (agent title, plus branch and path for real workspaces), and Clear from list for finished threads, reusing the row's existing actions and clearActivityThread. * fix(sidebar): leave the activity view only once activation commits Go to workspace and Cmd+1-9 switched the sidebar before activating, so a blocked folder activation still left the activity view. Activation now owns the switch via showWorkspaceList, applied after its failure gates. The separate showSidebarWorkspaceList action and the one-line sidebar-body-reveal module go away: setSidebarBody is the single writer, and the census pins its callers. * fix(activity): close the row preview when its right-click menu opens * fix(activity): keep the row preview closed while its right-click menu is open * fix(ui): give right-click menus the same solid surface as dropdowns * fix(activity): match the agent row menu to the workspace row menu * fix(activity): offer the same copy items as the workspace row menu --- .../client-ui-pairing-local-fields.test.ts | 3 + .../ActivityThreadOptionsMenu.test.tsx | 86 ++++++++- .../activity-scope-filter-controls.tsx | 102 +++++++---- .../activity/activity-scope-filter.test.ts | 65 +++++++ .../activity/activity-scope-filter.ts | 37 +++- .../activity/activity-thread-actions.test.ts | 1 + .../activity-thread-context-menu.test.tsx | 167 ++++++++++++++++++ .../activity/activity-thread-context-menu.tsx | 121 +++++++++++++ .../activity-thread-hover-card.test.tsx | 17 ++ .../activity/activity-thread-hover-card.tsx | 10 +- .../activity/activity-thread-options-menu.tsx | 123 +++++++------ .../activity/activity-thread-row.tsx | 5 +- .../activity/activity-thread-virtual-row.tsx | 48 +++-- .../activity/use-agent-pane-threads.ts | 35 +++- .../components/sidebar/SidebarHeader.test.tsx | 4 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 12 +- .../sidebar/options-filter-count-badge.tsx | 23 +++ .../sidebar/sidebar-header-actions.tsx | 15 +- .../sidebar/workspace-options-menu-items.tsx | 3 +- .../src/components/ui/context-menu.tsx | 4 +- ...worktree-jump-palette-selection-actions.ts | 2 + .../workspace-shortcut-ipc-bridge.ts | 2 + .../useIpcEvents-cmd-j-digit-chord.test.ts | 4 +- src/renderer/src/i18n/locales/en.json | 21 ++- src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - ...ktree-activation-automation-filter.test.ts | 121 ++++++++++--- .../worktree-activation-sidebar-filters.ts | 26 +++ .../worktree-activation-surface-selection.ts | 2 + src/renderer/src/lib/worktree-activation.ts | 27 ++- .../src/lib/worktree-jump-navigation.test.ts | 19 +- .../src/lib/worktree-jump-navigation.ts | 6 +- .../store/slices/ui-agent-send-target.test.ts | 18 ++ .../slices/ui-hydration-view-layout.test.ts | 21 ++- ...ui-hydration-workspace-preferences.test.ts | 21 +++ .../slices/ui/sidebar-body-writers.test.ts | 65 +++++++ .../store/slices/ui/ui-slice-agent-actions.ts | 2 + ...ui-slice-agents-view-preference-actions.ts | 63 +++++++ .../ui/ui-slice-contract-preferences.ts | 6 + .../slices/ui/ui-slice-hydration-actions.ts | 4 + .../slices/ui/ui-slice-preference-actions.ts | 42 +---- .../slices/ui/ui-slice-surface-actions.ts | 46 ++--- .../web-preference-normalization.ts | 3 + ...reload-api-ui-pairing-local-fields.test.ts | 100 +++++++++++ .../src/web/web-preload-api-ui.test.ts | 76 -------- src/shared/constants.ts | 3 + src/shared/pairing-local-ui-fields.test.ts | 3 + src/shared/pairing-local-ui-fields.ts | 3 + src/shared/persisted-ui-state-types.ts | 4 + src/shared/rpc-contract/client-ui-params.ts | 3 + 53 files changed, 1240 insertions(+), 359 deletions(-) create mode 100644 src/renderer/src/components/activity/activity-thread-context-menu.test.tsx create mode 100644 src/renderer/src/components/activity/activity-thread-context-menu.tsx create mode 100644 src/renderer/src/components/sidebar/options-filter-count-badge.tsx create mode 100644 src/renderer/src/lib/worktree-activation-sidebar-filters.ts create mode 100644 src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts create mode 100644 src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index 5bcaca1fd0e..4e3beb3a771 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -48,6 +48,9 @@ describe('client UI RPC pairing-local field seams', () => { workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'], agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'], agentsFilterRepoIds: ['repo-a'], + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true, agentsShowChildAgents: true, agentsCompactMode: false, agentsShowSearch: false, diff --git a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx index 6ba2b7906a4..c5cedcb2443 100644 --- a/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx +++ b/src/renderer/src/components/activity/ActivityThreadOptionsMenu.test.tsx @@ -48,7 +48,13 @@ describe('ActivityThreadOptionsMenu', () => { let root: Root beforeEach(() => { - useAppStore.setState({ agentsVisibleHostIds: null, agentsFilterRepoIds: [] }) + useAppStore.setState({ + agentsVisibleHostIds: null, + agentsFilterRepoIds: [], + agentsHideWorkspacesFromOtherDevices: false, + agentsHideAutomationGeneratedWorkspaces: false, + agentsHideCliCreatedWorkspaces: false + }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -68,10 +74,80 @@ describe('ActivityThreadOptionsMenu', () => { }) const trigger = container.querySelector( - 'button[aria-label="Thread list options, filters active"]' + 'button[aria-label="Thread list options (1 filter active)"]' ) expect(trigger).not.toBeNull() - expect(trigger?.querySelector('[data-scope-filter-dot]')).not.toBeNull() + expect(trigger?.querySelector('[data-options-filter-count]')?.textContent).toBe('1') + }) + + it('counts every active activity filter in the badge', async () => { + useAppStore.setState({ + agentsVisibleHostIds: ['local'], + agentsFilterRepoIds: ['repo-1'], + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true + }) + + await act(async () => { + root.render( + + + + ) + }) + + const trigger = container.querySelector( + 'button[aria-label="Thread list options (6 filters active)"]' + ) + expect(trigger?.querySelector('[data-options-filter-count]')?.textContent).toBe('6') + }) + + it('persists the workspace-origin toggles separately from the workspace-nav filters', async () => { + const originalState = useAppStore.getState() + const persist = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { set: persist } }) + useAppStore.setState({ + runtimeEnvironmentCatalogHydrated: true, + runtimeEnvironments: [], + agentsHideCliCreatedWorkspaces: false, + hideCliCreatedWorkspaces: false + }) + try { + await act(async () => root.render()) + const trigger = container.querySelector( + 'button[aria-label="Thread list options"]' + ) + await act(async () => { + trigger?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + const labels = Array.from( + document.querySelectorAll('[role="menuitemcheckbox"]') + ).map((item) => item.textContent) + expect(labels).toContain('Hide automation-created') + // Without paired runtimes there is no other-client provenance to filter on. + expect(labels).not.toContain('Hide other-client agents') + + const cliItem = Array.from( + document.querySelectorAll('[role="menuitemcheckbox"]') + ).find((item) => item.textContent === 'Hide CLI-created') + await act(async () => { + cliItem?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })) + }) + + expect(useAppStore.getState().agentsHideCliCreatedWorkspaces).toBe(true) + expect(useAppStore.getState().hideCliCreatedWorkspaces).toBe(false) + expect(persist).toHaveBeenCalledWith({ agentsHideCliCreatedWorkspaces: true }) + } finally { + act(() => useAppStore.setState(originalState)) + vi.unstubAllGlobals() + } }) it('opens without recursively updating composed Radix trigger refs', async () => { @@ -108,7 +184,7 @@ describe('ActivityThreadOptionsMenu', () => { try { await act(async () => root.render()) const trigger = container.querySelector( - 'button[aria-label="Thread list options, filters active"]' + 'button[aria-label="Thread list options (1 filter active)"]' ) expect(trigger).not.toBeNull() await act(async () => { @@ -128,7 +204,7 @@ describe('ActivityThreadOptionsMenu', () => { expect(useAppStore.getState().filterRepoIds).toEqual(['workspace-nav-filter']) expect(persist).toHaveBeenCalledWith({ agentsVisibleHostIds: null }) expect(persist).toHaveBeenCalledWith({ agentsFilterRepoIds: [] }) - expect(container.querySelector('[data-scope-filter-dot]')).toBeNull() + expect(container.querySelector('[data-options-filter-count]')).toBeNull() } finally { act(() => useAppStore.setState(originalState)) vi.unstubAllGlobals() diff --git a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx index e90ec1f3322..5671567937f 100644 --- a/src/renderer/src/components/activity/activity-scope-filter-controls.tsx +++ b/src/renderer/src/components/activity/activity-scope-filter-controls.tsx @@ -1,6 +1,6 @@ import React from 'react' import { useAppStore } from '@/store' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuCheckboxItem, DropdownMenuItem } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' import SidebarRepositoryFilterSection from '@/components/sidebar/SidebarRepositoryFilterSection' import { SidebarHostScopeMenuSection } from '@/components/sidebar/SidebarHostScopeMenuSection' @@ -11,45 +11,73 @@ import { import { useSidebarHostScopeOptions } from '@/components/sidebar/use-sidebar-host-scope-options' /** - * Whether {@link ActivityScopeFilterMenuItems} renders anything. - * Why exported: the parent owns the Filters label and separator, so it has to - * know whether the section would be empty. + * Workspace-origin, host, and project scope items for the Agents activity surfaces. State is the + * persisted agents-view scope (agentsHide*, agentsVisibleHostIds, agentsFilterRepoIds), + * deliberately separate from the workspace-nav filters. The parent owns the Filters label and + * separator. */ -export function useActivityScopeFilterMenuItemsVisible(): boolean { - const repos = useAppStore((s) => s.repos) - const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) - const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) - const { hostOptions } = useSidebarHostScopeOptions() - return ( - agentsVisibleHostIds !== null || - agentsFilterRepoIds.length > 0 || - shouldShowHostScopeControls(hostOptions) || - repos.length > 1 - ) -} - -/** - * Host/project scope items for the Agents activity surfaces. State is the - * persisted agents-view scope (agentsVisibleHostIds / agentsFilterRepoIds), - * deliberately separate from the workspace-nav filters. The parent owns the - * Filters label and separator. - */ -export function ActivityScopeFilterMenuItems(): React.JSX.Element | null { +export function ActivityScopeFilterMenuItems(): React.JSX.Element { const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) const setAgentsVisibleHostIds = useAppStore((s) => s.setAgentsVisibleHostIds) const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) const setAgentsFilterRepoIds = useAppStore((s) => s.setAgentsFilterRepoIds) + const hideAutomationGenerated = useAppStore((s) => s.agentsHideAutomationGeneratedWorkspaces) + const setHideAutomationGenerated = useAppStore( + (s) => s.setAgentsHideAutomationGeneratedWorkspaces + ) + const hideCliCreated = useAppStore((s) => s.agentsHideCliCreatedWorkspaces) + const setHideCliCreated = useAppStore((s) => s.setAgentsHideCliCreatedWorkspaces) + const hideFromOtherDevices = useAppStore((s) => s.agentsHideWorkspacesFromOtherDevices) + const setHideFromOtherDevices = useAppStore((s) => s.setAgentsHideWorkspacesFromOtherDevices) + // Same gate as the workspace menu: other-client provenance only exists with paired runtimes. + const showOtherClientFilter = useAppStore( + (s) => + !s.runtimeEnvironmentCatalogHydrated || + s.runtimeEnvironments.length > 0 || + s.agentsHideWorkspacesFromOtherDevices + ) const { hostOptions } = useSidebarHostScopeOptions() const showHostScopeControls = shouldShowHostScopeControls(hostOptions) const hasScopeFilter = agentsVisibleHostIds !== null || agentsFilterRepoIds.length > 0 - const visible = useActivityScopeFilterMenuItemsVisible() - - if (!visible) { - return null - } return ( <> + setHideAutomationGenerated(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.hideAutomationCreated', + 'Hide automation-created' + )} + + setHideCliCreated(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.hideCliCreated', + 'Hide CLI-created' + )} + + {showOtherClientFilter ? ( + setHideFromOtherDevices(checked === true)} + onSelect={(event) => event.preventDefault()} + aria-label={translate( + 'auto.components.activity.ActivityScopeFilterControls.hideOtherClientsAria', + 'Hide agents in workspaces created from other Orca clients on shared remote servers' + )} + > + {translate( + 'auto.components.activity.ActivityScopeFilterControls.hideOtherClients', + 'Hide other-client agents' + )} + + ) : null} {showHostScopeControls ? ( state.agentsVisibleHostIds !== null || state.agentsFilterRepoIds.length > 0 + (state) => + (state.agentsVisibleHostIds !== null ? 1 : 0) + + state.agentsFilterRepoIds.length + + (state.agentsHideWorkspacesFromOtherDevices ? 1 : 0) + + (state.agentsHideAutomationGeneratedWorkspaces ? 1 : 0) + + (state.agentsHideCliCreatedWorkspaces ? 1 : 0) ) } diff --git a/src/renderer/src/components/activity/activity-scope-filter.test.ts b/src/renderer/src/components/activity/activity-scope-filter.test.ts index 45817e50a7b..557c91badf7 100644 --- a/src/renderer/src/components/activity/activity-scope-filter.test.ts +++ b/src/renderer/src/components/activity/activity-scope-filter.test.ts @@ -42,6 +42,10 @@ function makeScope(overrides: Partial = {}): ActivityScopeF visibleHostIds: null, filterRepoIds: [], defaultHostId: LOCAL_EXECUTION_HOST_ID, + hideWorkspacesFromOtherDevices: false, + pairedDeviceIdsByEnvironment: new Map(), + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, ...overrides } } @@ -75,6 +79,67 @@ describe('threadMatchesActivityScope', () => { }) }) +describe('threadMatchesActivityScope workspace-origin toggles', () => { + const cliThread = makeThread({ + worktree: { ...makeWorktree(), cliProvenance: { kind: 'created-by-cli', createdAt: 1 } } + }) + const otherDeviceThread = makeThread({ + worktree: { + ...makeWorktree(), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'paired-device', deviceId: 'phone' } + } + }) + const ownDeviceThread = makeThread({ + worktree: { + ...makeWorktree(), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'paired-device', deviceId: 'this-desktop' } + } + }) + const pairedDeviceIdsByEnvironment = new Map([['env-1', 'this-desktop']]) + + it('hides CLI-created workspaces only when that toggle is on', () => { + expect(threadMatchesActivityScope(cliThread, makeScope())).toBe(true) + expect( + threadMatchesActivityScope(cliThread, makeScope({ hideCliCreatedWorkspaces: true })) + ).toBe(false) + expect( + threadMatchesActivityScope(cliThread, makeScope({ hideAutomationGeneratedWorkspaces: true })) + ).toBe(true) + }) + + it('hides workspaces created from another client of the same runtime', () => { + const scope = makeScope({ hideWorkspacesFromOtherDevices: true, pairedDeviceIdsByEnvironment }) + expect(threadMatchesActivityScope(otherDeviceThread, scope)).toBe(false) + expect(threadMatchesActivityScope(ownDeviceThread, scope)).toBe(true) + }) + + it('never hides provenance-less threads such as floating terminals', () => { + const floating = makeThread({ repo: null }) + const scope = makeScope({ + hideWorkspacesFromOtherDevices: true, + pairedDeviceIdsByEnvironment, + hideAutomationGeneratedWorkspaces: true, + hideCliCreatedWorkspaces: true + }) + expect(threadMatchesActivityScope(floating, scope)).toBe(true) + }) + + it('keeps the exempt pane visible and counts the rest as hidden', () => { + const plain = makeThread({ paneKey: 'pane-plain' }) + const exemptCli = { ...cliThread, paneKey: 'pane-exempt' } + const result = filterThreadsByActivityScope({ + threads: [plain, cliThread, exemptCli], + scope: makeScope({ hideCliCreatedWorkspaces: true }), + exemptPaneKey: 'pane-exempt' + }) + expect(result.threads).toEqual([plain, exemptCli]) + expect(result.matchingThreads).toEqual([plain]) + expect(result.hiddenCount).toBe(1) + }) +}) + describe('filterThreadsByActivityScope', () => { it('returns the input array by identity when the scope is inactive', () => { const threads = [makeThread(), makeThread({ paneKey: 'pane-2', repo: null })] diff --git a/src/renderer/src/components/activity/activity-scope-filter.ts b/src/renderer/src/components/activity/activity-scope-filter.ts index 36dfafa8cf6..b8330ffc866 100644 --- a/src/renderer/src/components/activity/activity-scope-filter.ts +++ b/src/renderer/src/components/activity/activity-scope-filter.ts @@ -1,14 +1,34 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' +import { + isAutomationGeneratedWorkspace, + isCliCreatedWorkspace +} from '@/components/sidebar/visible-worktree-kinds' +import { isWorkspaceFromOtherDevice } from '@/components/sidebar/workspace-creator-visibility' import type { AgentPaneThread } from './activity-thread-types' -/** Host/project scope for the Agents activity surfaces. Persisted and separate +/** Host/project/workspace-origin scope for the Agents activity surfaces. Persisted and separate * from the workspace-nav filters; hosts `null` = all, repoIds empty = all. */ export type ActivityScopeFilter = { visibleHostIds: readonly ExecutionHostId[] | null filterRepoIds: readonly string[] defaultHostId: ExecutionHostId + hideWorkspacesFromOtherDevices: boolean + /** Only read when hideWorkspacesFromOtherDevices is on. */ + pairedDeviceIdsByEnvironment: ReadonlyMap + hideAutomationGeneratedWorkspaces: boolean + hideCliCreatedWorkspaces: boolean +} + +function isActivityScopeActive(scope: ActivityScopeFilter): boolean { + return ( + scope.visibleHostIds !== null || + scope.filterRepoIds.length > 0 || + scope.hideWorkspacesFromOtherDevices || + scope.hideAutomationGeneratedWorkspaces || + scope.hideCliCreatedWorkspaces + ) } /** Repo ids that still exist; stale persisted ids must not count as an active filter. */ @@ -36,7 +56,7 @@ export function filterThreadsByActivityScope(args: { hiddenCount: number } { const { threads, scope, exemptPaneKey } = args - if (!scope.visibleHostIds && scope.filterRepoIds.length === 0) { + if (!isActivityScopeActive(scope)) { return { threads, matchingThreads: threads, hiddenCount: 0 } } const matchingThreads: AgentPaneThread[] = [] @@ -76,5 +96,18 @@ export function threadMatchesActivityScope( return false } } + // Synthetic floating/standalone worktrees carry no provenance, so these never hide them. + if (scope.hideAutomationGeneratedWorkspaces && isAutomationGeneratedWorkspace(thread.worktree)) { + return false + } + if (scope.hideCliCreatedWorkspaces && isCliCreatedWorkspace(thread.worktree)) { + return false + } + if ( + scope.hideWorkspacesFromOtherDevices && + isWorkspaceFromOtherDevice(thread.worktree, scope.pairedDeviceIdsByEnvironment) + ) { + return false + } return true } diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index ce546f03697..0e1cdd6a20e 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -259,6 +259,7 @@ describe('activity thread host routing', () => { expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey]) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { navigationIntent: 'user-open', + showWorkspaceList: true, executionHostId: REMOTE_HOST }) }) diff --git a/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx b/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx new file mode 100644 index 00000000000..46758ccf057 --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + ActivityThreadContextMenu, + getActivityThreadCopyTargets +} from './activity-thread-context-menu' +import type * as ActivityClearCompleted from './activity-clear-completed' +import type { AgentPaneThread } from './activity-thread-types' +import { makeRepo, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' + +const mocks = vi.hoisted(() => ({ clearActivityThread: vi.fn() })) + +vi.mock('./activity-clear-completed', async (importOriginal) => ({ + ...(await importOriginal()), + clearActivityThread: mocks.clearActivityThread +})) + +function makeThread(overrides: Partial = {}): AgentPaneThread { + return { + paneKey: 'tab-1:leaf-1', + paneTitle: 'Fix the flaky test', + worktree: { ...makeWorktree(), branch: 'refs/heads/feat/flaky' }, + repo: makeRepo(), + tab: makeTab(), + agentType: 'claude', + currentAgentState: 'working', + currentAgentEntry: null, + responsePreview: '', + latestTimestamp: 1000, + latestEvent: null, + events: [], + unread: true, + ...overrides + } +} + +const handlers = { + onOpen: vi.fn(), + onJump: vi.fn(), + onMarkRead: vi.fn(), + onMarkUnread: vi.fn() +} + +function openMenu(thread: AgentPaneThread, canJump = true, disableMarkUnread = false): void { + render( + + {(menuOpen) => ( +
+ row +
+ )} +
+ ) + fireEvent.contextMenu(screen.getByTestId('row')) +} + +function menuItem(name: string): HTMLElement { + return screen.getByRole('menuitem', { name }) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('ActivityThreadContextMenu', () => { + it('opens the thread', () => { + const thread = makeThread() + openMenu(thread) + + fireEvent.click(menuItem('Open')) + expect(handlers.onOpen).toHaveBeenCalledWith(thread) + }) + + it('shows one read toggle that marks an unread thread read', () => { + const thread = makeThread() + openMenu(thread) + + expect(screen.queryByRole('menuitem', { name: 'Mark Unread' })).toBeNull() + fireEvent.click(menuItem('Mark Read')) + expect(handlers.onMarkRead).toHaveBeenCalledWith(thread) + }) + + it('shows one read toggle that marks a read thread unread', () => { + const thread = makeThread({ unread: false }) + openMenu(thread) + + expect(screen.queryByRole('menuitem', { name: 'Mark Read' })).toBeNull() + fireEvent.click(menuItem('Mark Unread')) + expect(handlers.onMarkUnread).toHaveBeenCalledWith(thread) + }) + + it('disables Mark Unread for the open thread', () => { + openMenu(makeThread({ unread: false }), true, true) + + expect(menuItem('Mark Unread').hasAttribute('data-disabled')).toBe(true) + }) + + it('lists copy actions flat instead of in a submenu', () => { + openMenu(makeThread()) + + expect(menuItem('Copy Path')).toBeTruthy() + expect(menuItem('Copy Title')).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: 'Copy Branch' })).toBeNull() + }) + + it('tells the row while the menu is open so it can keep its preview closed', () => { + openMenu(makeThread()) + expect(screen.getByTestId('row').hasAttribute('data-menu-open')).toBe(true) + + fireEvent.keyDown(menuItem('Open'), { key: 'Escape' }) + expect(screen.getByTestId('row').hasAttribute('data-menu-open')).toBe(false) + }) + + it('offers Go to Workspace only for threads with a real workspace', () => { + openMenu(makeThread(), false) + + expect(screen.queryByRole('menuitem', { name: 'Go to Workspace' })).toBeNull() + }) + + it('offers Clear from List only for finished threads', () => { + const done = makeThread({ + currentAgentState: null, + paneEntry: { + state: 'done', + prompt: '', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'claude', + paneKey: 'tab-1:leaf-1', + stateHistory: [] + } + }) + openMenu(done) + + fireEvent.click(menuItem('Clear from List')) + expect(mocks.clearActivityThread).toHaveBeenCalledWith(done) + cleanup() + + openMenu(makeThread()) + expect(screen.queryByRole('menuitem', { name: 'Clear from List' })).toBeNull() + }) + + it('copies the path, then the title, of a real workspace like the workspace menu', () => { + expect(getActivityThreadCopyTargets(makeThread(), true)).toEqual([ + { key: 'path', label: 'Copy Path', value: '/repo/wt-1' }, + { key: 'title', label: 'Copy Title', value: 'Fix the flaky test' } + ]) + }) + + it('offers only the title for a synthetic terminal without a workspace', () => { + const synthetic = makeThread({ + worktree: { ...makeWorktree(), path: '', branch: 'Floating terminal' } + }) + + expect(getActivityThreadCopyTargets(synthetic, false)).toEqual([ + { key: 'title', label: 'Copy Title', value: 'Fix the flaky test' } + ]) + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-context-menu.tsx b/src/renderer/src/components/activity/activity-thread-context-menu.tsx new file mode 100644 index 00000000000..2a63c5d627c --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-context-menu.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import { Bell, BellOff, Copy, ExternalLink, PanelRight, X } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { translate } from '@/i18n/i18n' +import { clearActivityThread, isClearableActivityThread } from './activity-clear-completed' +import { activityThreadRowCopy } from './activity-thread-presentation' +import type { AgentPaneThread } from './activity-thread-types' + +type CopyTarget = { key: string; label: string; value: string } + +export function getActivityThreadCopyTargets( + thread: AgentPaneThread, + hasWorkspace: boolean +): CopyTarget[] { + const title: CopyTarget = { + key: 'title', + label: translate('auto.components.activity.ActivityThreadContextMenu.copyTitle', 'Copy Title'), + value: activityThreadRowCopy(thread).taskTitle + } + // Why gated: synthetic floating/standalone worktrees have no path. + if (!hasWorkspace || !thread.worktree.path) { + return [title] + } + const path: CopyTarget = { + key: 'path', + label: translate('auto.components.activity.ActivityThreadContextMenu.copyPath', 'Copy Path'), + value: thread.worktree.path + } + // Same order as the workspace menu: Copy Path, then the name. + return [path, title] +} + +/** Right-click actions for an activity row; mirrors the row's own click and hover actions. */ +export function ActivityThreadContextMenu({ + thread, + canJump, + disableMarkUnread, + onOpen, + onJump, + onMarkRead, + onMarkUnread, + children +}: { + thread: AgentPaneThread + canJump: boolean + disableMarkUnread: boolean + onOpen: (thread: AgentPaneThread) => void + onJump: (thread: AgentPaneThread) => void + onMarkRead: (thread: AgentPaneThread) => void + onMarkUnread: (thread: AgentPaneThread) => void + /** Receives whether the menu is open, so the row can keep its preview out of the way. */ + children: (menuOpen: boolean) => React.ReactElement +}): React.JSX.Element { + const [menuOpen, setMenuOpen] = React.useState(false) + return ( + + {children(menuOpen)} + {/* Why no focus restore: refocusing the row would reopen its hover preview and pin it open. */} + event.preventDefault()}> + + {translate('auto.components.activity.ActivityThreadContextMenu.agentSection', 'Agent')} + + onOpen(thread)}> + + {translate('auto.components.activity.ActivityThreadContextMenu.open', 'Open')} + + {canJump ? ( + onJump(thread)}> + + {translate( + 'auto.components.activity.ActivityThreadContextMenu.goToWorkspace', + 'Go to Workspace' + )} + + ) : null} + + {getActivityThreadCopyTargets(thread, canJump).map((target) => ( + void window.api.ui.writeClipboardText(target.value)} + > + + {target.label} + + ))} + + (thread.unread ? onMarkRead(thread) : onMarkUnread(thread))} + > + {thread.unread ? : } + {thread.unread + ? translate('auto.components.activity.ActivityThreadContextMenu.markRead', 'Mark Read') + : translate( + 'auto.components.activity.ActivityThreadContextMenu.markUnread', + 'Mark Unread' + )} + + {isClearableActivityThread(thread) ? ( + <> + + clearActivityThread(thread)}> + + {translate( + 'auto.components.activity.ActivityThreadContextMenu.clear', + 'Clear from List' + )} + + + ) : null} + + + ) +} diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx index d64f60c11a1..312b9e8c4ab 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx @@ -215,6 +215,23 @@ describe('ActivityThreadHoverCard and ActivityThreadRow', () => { expect(content).toContain('Performance investigation notes') }) + it('keeps the preview closed while suppressed by the row menu', async () => { + const renderCard = (suppressed: boolean) => + root.render( + + +
Hover Target
+
+
+ ) + + await act(async () => renderCard(true)) + expect(container.querySelector('[data-testid="hover-card-content"]')).toBeNull() + + await act(async () => renderCard(false)) + expect(container.querySelector('[data-testid="hover-card-content"]')).not.toBeNull() + }) + it('allows clicking row while preventing inner hover interactions from bubbling', async () => { const onSelect = vi.fn() const thread = createTestThread() diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.tsx index 9bb4ed83887..064235f175e 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.tsx @@ -37,6 +37,8 @@ export type ActivityThreadHoverCardProps = { closeDelay?: number onJumpToWorkspace?: (thread: AgentPaneThread) => void canJumpToWorkspace?: boolean + /** Keeps the preview closed, e.g. while the row's right-click menu covers it. */ + suppressed?: boolean } export function ActivityThreadHoverCard({ @@ -45,19 +47,21 @@ export function ActivityThreadHoverCard({ openDelay = 200, closeDelay = 120, onJumpToWorkspace, - canJumpToWorkspace + canJumpToWorkspace, + suppressed = false }: ActivityThreadHoverCardProps): React.JSX.Element { const detailsHoverControl = useWorktreeCardDetailsHoverControl() + const open = detailsHoverControl.hoverOpen && !suppressed return ( {children} - {detailsHoverControl.hoverOpen ? ( + {open ? ( void }): React.JSX.Element { const skipCloseAutoFocusRef = React.useRef(false) - const scopeFilterActive = useActivityScopeFilterActive() - const scopeFilterItemsVisible = useActivityScopeFilterMenuItemsVisible() - const hasFilters = Boolean( - onUnreadOnlyChange || onShowChildAgentsChange || scopeFilterItemsVisible - ) - const optionsLabel = scopeFilterActive - ? translate( - 'auto.components.activity.ActivityPrototypePage.threadListOptionsFiltered', - 'Thread list options, filters active' - ) - : translate('auto.components.activity.ActivityPrototypePage.db8a1878b5', 'Thread list options') + const activeFilterCount = + useActivityScopeFilterCount() + (onUnreadOnlyChange && unreadOnly ? 1 : 0) + const activeFilterLabel = formatActiveFilterLabel(activeFilterCount) + const optionsLabel = + activeFilterCount > 0 + ? translate( + 'auto.components.activity.ActivityPrototypePage.threadListOptionsActive', + 'Thread list options ({{value0}} active)', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.activity.ActivityPrototypePage.db8a1878b5', + 'Thread list options' + ) return ( @@ -105,23 +111,23 @@ export function ActivityThreadOptionsMenu({ className="relative text-muted-foreground" aria-label={optionsLabel} > - - {scopeFilterActive ? ( - - - {translate( - 'auto.components.activity.ActivityPrototypePage.activityOptions', - 'Activity options' - )} + + {activeFilterCount > 0 + ? translate( + 'auto.components.activity.ActivityPrototypePage.activityOptionsActive', + 'Activity options ({{value0}})', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.activity.ActivityPrototypePage.activityOptions', + 'Activity options' + )} - {hasFilters ? ( - <> - - {translate( - 'auto.components.activity.ActivityPrototypePage.filtersSection', - 'Filters' - )} - - {onUnreadOnlyChange ? ( - onUnreadOnlyChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.showUnreadOnly', - 'Show unread only' - )} - - ) : null} - {onShowChildAgentsChange ? ( - onShowChildAgentsChange(checked === true)} - onSelect={(event) => event.preventDefault()} - > - {translate( - 'auto.components.activity.ActivityPrototypePage.showChildAgents', - 'Show child agents' - )} - - ) : null} - - - + + {translate('auto.components.activity.ActivityPrototypePage.filtersSection', 'Filters')} + + {onUnreadOnlyChange ? ( + onUnreadOnlyChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityPrototypePage.showUnreadOnly', + 'Show unread only' + )} + ) : null} + {onShowChildAgentsChange ? ( + onShowChildAgentsChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.activity.ActivityPrototypePage.showChildAgents', + 'Show child agents' + )} + + ) : null} + + {translate('auto.components.activity.ActivityPrototypePage.viewSection', 'View')} diff --git a/src/renderer/src/components/activity/activity-thread-row.tsx b/src/renderer/src/components/activity/activity-thread-row.tsx index 207ba0d6021..a418624bfff 100644 --- a/src/renderer/src/components/activity/activity-thread-row.tsx +++ b/src/renderer/src/components/activity/activity-thread-row.tsx @@ -65,7 +65,8 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ canJump, compactMode, disableMarkUnread = false, - showJumpAction = true + showJumpAction = true, + previewSuppressed = false }: { thread: AgentPaneThread selected: boolean @@ -78,6 +79,7 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ compactMode: boolean disableMarkUnread?: boolean showJumpAction?: boolean + previewSuppressed?: boolean }): React.JSX.Element { const { taskTitle, statusLine, statusKind, needsAttention, workspaceLabel } = activityThreadRowCopy(thread) @@ -89,6 +91,7 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ thread={thread} onJumpToWorkspace={onJump} canJumpToWorkspace={canJump} + suppressed={previewSuppressed} >
) } + const canJump = canJumpToWorkspace(item.thread) + const disableMarkUnread = item.thread.paneKey === selectedPaneKey && !allowMarkUnreadWhenSelected return ( -
- -
+ + {(menuOpen) => ( + // Why the menu wraps this wrapper, not the row: the row is already the hover-card + // trigger, and stacking two Radix triggers on one node composes their refs. +
+ +
+ )} +
) } diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index ce1f571cdbc..44fecebb5e6 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -11,6 +11,10 @@ import { buildActivityEvents, createActivityEventBuildCache } from './activity-e import { projectActivityTabs, type ActivityTabProjection } from './activity-tab-projection' import { buildAgentPaneThreads, createAgentPaneThreadReuseCache } from './activity-thread-builder' import { collectChildAgentPaneKeys } from './activity-thread-child-agent' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment +} from '@/components/sidebar/workspace-creator-visibility' const EMPTY_PANE_KEYS: ReadonlySet = new Set() import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter' @@ -77,6 +81,25 @@ export function useAgentPaneThreads(args: { const { query, readFilter, groupBy, selectedPaneKey, showChildAgents = false } = args const agentsVisibleHostIds = useAppStore((s) => s.agentsVisibleHostIds) const agentsFilterRepoIds = useAppStore((s) => s.agentsFilterRepoIds) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.agentsHideWorkspacesFromOtherDevices) + const hideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.agentsHideAutomationGeneratedWorkspaces + ) + const hideCliCreatedWorkspaces = useAppStore((s) => s.agentsHideCliCreatedWorkspaces) + // Why gated selectors: runtime status churns, and only the other-device filter reads it. + const runtimeEnvironments = useAppStore((s) => + s.agentsHideWorkspacesFromOtherDevices ? s.runtimeEnvironments : null + ) + const runtimeStatusByEnvironmentId = useAppStore((s) => + s.agentsHideWorkspacesFromOtherDevices ? s.runtimeStatusByEnvironmentId : null + ) + const pairedDeviceIdsByEnvironment = useMemo( + () => + runtimeEnvironments && runtimeStatusByEnvironmentId + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [runtimeEnvironments, runtimeStatusByEnvironmentId] + ) // Why project: the unified tab map is rewritten on every tab focus; the projection keeps // its identity (and each tab's) unless a field this pipeline reads actually changed. const tabProjectionRef = useRef<{ @@ -173,7 +196,7 @@ export function useAgentPaneThreads(args: { selectedPaneKey === null || allThreads.some((thread) => thread.paneKey === selectedPaneKey) const effectiveSelectedPaneKey = selectedPaneKeyIsLive ? selectedPaneKey : null - // Why scope runs before the per-view filters: host/project scope must stay separate + // Why scope runs before the per-view filters: host/project/origin scope must stay separate // from unread/search narrowing. const { threads: scopeVisibleThreads } = useMemo( () => @@ -182,7 +205,11 @@ export function useAgentPaneThreads(args: { scope: { visibleHostIds: agentsVisibleHostIds, filterRepoIds: resolveActivityScopeRepoIds(agentsFilterRepoIds, storeData.repoMap), - defaultHostId: storeData.defaultHostId + defaultHostId: storeData.defaultHostId, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces }, exemptPaneKey: effectiveSelectedPaneKey }), @@ -192,6 +219,10 @@ export function useAgentPaneThreads(args: { agentsFilterRepoIds, storeData.repoMap, storeData.defaultHostId, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, effectiveSelectedPaneKey ] ) diff --git a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx index 44cd588be5b..d68d35e36fb 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.test.tsx @@ -270,7 +270,7 @@ describe('SidebarHeader', () => { ) }) - it('drops both project actions in the agents view, which lists activity, not projects', () => { + it('swaps only the options slot in the agents view so no header button shifts', () => { mockState.sidebarBody = 'agents' act(() => { root.render() @@ -279,7 +279,7 @@ describe('SidebarHeader', () => { expect(container.querySelector('[aria-label="Turn off activity view"]')).toBeTruthy() expect(container.querySelector('[aria-label="New workspace"]')).toBeTruthy() expect(container.querySelector('[aria-label="Workspace options"]')).toBeNull() - expect(container.querySelector('[aria-label="Add project"]')).toBeNull() + expect(container.querySelector('[aria-label="Add project"]')).toBeTruthy() }) it('keeps the activity bell and actions on one row at the default sidebar width', () => { diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 9f230e94f95..c423a978861 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import { OptionsFilterCountBadge } from './options-filter-count-badge' import { useWorkspaceOptionsFilterBadge, WorkspaceOptionsMenuItems @@ -58,16 +59,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined} > - {hasAnyFilter && ( - // Why: this combined options button now owns filtering, so it - // needs the same at-a-glance signal that the old filter button had. - - {activeFilterCount > 9 ? '9+' : activeFilterCount} - - )} + diff --git a/src/renderer/src/components/sidebar/options-filter-count-badge.tsx b/src/renderer/src/components/sidebar/options-filter-count-badge.tsx new file mode 100644 index 00000000000..ad835d5698b --- /dev/null +++ b/src/renderer/src/components/sidebar/options-filter-count-badge.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +/** Pluralized "N filter(s)" used in the options triggers' tooltip and aria label. */ +export function formatActiveFilterLabel(count: number): string { + return `${count} ${count === 1 ? 'filter' : 'filters'}` +} + +/** Active-filter count on a sidebar options trigger (the trigger must be `relative`). Shared so + * the workspace and activity headers signal filtering identically. */ +export function OptionsFilterCountBadge({ count }: { count: number }): React.JSX.Element | null { + if (count === 0) { + return null + } + return ( + + {count > 9 ? '9+' : count} + + ) +} diff --git a/src/renderer/src/components/sidebar/sidebar-header-actions.tsx b/src/renderer/src/components/sidebar/sidebar-header-actions.tsx index 994c26eb88d..d673745d251 100644 --- a/src/renderer/src/components/sidebar/sidebar-header-actions.tsx +++ b/src/renderer/src/components/sidebar/sidebar-header-actions.tsx @@ -88,16 +88,15 @@ export function SidebarHeaderActions({ }): React.JSX.Element { return (
- {/* Why both hidden in the agents view: it lists activity, not projects. */} + {/* Why only options swap: the activity view portals its own options button into this slot + (SidebarHeader), and keeping Add project means no header button shifts between views. */} {agentsViewActive ? null : ( - <> - - - + )} +
) diff --git a/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx b/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx index c4318449bee..5b0ad23e502 100644 --- a/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx +++ b/src/renderer/src/components/sidebar/workspace-options-menu-items.tsx @@ -21,6 +21,7 @@ import { PROJECT_ORDER_OPTIONS, SORT_OPTIONS } from './sidebar-workspace-option- import { WorktreeCardDisplayMenuSection } from './WorktreeCardDisplayMenuSection' import { translate } from '@/i18n/i18n' import { SidebarGroupByToggle } from './SidebarGroupByToggle' +import { formatActiveFilterLabel } from './options-filter-count-badge' export function useWorkspaceOptionsFilterBadge(): { hasAnyFilter: boolean @@ -79,7 +80,7 @@ export function useWorkspaceOptionsFilterBadge(): { return { hasAnyFilter, activeFilterCount, - activeFilterLabel: `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` + activeFilterLabel: formatActiveFilterLabel(activeFilterCount) } } diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index 40fc7dd7bea..5fa36fe1ebe 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -59,7 +59,7 @@ function ContextMenuSubContent({ { skipRestoreFocusRef.current = true + // Why switch: picking a project row explicitly asks to see it in the workspace list. + useAppStore.getState().setSidebarBody('workspaces') revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true }) recordFeatureInteraction('cmd-j') closeModal() diff --git a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts index 496f885ebbb..959f00d4a30 100644 --- a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts @@ -95,6 +95,8 @@ export function registerWorkspaceShortcutIpcBridge(unsubs: (() => void)[]): void if (target) { activateAndRevealWorkspace(target.id, { navigationIntent: 'user-open', + // Why: the digit addresses a workspace-list row, so show the list it acted on. + showWorkspaceList: true, ...(target.executionHostId ? { executionHostId: target.executionHostId } : {}) }) } diff --git a/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts b/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts index ae61b9adcb9..ff78eb065f6 100644 --- a/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts @@ -53,7 +53,8 @@ describe('useIpcEvents digit-chord routing while Cmd+J is open', () => { expect(rowJumps).toEqual([]) // Why assert the activation and not just the silent bus: a premature return would also emit nothing. expect(harness.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-c', { - navigationIntent: 'user-open' + navigationIntent: 'user-open', + showWorkspaceList: true }) }) @@ -70,6 +71,7 @@ describe('useIpcEvents digit-chord routing while Cmd+J is open', () => { expect(harness.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::path', { navigationIntent: 'user-open', + showWorkspaceList: true, executionHostId: 'ssh:box' }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 40c50ff3b5c..4588ebee405 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16907,7 +16907,6 @@ "showUnreadOnly": "Show unread only", "showChildAgents": "Show child agents", "activityOptions": "Activity options", - "threadListOptionsFiltered": "Thread list options, filters active", "showSearch": "Show search", "interrupted": "Interrupted", "state": { @@ -16923,7 +16922,9 @@ "permission": "Needs attention" }, "filtersSection": "Filters", - "viewSection": "View" + "viewSection": "View", + "threadListOptionsActive": "Thread list options ({{value0}} active)", + "activityOptionsActive": "Activity options ({{value0}})" }, "clearCompleted": { "clearedOne": "Cleared 1 completed agent", @@ -16941,7 +16942,11 @@ }, "ActivityScopeFilterControls": { "hiddenCount": "{{value0}} hidden", - "resetScope": "Show all hosts and projects" + "resetScope": "Show all hosts and projects", + "hideAutomationCreated": "Hide automation-created", + "hideCliCreated": "Hide CLI-created", + "hideOtherClientsAria": "Hide agents in workspaces created from other Orca clients on shared remote servers", + "hideOtherClients": "Hide other-client agents" }, "ActivityThreadHoverCard": { "pathCopied": "Path copied to clipboard", @@ -16952,6 +16957,16 @@ }, "ActivityThreadRow": { "clearNotification": "Clear notification" + }, + "ActivityThreadContextMenu": { + "copyTitle": "Copy Title", + "copyPath": "Copy Path", + "open": "Open", + "markRead": "Mark Read", + "markUnread": "Mark Unread", + "goToWorkspace": "Go to Workspace", + "clear": "Clear from List", + "agentSection": "Agent" } }, "confirmation": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0d836852f59..6d2694fdfce 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16672,7 +16672,6 @@ "770d458144": "Agrupar por", "795cbf26e2": "Filtrar...", "4616ea39fd": "Ir al workspace", - "threadListOptionsFiltered": "Opciones de lista de hilos, filtros activos", "showSearch": "Mostrar búsqueda", "markThreadRead": "Marcar hilo como leído", "59b131fbd9": "Marcar hilo como no leído", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0b7490532bb..6a7ecc60d85 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16775,7 +16775,6 @@ "770d458144": "Grouper l'activité des agents par", "795cbf26e2": "Filtrer...", "4616ea39fd": "Aller à l'espace de travail", - "threadListOptionsFiltered": "Options de la liste des fils, filtres actifs", "showSearch": "Afficher la recherche", "showUnreadOnly": "Afficher uniquement les fils non lus", "showChildAgents": "Afficher les agents enfants", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f83c99f8972..10b6de6e546 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16672,7 +16672,6 @@ "770d458144": "グループ化", "795cbf26e2": "フィルター…", "4616ea39fd": "ワークスペースにジャンプ", - "threadListOptionsFiltered": "スレッドリストのオプション、フィルターが有効", "showSearch": "検索を表示", "markThreadRead": "スレッドを既読としてマーク", "59b131fbd9": "スレッドを未読としてマークする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 27247862e4b..346317e1b17 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16672,7 +16672,6 @@ "770d458144": "그룹화 기준", "795cbf26e2": "필터...", "4616ea39fd": "워크스페이스로 이동", - "threadListOptionsFiltered": "스레드 목록 옵션, 필터 활성화됨", "showSearch": "검색 표시", "markThreadRead": "스레드를 읽은 것으로 표시", "59b131fbd9": "스레드를 읽지 않은 것으로 표시", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 63b7188d2a1..31deaef11e1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16672,7 +16672,6 @@ "770d458144": "分组方式", "795cbf26e2": "筛选...", "4616ea39fd": "跳转到工作区", - "threadListOptionsFiltered": "线程列表选项,筛选器已启用", "showSearch": "显示搜索", "markThreadRead": "将话题标记为已读", "59b131fbd9": "将话题标记为未读", diff --git a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts index 9e07ee425aa..8ddc5ff4225 100644 --- a/src/renderer/src/lib/worktree-activation-automation-filter.test.ts +++ b/src/renderer/src/lib/worktree-activation-automation-filter.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Worktree } from '../../../shared/worktree/types' import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from './worktree-activation' +import { activateAndRevealWorkspace, activateAndRevealWorktree } from './worktree-activation' const initialAppStoreState = useAppStore.getState() @@ -44,40 +44,107 @@ function makeAutomationWorktree(): Worktree { } } +function seedAutomationWorktreeState( + worktree: Worktree, + overrides: Partial> = {} +): void { + useAppStore.setState({ + repos: [ + { + id: worktree.repoId, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + activeWorktreeId: worktree.id, + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabsByWorktree: { [worktree.id]: [] }, + ptyIdsByTabId: {}, + everActivatedWorktreeIds: new Set([worktree.id]), + hideAutomationGeneratedWorkspaces: true, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + ...overrides + }) +} + describe('activateAndRevealWorktree automation filters', () => { it('clears the automation-generated filter before revealing an automation-created worktree', () => { const worktree = makeAutomationWorktree() const revealWorktreeInSidebar = vi.fn() - - useAppStore.setState({ - repos: [ - { - id: worktree.repoId, - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0 - } - ], - worktreesByRepo: { [worktree.repoId]: [worktree] }, - activeRepoId: worktree.repoId, - activeView: 'terminal', - activeWorktreeId: worktree.id, - activeTabId: 'tab-1', - activeTabType: 'terminal', - tabsByWorktree: { [worktree.id]: [] }, - ptyIdsByTabId: {}, - everActivatedWorktreeIds: new Set([worktree.id]), - hideAutomationGeneratedWorkspaces: true, - markWorktreeVisited: vi.fn(), - recordWorktreeVisit: vi.fn(), - refreshGitHubForWorktreeIfStale: vi.fn(), - revealWorktreeInSidebar - }) + seedAutomationWorktreeState(worktree, { revealWorktreeInSidebar }) activateAndRevealWorktree(worktree.id) expect(useAppStore.getState().hideAutomationGeneratedWorkspaces).toBe(false) expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) }) + + it('keeps workspace-list filters while the activity view is showing', () => { + const worktree = makeAutomationWorktree() + seedAutomationWorktreeState(worktree, { + sidebarBody: 'agents', + filterRepoIds: ['other-repo'] + }) + + activateAndRevealWorktree(worktree.id) + + const state = useAppStore.getState() + expect(state.hideAutomationGeneratedWorkspaces).toBe(true) + expect(state.filterRepoIds).toEqual(['other-repo']) + expect(state.sidebarBody).toBe('agents') + expect(state.pendingRevealWorktree).toBeNull() + }) + + it('leaves the activity view to lift filters and reveal when the caller asks for the list', () => { + const worktree = makeAutomationWorktree() + seedAutomationWorktreeState(worktree, { sidebarBody: 'agents' }) + + activateAndRevealWorktree(worktree.id, { showWorkspaceList: true }) + + const state = useAppStore.getState() + expect(state.sidebarBody).toBe('workspaces') + expect(state.hideAutomationGeneratedWorkspaces).toBe(false) + expect(state.pendingRevealWorktree?.worktreeId).toBe(worktree.id) + }) + + it('stays in the activity view when a blocked folder activation fails', () => { + const getFreshFolderWorkspacePathStatus = vi.fn(() => ({ + path: '/gone', + exists: false, + reason: 'missing' as const + })) + useAppStore.setState({ + sidebarBody: 'agents', + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'gone', + folderPath: '/gone', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + ], + getFreshFolderWorkspacePathStatus + }) + + expect(activateAndRevealWorkspace('folder:folder-1', { showWorkspaceList: true })).toBe(false) + expect(getFreshFolderWorkspacePathStatus).toHaveBeenCalled() + expect(useAppStore.getState().sidebarBody).toBe('agents') + }) }) diff --git a/src/renderer/src/lib/worktree-activation-sidebar-filters.ts b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts new file mode 100644 index 00000000000..06c934bb4bd --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts @@ -0,0 +1,26 @@ +import type { Worktree } from '../../../shared/worktree/types' +import { useAppStore } from '@/store' +import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' +import { revealRepoInProjectFilter } from '@/components/sidebar/project-filter-reveal' + +/** Lifts the workspace-list filters hiding an activated worktree; its reveal needs the card rendered, else it silently no-ops. */ +export function liftSidebarFiltersHidingWorktree(wt: Worktree): void { + const state = useAppStore.getState() + // Not in the activity view: the reveal is skipped there, so lifting would only discard filters. + if (state.sidebarBody === 'agents') { + return + } + revealRepoInProjectFilter(state, wt.repoId) + if ( + state.hideAutomationGeneratedWorkspaces && + wt.automationProvenance?.kind === 'created-by-automation' + ) { + state.setHideAutomationGeneratedWorkspaces(false) + } + if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') { + state.setHideCliCreatedWorkspaces(false) + } + if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(wt)) { + state.setHideDetachedHeadWorkspaces(false) + } +} diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts index 9b78a23cd09..566ad5c3e69 100644 --- a/src/renderer/src/lib/worktree-activation-surface-selection.ts +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -26,6 +26,8 @@ export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & { sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] notifyHostRuntime?: boolean revealInSidebar?: boolean + /** Explicit request to see the workspace in the list: leave the activity view once activation commits. */ + showWorkspaceList?: boolean executionHostId?: ExecutionHostId backendStartupTerminalSpawned?: boolean /** Install a preserved fallback startup beside setup/default terminals already seeded. */ diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index f63b1189097..d8351a9f27a 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -18,8 +18,6 @@ import { getFolderWorkspacePathStatusTitle } from './folder-workspace-path-status' import { toast } from 'sonner' -import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees' -import { revealRepoInProjectFilter } from '@/components/sidebar/project-filter-reveal' import type { ExecutionHostId } from '../../../shared/execution-host' import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' @@ -33,6 +31,7 @@ import { type WorktreeActivationSurfaceSelection } from './worktree-activation-surface-selection' import { gateAndReseedEmptyWorkspace } from './worktree-activation-gated-empty-reseed' +import { liftSidebarFiltersHidingWorktree } from './worktree-activation-sidebar-filters' /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. @@ -84,6 +83,7 @@ export function activateAndRevealFolderWorkspace( opts?: WorktreeActivationSurfaceSelection & { sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] revealInSidebar?: boolean + showWorkspaceList?: boolean startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null executionHostId?: ExecutionHostId @@ -165,6 +165,9 @@ export function activateAndRevealFolderWorkspace( seedUserDefaultSurface ) + if (opts?.showWorkspaceList) { + state.setSidebarBody('workspaces') + } if (opts?.revealInSidebar !== false) { state.revealWorktreeInSidebar( workspaceKey, @@ -286,21 +289,12 @@ export function activateAndRevealWorktree( useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) } - // 5. Lift the sidebar filters hiding the target — reveal needs the card rendered, else it silently no-ops. + // 5. Explicit list requests leave the activity view only now that activation has committed. + if (opts?.showWorkspaceList) { + state.setSidebarBody('workspaces') + } if (opts?.clearSidebarFilters !== false) { - revealRepoInProjectFilter(state, wt.repoId) - if ( - state.hideAutomationGeneratedWorkspaces && - wt.automationProvenance?.kind === 'created-by-automation' - ) { - state.setHideAutomationGeneratedWorkspaces(false) - } - if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') { - state.setHideCliCreatedWorkspaces(false) - } - if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(wt)) { - state.setHideDetachedHeadWorkspaces(false) - } + liftSidebarFiltersHidingWorktree(wt) } // 6. Reveal in sidebar @@ -340,6 +334,7 @@ export function activateAndRevealWorkspace( opts?: WorktreeActivationSurfaceSelection & { executionHostId?: ExecutionHostId revealInSidebar?: boolean + showWorkspaceList?: boolean /** Worktree-only: folder workspaces are never filter-hidden. */ clearSidebarFilters?: boolean } diff --git a/src/renderer/src/lib/worktree-jump-navigation.test.ts b/src/renderer/src/lib/worktree-jump-navigation.test.ts index af8072ac9c4..18e0e45d114 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.test.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.test.ts @@ -29,8 +29,6 @@ describe('worktree jump navigation', () => { mocks.getVisibleWorktreeShortcutTargets.mockReturnValue([]) mocks.worktreePassesSidebarFilters.mockReturnValue(false) mocks.getState.mockReturnValue({ - sidebarBody: 'agents', - setSidebarBody: vi.fn(), worktreesByRepo: { repo: [] }, showSleepingWorkspaces: true, filterRepoIds: ['other-repo'], @@ -58,18 +56,17 @@ describe('worktree jump navigation', () => { expect(mocks.worktreePassesSidebarFilters).not.toHaveBeenCalled() expect(mocks.warning).not.toHaveBeenCalled() expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/deleted', { - navigationIntent: 'user-open' + navigationIntent: 'user-open', + showWorkspaceList: true }) }) it('switches the left sidebar to Spaces and warns when filters hide the target', () => { - const state = mocks.getState() - expect(jumpToWorktreeFromSidebar('repo::/target')).toBe(true) - expect(state.setSidebarBody).toHaveBeenCalledWith('workspaces') expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/target', { navigationIntent: 'user-open', + showWorkspaceList: true, revealInSidebar: false, clearSidebarFilters: false }) @@ -102,7 +99,8 @@ describe('worktree jump navigation', () => { expect(jumpToWorktreeFromSidebar('wt-collapsed')).toBe(true) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-collapsed', { - navigationIntent: 'user-open' + navigationIntent: 'user-open', + showWorkspaceList: true }) expect(mocks.warning).not.toHaveBeenCalled() }) @@ -127,6 +125,7 @@ describe('worktree jump navigation', () => { expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('repo::/target', { navigationIntent: 'user-open', + showWorkspaceList: true, revealInSidebar: false, clearSidebarFilters: false, executionHostId: 'ssh:beta' @@ -135,24 +134,20 @@ describe('worktree jump navigation', () => { }) it('routes folder workspaces through the workspace dispatcher without a filter check', () => { - const state = mocks.getState() - expect(jumpToWorktreeFromSidebar('folder:folder-1', { executionHostId: 'local' })).toBe(true) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('folder:folder-1', { navigationIntent: 'user-open', + showWorkspaceList: true, executionHostId: 'local' }) // Folder workspaces never get the filter-hidden treatment. expect(mocks.worktreePassesSidebarFilters).not.toHaveBeenCalled() - expect(state.setSidebarBody).toHaveBeenCalledWith('workspaces') }) it('propagates a blocked folder-workspace activation as failure', () => { - const state = mocks.getState() mocks.activateAndRevealWorkspace.mockReturnValue(false) expect(jumpToWorktreeFromSidebar('folder:folder-1')).toBe(false) - expect(state.setSidebarBody).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/worktree-jump-navigation.ts b/src/renderer/src/lib/worktree-jump-navigation.ts index 38721b3337b..cb3685c94b9 100644 --- a/src/renderer/src/lib/worktree-jump-navigation.ts +++ b/src/renderer/src/lib/worktree-jump-navigation.ts @@ -51,8 +51,6 @@ export function jumpToWorktreeFromSidebar( worktreeId: string, options?: { executionHostId?: ExecutionHostId } ): boolean { - const state = useAppStore.getState() - // Folder workspaces aren't in the worktree filter pipeline; only git worktrees can be filter-hidden. const hiddenBeforeActivation = parseWorkspaceKey(worktreeId)?.type !== 'folder' && @@ -61,6 +59,7 @@ export function jumpToWorktreeFromSidebar( // Why the workspace dispatcher: it owns the folder-vs-worktree split and the folder path-status gate. const activated = activateAndRevealWorkspace(worktreeId, { navigationIntent: 'user-open', + showWorkspaceList: true, ...(hiddenBeforeActivation ? { revealInSidebar: false, clearSidebarFilters: false } : {}), ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}) }) @@ -68,9 +67,6 @@ export function jumpToWorktreeFromSidebar( return false } - // The worktree list is the Spaces/Projects sidebar body; jump actions should always expose it. - state.setSidebarBody?.('workspaces') - const hiddenAfterActivation = hiddenBeforeActivation && wasHiddenBySidebarFilters(worktreeId, options?.executionHostId) if (hiddenBeforeActivation && !hiddenAfterActivation) { diff --git a/src/renderer/src/store/slices/ui-agent-send-target.test.ts b/src/renderer/src/store/slices/ui-agent-send-target.test.ts index 6d49bb1baa5..071d83ff1cc 100644 --- a/src/renderer/src/store/slices/ui-agent-send-target.test.ts +++ b/src/renderer/src/store/slices/ui-agent-send-target.test.ts @@ -197,6 +197,24 @@ describe('createUISlice agent send target mode', () => { }) }) + it('switches out of the activity view because send targets render on workspace cards', () => { + const store = createAgentSendStore() + seedAgentSendState(store) + store.getState().setSidebarBody('agents') + + store.getState().openAgentSendPopoverTargetMode({ + id: 'send-1', + worktreeId, + source: 'diff-notes', + prompt: 'Review this', + label: 'All unsent notes', + launchSource: 'notes_send' + }) + + expect(store.getState().sidebarBody).toBe('workspaces') + expect(store.getState().pendingRevealWorktree).toMatchObject({ worktreeId }) + }) + it('disables sidebar target rows that need permission', async () => { const store = createAgentSendStore() seedAgentSendState(store) diff --git a/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts b/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts index c6219d1be5c..fd01ee2d2a7 100644 --- a/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-view-layout.test.ts @@ -50,17 +50,28 @@ beforeEach(() => { }) describe('sidebar reveal actions', () => { - it('switch the sidebar body back to Spaces so the worktree list can consume the reveal', () => { + it('skip reveals while the activity view is showing instead of switching bodies', () => { const store = createUIStore() store.getState().setSidebarBody('agents') store.getState().revealWorktreeInSidebar('wt-1', { highlight: true }) + store.getState().revealSidebarRow('repo:r1') + + expect(store.getState().sidebarBody).toBe('agents') + expect(store.getState().pendingRevealWorktree).toBeNull() + expect(store.getState().pendingRevealSidebarRow).toBeNull() + }) + + it('reveal after an explicit switch to the workspace list', () => { + const store = createUIStore() + store.getState().setSidebarBody('agents') + + store.getState().setSidebarBody('workspaces') + store.getState().revealWorktreeInSidebar('wt-1', { highlight: true }) + store.getState().revealSidebarRow('repo:r1') + expect(store.getState().sidebarBody).toBe('workspaces') expect(store.getState().pendingRevealWorktree?.worktreeId).toBe('wt-1') - - store.getState().setSidebarBody('agents') - store.getState().revealSidebarRow('repo:r1') - expect(store.getState().sidebarBody).toBe('workspaces') expect(store.getState().pendingRevealSidebarRow?.rowKey).toBe('repo:r1') }) }) diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 815ae36b523..8a9e627788a 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -557,6 +557,9 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().agentsVisibleHostIds).toBeNull() expect(store.getState().agentsFilterRepoIds).toEqual([]) + expect(store.getState().agentsHideWorkspacesFromOtherDevices).toBe(false) + expect(store.getState().agentsHideAutomationGeneratedWorkspaces).toBe(false) + expect(store.getState().agentsHideCliCreatedWorkspaces).toBe(false) expect(store.getState().agentsShowChildAgents).toBe(false) expect(store.getState().agentsCompactMode).toBe(true) expect(store.getState().agentsShowSearch).toBe(true) @@ -564,6 +567,24 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().agentsGroupBy).toBe('status') }) + it('restores the agents workspace-origin filters independently of the workspace-nav ones', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true, + hideCliCreatedWorkspaces: false + }) + ) + + expect(store.getState().agentsHideWorkspacesFromOtherDevices).toBe(true) + expect(store.getState().agentsHideAutomationGeneratedWorkspaces).toBe(true) + expect(store.getState().agentsHideCliCreatedWorkspaces).toBe(true) + expect(store.getState().hideCliCreatedWorkspaces).toBe(false) + }) + it('restores a hidden agents search field', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts b/src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts new file mode 100644 index 00000000000..1935eea6f8c --- /dev/null +++ b/src/renderer/src/store/slices/ui/sidebar-body-writers.test.ts @@ -0,0 +1,65 @@ +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Leaving the activity view is a user decision: the bell toggles the body, and only explicit + * "show me this in the workspace list" requests switch it (activation only via showWorkspaceList). + * Reveals must never write sidebarBody, or every incidental activation kicks the user out. + */ +const RENDERER_ROOT = path.resolve(import.meta.dirname, '../../..') + +const SLICE_DEFINITION_FILES = [ + 'store/slices/ui/ui-slice-contract-preferences.ts', + 'store/slices/ui/ui-slice-preference-actions.ts' +] + +const BODY_WRITER_FILES = [ + // The bell toggle. + 'components/sidebar/SidebarHeader.tsx', + // Explicit workspace-list requests. + 'components/sidebar/use-workspace-reveal-body-redirect.ts', + 'components/use-worktree-jump-palette-selection-actions.ts', + 'lib/worktree-activation.ts', + 'store/slices/ui/ui-slice-agent-actions.ts' +] + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') { + collectSourceFiles(full, out) + } + continue + } + if (/\.tsx?$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name)) { + out.push(full) + } + } + return out +} + +function filesMatching(pattern: RegExp): string[] { + return ( + collectSourceFiles(RENDERER_ROOT) + .map((file) => ({ file, source: readFileSync(file, 'utf8') })) + // Test harnesses stub the actions without being callers. + .filter(({ source }) => pattern.test(source) && !source.includes("from 'vitest'")) + .map(({ file }) => file) + .map((file) => path.relative(RENDERER_ROOT, file).split(path.sep).join('/')) + .sort() + ) +} + +describe('sidebarBody writers', () => { + it('only the slice defines sidebarBody state writes', () => { + expect(filesMatching(/\bsidebarBody\s*:/)).toEqual(SLICE_DEFINITION_FILES) + }) + + it('only the bell toggle and explicit workspace-list requests call setSidebarBody', () => { + expect(filesMatching(/\bsetSidebarBody\b/)).toEqual( + [...SLICE_DEFINITION_FILES, ...BODY_WRITER_FILES].sort() + ) + }) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts index 7af82053993..6c0ba2a5b78 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -76,6 +76,8 @@ export function createUiAgentActions( targets.some((target) => target.status === 'eligible') && (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) ) { + // Why switch: the send targets render on workspace cards, not activity rows. + get().setSidebarBody('workspaces') get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) } }, diff --git a/src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts new file mode 100644 index 00000000000..098fd5c455f --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-agents-view-preference-actions.ts @@ -0,0 +1,63 @@ +import type { UISlice, UISliceSet } from './ui-slice-contract' +import { + DEFAULT_AGENTS_GROUP_BY, + DEFAULT_AGENTS_READ_FILTER +} from '../../../../../shared/agents-view-thread-filters' +import { normalizeVisibleExecutionHostIds } from '../../../../../shared/execution-host' + +/** Agents-view preferences; each setter persists immediately and stays separate from workspace-nav state. */ +export function createAgentsViewPreferenceActions(set: UISliceSet): Partial { + return { + agentsVisibleHostIds: null, + setAgentsVisibleHostIds: (ids) => { + const agentsVisibleHostIds = normalizeVisibleExecutionHostIds(ids) + set({ agentsVisibleHostIds }) + window.api.ui.set({ agentsVisibleHostIds }).catch(console.error) + }, + agentsFilterRepoIds: [], + setAgentsFilterRepoIds: (ids) => { + set({ agentsFilterRepoIds: ids }) + window.api.ui.set({ agentsFilterRepoIds: [...ids] }).catch(console.error) + }, + agentsHideWorkspacesFromOtherDevices: false, + setAgentsHideWorkspacesFromOtherDevices: (v) => { + set({ agentsHideWorkspacesFromOtherDevices: v }) + window.api.ui.set({ agentsHideWorkspacesFromOtherDevices: v }).catch(console.error) + }, + agentsHideAutomationGeneratedWorkspaces: false, + setAgentsHideAutomationGeneratedWorkspaces: (v) => { + set({ agentsHideAutomationGeneratedWorkspaces: v }) + window.api.ui.set({ agentsHideAutomationGeneratedWorkspaces: v }).catch(console.error) + }, + agentsHideCliCreatedWorkspaces: false, + setAgentsHideCliCreatedWorkspaces: (v) => { + set({ agentsHideCliCreatedWorkspaces: v }) + window.api.ui.set({ agentsHideCliCreatedWorkspaces: v }).catch(console.error) + }, + agentsShowChildAgents: false, + setAgentsShowChildAgents: (v) => { + set({ agentsShowChildAgents: v }) + window.api.ui.set({ agentsShowChildAgents: v }).catch(console.error) + }, + agentsCompactMode: true, + setAgentsCompactMode: (v) => { + set({ agentsCompactMode: v }) + window.api.ui.set({ agentsCompactMode: v }).catch(console.error) + }, + agentsShowSearch: true, + setAgentsShowSearch: (v) => { + set({ agentsShowSearch: v }) + window.api.ui.set({ agentsShowSearch: v }).catch(console.error) + }, + agentsReadFilter: DEFAULT_AGENTS_READ_FILTER, + setAgentsReadFilter: (v) => { + set({ agentsReadFilter: v }) + window.api.ui.set({ agentsReadFilter: v }).catch(console.error) + }, + agentsGroupBy: DEFAULT_AGENTS_GROUP_BY, + setAgentsGroupBy: (v) => { + set({ agentsGroupBy: v }) + window.api.ui.set({ agentsGroupBy: v }).catch(console.error) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index 085dd44c2b3..e8b9ff8eec8 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -71,6 +71,12 @@ export type UISlicePreferences = { setAgentsVisibleHostIds: (ids: VisibleWorkspaceHostIds) => void agentsFilterRepoIds: readonly string[] setAgentsFilterRepoIds: (ids: readonly string[]) => void + agentsHideWorkspacesFromOtherDevices: boolean + setAgentsHideWorkspacesFromOtherDevices: (v: boolean) => void + agentsHideAutomationGeneratedWorkspaces: boolean + setAgentsHideAutomationGeneratedWorkspaces: (v: boolean) => void + agentsHideCliCreatedWorkspaces: boolean + setAgentsHideCliCreatedWorkspaces: (v: boolean) => void agentsShowChildAgents: boolean setAgentsShowChildAgents: (v: boolean) => void agentsCompactMode: boolean diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index dea5b0c2e80..c8da9a29dcc 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -157,6 +157,10 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par ? persistedAgentsFilterRepoIds : persistedAgentsFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)) ), + agentsHideWorkspacesFromOtherDevices: ui.agentsHideWorkspacesFromOtherDevices === true, + agentsHideAutomationGeneratedWorkspaces: + ui.agentsHideAutomationGeneratedWorkspaces === true, + agentsHideCliCreatedWorkspaces: ui.agentsHideCliCreatedWorkspaces === true, agentsShowChildAgents: ui.agentsShowChildAgents === true, agentsCompactMode: ui.agentsCompactMode !== false, agentsShowSearch: ui.agentsShowSearch !== false, diff --git a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts index 205a40acf9f..27a30efbc53 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts @@ -1,8 +1,5 @@ import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' -import { - DEFAULT_AGENTS_GROUP_BY, - DEFAULT_AGENTS_READ_FILTER -} from '../../../../../shared/agents-view-thread-filters' +import { createAgentsViewPreferenceActions } from './ui-slice-agents-view-preference-actions' import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, DEFAULT_SHOW_SLEEPING_WORKSPACES, @@ -167,42 +164,7 @@ export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Par filterRepoIds: [], setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), - agentsVisibleHostIds: null, - setAgentsVisibleHostIds: (ids) => { - const agentsVisibleHostIds = normalizeVisibleExecutionHostIds(ids) - set({ agentsVisibleHostIds }) - window.api.ui.set({ agentsVisibleHostIds }).catch(console.error) - }, - agentsFilterRepoIds: [], - setAgentsFilterRepoIds: (ids) => { - set({ agentsFilterRepoIds: ids }) - window.api.ui.set({ agentsFilterRepoIds: [...ids] }).catch(console.error) - }, - agentsShowChildAgents: false, - setAgentsShowChildAgents: (v) => { - set({ agentsShowChildAgents: v }) - window.api.ui.set({ agentsShowChildAgents: v }).catch(console.error) - }, - agentsCompactMode: true, - setAgentsCompactMode: (v) => { - set({ agentsCompactMode: v }) - window.api.ui.set({ agentsCompactMode: v }).catch(console.error) - }, - agentsShowSearch: true, - setAgentsShowSearch: (v) => { - set({ agentsShowSearch: v }) - window.api.ui.set({ agentsShowSearch: v }).catch(console.error) - }, - agentsReadFilter: DEFAULT_AGENTS_READ_FILTER, - setAgentsReadFilter: (v) => { - set({ agentsReadFilter: v }) - window.api.ui.set({ agentsReadFilter: v }).catch(console.error) - }, - agentsGroupBy: DEFAULT_AGENTS_GROUP_BY, - setAgentsGroupBy: (v) => { - set({ agentsGroupBy: v }) - window.api.ui.set({ agentsGroupBy: v }).catch(console.error) - }, + ...createAgentsViewPreferenceActions(set), collapsedGroups: new Set(), toggleCollapsedGroup: (key) => diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts index 7157a243d7a..40d55a16812 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts @@ -148,28 +148,34 @@ export function createUiSurfaceActions(set: UISliceSet, _get: UISliceGet): Parti pendingRevealWorktree: null, pendingRevealSidebarRow: null, - // Why sidebarBody here: the worktree list (and its reveal consumer) is unmounted while the - // Agents body is showing, so a reveal that does not switch bodies silently no-ops. + // Why skipped rather than queued: the worktree list is unmounted in the activity view, and a + // reveal that switched bodies would kick the user out on every incidental activation. revealWorktreeInSidebar: (worktreeId, options) => - set({ - sidebarBody: 'workspaces', - pendingRevealWorktree: { - worktreeId, - ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight ? { highlight: true } : {}), - ...(options?.beginRename ? { beginRename: true } : {}) - } - }), + set((state) => + state.sidebarBody === 'agents' + ? state + : { + pendingRevealWorktree: { + worktreeId, + ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight ? { highlight: true } : {}), + ...(options?.beginRename ? { beginRename: true } : {}) + } + } + ), revealSidebarRow: (rowKey, options) => - set({ - sidebarBody: 'workspaces', - pendingRevealSidebarRow: { - rowKey, - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight === false ? {} : { highlight: true }) - } - }), + set((state) => + state.sidebarBody === 'agents' + ? state + : { + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + } + ), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), scrollToDiffCommentId: null, diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index fd6239ba269..37b57d0e401 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -71,6 +71,9 @@ export function mergeHostWebUIState( workspaceHostOrder: local.workspaceHostOrder, agentsVisibleHostIds: local.agentsVisibleHostIds, agentsFilterRepoIds: local.agentsFilterRepoIds, + agentsHideWorkspacesFromOtherDevices: local.agentsHideWorkspacesFromOtherDevices === true, + agentsHideAutomationGeneratedWorkspaces: local.agentsHideAutomationGeneratedWorkspaces === true, + agentsHideCliCreatedWorkspaces: local.agentsHideCliCreatedWorkspaces === true, agentsShowChildAgents: local.agentsShowChildAgents, agentsCompactMode: local.agentsCompactMode, agentsShowSearch: local.agentsShowSearch, diff --git a/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts b/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts new file mode 100644 index 00000000000..e1f9ede3818 --- /dev/null +++ b/src/renderer/src/web/web-preload-api-ui-pairing-local-fields.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PAIRING_LOCAL_UI_FIELDS, + type PairingLocalUiField +} from '../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +describe('web UI preload API pairing-local fields', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + // Census-driven, matching the host-side seam tests: a field added to PAIRING_LOCAL_UI_FIELDS + // without wiring the web read seam fails here rather than shipping. The host sample differs from + // the browser's for every field, so only the pin makes this pass. + const browserLocalUiSamples: Record = { + automationHostFilter: { kind: 'host', hostKey: 'browser-local-host-key' }, + hideWorkspacesFromOtherDevices: true, + manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], + workspaceHostOrder: ['runtime:web-env-1', 'local'], + agentsVisibleHostIds: ['runtime:web-env-1'], + agentsFilterRepoIds: ['repo-b'], + agentsHideWorkspacesFromOtherDevices: true, + agentsHideAutomationGeneratedWorkspaces: true, + agentsHideCliCreatedWorkspaces: true, + agentsShowChildAgents: true, + agentsCompactMode: false, + agentsShowSearch: false, + agentsReadFilter: 'unread', + agentsGroupBy: 'project', + activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 }, + manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 } + } + const hostUiSamples: Record = { + automationHostFilter: { kind: 'all' }, + hideWorkspacesFromOtherDevices: false, + manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], + workspaceHostOrder: ['local', 'ssh:box'], + agentsVisibleHostIds: ['local'], + agentsFilterRepoIds: ['repo-a'], + agentsHideWorkspacesFromOtherDevices: false, + agentsHideAutomationGeneratedWorkspaces: false, + agentsHideCliCreatedWorkspaces: false, + agentsShowChildAgents: false, + agentsCompactMode: true, + agentsShowSearch: true, + agentsReadFilter: 'all', + agentsGroupBy: 'status', + activityClearedAtByPaneKey: { 'tab-2:leaf-2': 456 }, + manuallyUnreadTurnsByPaneKey: { 'tab-2:leaf-2': 654 } + } + + it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( + 'keeps the browser-local %s and never sends it to the host', + async (field) => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { [field]: hostUiSamples[field] } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const browserLocal = { [field]: browserLocalUiSamples[field] } as Partial + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem('orca.web.ui.v1', JSON.stringify(browserLocal)) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.ui.set({ ...browserLocal, sidebarWidth: 280 }) + + expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) + await expect(globals.window.api.ui.get()).resolves.toMatchObject(browserLocal) + expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject( + browserLocal + ) + } + ) +}) diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index 751bf4e9bb6..649cf4d8b28 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -1,10 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FeatureInteractionState } from '../../../shared/feature-interactions' -import { - PAIRING_LOCAL_UI_FIELDS, - type PairingLocalUiField -} from '../../../shared/pairing-local-ui-fields' -import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { ManualRepoOrderEntry } from '../../../shared/ui-chrome-types' import { @@ -457,77 +452,6 @@ describe('web UI preload API', () => { }) }) - // Census-driven, matching the host-side seam tests: a field added to PAIRING_LOCAL_UI_FIELDS - // without wiring the web read seam fails here rather than shipping. The host sample differs from - // the browser's for every field, so only the pin makes this pass. - const browserLocalUiSamples: Record = { - automationHostFilter: { kind: 'host', hostKey: 'browser-local-host-key' }, - hideWorkspacesFromOtherDevices: true, - manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], - workspaceHostOrder: ['runtime:web-env-1', 'local'], - agentsVisibleHostIds: ['runtime:web-env-1'], - agentsFilterRepoIds: ['repo-b'], - agentsShowChildAgents: true, - agentsCompactMode: false, - agentsShowSearch: false, - agentsReadFilter: 'unread', - agentsGroupBy: 'project', - activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 }, - manuallyUnreadTurnsByPaneKey: { 'tab-1:leaf-1': 321 } - } - const hostUiSamples: Record = { - automationHostFilter: { kind: 'all' }, - hideWorkspacesFromOtherDevices: false, - manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], - workspaceHostOrder: ['local', 'ssh:box'], - agentsVisibleHostIds: ['local'], - agentsFilterRepoIds: ['repo-a'], - agentsShowChildAgents: false, - agentsCompactMode: true, - agentsShowSearch: true, - agentsReadFilter: 'all', - agentsGroupBy: 'status', - activityClearedAtByPaneKey: { 'tab-2:leaf-2': 456 }, - manuallyUnreadTurnsByPaneKey: { 'tab-2:leaf-2': 654 } - } - - it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( - 'keeps the browser-local %s and never sends it to the host', - async (field) => { - const runtimeCalls: { method: string; params: unknown }[] = [] - vi.doMock('./web-runtime-client', () => ({ - WebRuntimeClient: class { - call(method: string, params?: unknown): Promise> { - runtimeCalls.push({ method, params }) - return Promise.resolve({ - id: method, - ok: true, - result: { ui: { [field]: hostUiSamples[field] } }, - _meta: { runtimeId: 'runtime-1' } - }) - } - - close(): void {} - } - })) - - const browserLocal = { [field]: browserLocalUiSamples[field] } as Partial - const globals = installBrowserGlobals('Linux') - writeStoredRuntimeEnvironment(globals.storage) - globals.storage.setItem('orca.web.ui.v1', JSON.stringify(browserLocal)) - const { installWebPreloadApi } = await import('./web-preload-api') - installWebPreloadApi() - - await globals.window.api.ui.set({ ...browserLocal, sidebarWidth: 280 }) - - expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: { sidebarWidth: 280 } }) - await expect(globals.window.api.ui.get()).resolves.toMatchObject(browserLocal) - expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject( - browserLocal - ) - } - ) - it('union-merges local contextual tour seen ids when ui.get returns stale host state', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f26fe093676..2b3b82b5a37 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -222,6 +222,9 @@ export function getDefaultUIState(): PersistedUIState { filterRepoIds: [], agentsVisibleHostIds: null, agentsFilterRepoIds: [], + agentsHideWorkspacesFromOtherDevices: false, + agentsHideAutomationGeneratedWorkspaces: false, + agentsHideCliCreatedWorkspaces: false, agentsShowChildAgents: false, agentsCompactMode: true, agentsShowSearch: true, diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts index d3758c0246e..780b471a1ca 100644 --- a/src/shared/pairing-local-ui-fields.test.ts +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -12,6 +12,9 @@ describe('pairing-local UI fields', () => { 'workspaceHostOrder', 'agentsVisibleHostIds', 'agentsFilterRepoIds', + 'agentsHideWorkspacesFromOtherDevices', + 'agentsHideAutomationGeneratedWorkspaces', + 'agentsHideCliCreatedWorkspaces', 'agentsShowChildAgents', 'agentsCompactMode', 'agentsShowSearch', diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts index 60478018064..65e036699ac 100644 --- a/src/shared/pairing-local-ui-fields.ts +++ b/src/shared/pairing-local-ui-fields.ts @@ -15,6 +15,9 @@ export const PAIRING_LOCAL_UI_FIELDS = [ // Agent View filters and presentation belong to each client's host catalog and viewport. 'agentsVisibleHostIds', 'agentsFilterRepoIds', + 'agentsHideWorkspacesFromOtherDevices', + 'agentsHideAutomationGeneratedWorkspaces', + 'agentsHideCliCreatedWorkspaces', 'agentsShowChildAgents', 'agentsCompactMode', 'agentsShowSearch', diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 6eaa295eed4..f7708d50206 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -81,6 +81,10 @@ export type PersistedUIState = { agentsVisibleHostIds?: VisibleWorkspaceHostIds /** Agents-view project filter; empty = all projects. Separate from filterRepoIds (workspace nav). */ agentsFilterRepoIds?: string[] + /** Agents-view workspace-origin filters; separate from the workspace-nav hide flags. Absent means off. */ + agentsHideWorkspacesFromOtherDevices?: boolean + agentsHideAutomationGeneratedWorkspaces?: boolean + agentsHideCliCreatedWorkspaces?: boolean /** Agents-view: include child (orchestration-dispatched) agent threads. Absent means off. */ agentsShowChildAgents?: boolean /** Agents-view compact thread rows. Absent means on. */ diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index 0295247a2e8..08113208e23 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -145,6 +145,9 @@ export const UiUpdateFields = z visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(), agentsVisibleHostIds: z.array(z.string()).nullable().optional(), agentsFilterRepoIds: StringArray.optional(), + agentsHideWorkspacesFromOtherDevices: z.boolean().optional(), + agentsHideAutomationGeneratedWorkspaces: z.boolean().optional(), + agentsHideCliCreatedWorkspaces: z.boolean().optional(), agentsShowChildAgents: z.boolean().optional(), agentsCompactMode: z.boolean().optional(), agentsShowSearch: z.boolean().optional(),