From 7c970e5240fb75f1a89ceea43dffbad8de5e1f79 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:29:13 -0700 Subject: [PATCH 01/11] fix(native-chat): a goal set while the agent rests clears the composer Setting a goal starts the agent when none is running, which moves the session's runtime fence before the host answers. The composer dropped that successful answer as stale, so the objective and Goal chip stayed. Keep the reply for goal changes as for the other writes that may start the agent. --- ...e-structured-agent-session-mutate.test.tsx | 26 +++++++++++++++++++ .../use-structured-agent-session-mutate.ts | 26 +++++++++++-------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index f3df62da35a1..dda53ba5cbdd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -154,6 +154,32 @@ describe('a write pressed again', () => { }) }) +describe('a goal set while the agent rests', () => { + it('keeps the reply after the agent the host started for it moved the fence', async () => { + const stateRef = { current: { fence: 3 as number | null } } + mocks.call.mockImplementation(async () => { + stateRef.current.fence = 4 + return { ok: true, value: { change: 'set' } } + }) + const { current } = renderHook(() => + useStructuredAgentSessionMutate({ + sessionId: 'session-1', + target: { kind: 'local' }, + stateRef + }) + ).result + + let answer: unknown + await act(async () => { + answer = await current.mutate('agentSession.threadGoal', 'agentSession.threadGoal', { + change: { kind: 'set', objective: 'Ship the parser' } + }) + }) + + expect(answer).toEqual({ change: 'set' }) + }) +}) + describe('a conversation command typed again', () => { it('goes out as a new command, which the host resumes or runs', async () => { const ids: string[] = [] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index bdbbe492dc1b..980d0334a0ea 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -4,9 +4,9 @@ // from an earlier one. The host answers a second Stop of a named turn or task quietly; against an // older host that does not, this client joins one still on its way instead of stopping twice. // Every result is discarded unless the runtime fence it was issued against is still the current -// one, except the reply to the conversation command or rewind a pane still waits on, since either -// may move the fence itself. A write that did not happen is reported once, in the person's words, by -// the caller that knows where to say it; nothing latches. +// one, except the reply to the conversation command, rewind or goal change a pane still waits on, +// since each may start the agent and so move the fence itself. A write that did not happen is +// reported once, in the person's words, by the caller that knows where to say it; nothing latches. import { useCallback, useEffect, useRef } from 'react' import { toast } from 'sonner' @@ -43,6 +43,13 @@ export type StructuredAgentSessionWrite = ( /** A write whose failure is shown as a toast; the control that sent it is the way to try again. */ export type StructuredAgentSessionMutate = (...args: WriteArgs) => Promise +/** Writes the host may start the agent for before answering, moving the fence under the reply. */ +const MAY_START_THE_AGENT = new Set([ + 'agentSession.conversationCommand', + 'agentSession.rewind', + 'agentSession.threadGoal' +]) + /** A Stop naming its turn or task; one naming neither acts on whatever runs when the host reaches * it, so a second press is a Stop of its own. */ function namesWhatItStops(fingerprintMethod: string, fields: Record): boolean { @@ -89,7 +96,7 @@ export function useStructuredAgentSessionMutate(args: { new Map>>() ) const enabledRef = useRef(enabled) - // The conversation command or rewind this pane still waits on; only its reply may outlive a fence + // The write that may start the agent this pane still waits on; only its reply may outlive a fence // move. A newer one replaces it, and disabling the pane forgets it. const commandInFlight = useRef(null) useEffect(() => { @@ -111,16 +118,13 @@ export function useStructuredAgentSessionMutate(args: { } const targetFence = pressedFence const request = {} - if ( - fingerprintMethod === 'agentSession.conversationCommand' || - fingerprintMethod === 'agentSession.rewind' - ) { + if (MAY_START_THE_AGENT.has(fingerprintMethod)) { commandInFlight.current = request } // Whether this call's answer is still for what the pane shows. Another fence's result is about - // a runtime the pane replaced; a conversation command may itself start or stop the agent, - // moving the fence under its own reply, so that reply is kept while this pane still waits on - // it. Called once, when the call settles. + // a runtime the pane replaced; a write that may itself start or stop the agent moves the fence + // under its own reply, so that reply is kept while this pane still waits on it. Called once, + // when the call settles. const settlesHere = (): boolean => { const waitedOn = commandInFlight.current === request if (waitedOn) { From b3a06201e036c23cecb40d51a00ca4430d945dc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:46:57 -0700 Subject: [PATCH 02/11] fix(native-chat): settle each command independently of runtime changes --- ...NativeChatComposer.resume-command.test.tsx | 1 + .../NativeChatInterruptedContinue.test.tsx | 2 + ...veChatQueuedMessageList.in-flight.test.tsx | 1 + .../NativeChatQueuedMessageList.test.tsx | 1 + ...ive-chat-composer-draft-lifecycle.test.tsx | 1 + .../native-chat/native-chat-composer-types.ts | 2 + .../native-chat-image-references.test.tsx | 1 + ...structured-send-composition-clear.test.tsx | 1 + ...native-chat-submission-navigation.test.tsx | 2 + ...tructured-session-takeover-report.test.tsx | 1 + .../use-native-chat-composer-submit.test.tsx | 22 ++++ .../use-native-chat-composer-submit.ts | 9 +- .../use-native-chat-rewind.test.tsx | 25 +++++ .../native-chat/use-native-chat-rewind.ts | 41 +++---- ...ive-chat-structured-composer-send.test.tsx | 18 ++++ ...se-native-chat-structured-composer-send.ts | 22 +++- ...tive-chat-structured-composer-transport.ts | 1 + ...ent-session-command-start-failure.test.tsx | 67 ++---------- ...e-structured-agent-session-mutate.test.tsx | 14 ++- .../use-structured-agent-session-mutate.ts | 101 +++++++----------- ...nt-session-queued-messages.resume.test.tsx | 15 ++- ...red-agent-session-queued-messages.test.tsx | 1 + ...tructured-agent-session-queued-messages.ts | 20 ++-- .../use-structured-agent-session-rewind.ts | 3 + .../use-structured-agent-session.test.tsx | 4 +- .../use-structured-agent-session.ts | 27 ++--- ...ructured-native-chat-submit-reveal.test.ts | 7 +- ...se-structured-native-chat-submit-reveal.ts | 9 +- 28 files changed, 242 insertions(+), 177 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx index da02a7074705..7e011d5733ed 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx @@ -94,6 +94,7 @@ const RESUME: CommandItem = { function renderStructuredChat() { const transport: NativeChatStructuredComposerTransport = { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), optionsSurface: { diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index c29edceb3eeb..c7f6e020b15d 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -388,6 +388,7 @@ it('keeps the draft through Continue admission, refusal, uncertainty and recorde sendOptions: () => options }) const transport: NativeChatStructuredComposerTransport = { + capturePresentation: () => () => true, send: (text, images, request) => sends.send( text, @@ -514,6 +515,7 @@ it('keeps the notice view stable while Continue invokes the latest committed tra const first = vi.fn(() => true) const latest = vi.fn(() => true) const transport: NativeChatStructuredComposerTransport = { + capturePresentation: () => () => true, send: first, dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), optionsSurface, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx index 0340ae3a7573..1733c5a21b3b 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx @@ -45,6 +45,7 @@ function renderHeldQueue( ): void { function HeldQueue(): React.JSX.Element { const owner = useStructuredAgentSessionQueuedMessages({ + capturePresentation: () => () => true, enabled: true, queuedMessages, queuePause: { reason: 'stopped' }, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 75ea2a0ad799..a20e4108ed35 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -118,6 +118,7 @@ function renderHeldQueue( const mutate = vi.fn(async (..._call: [string, string, Record]) => null) function HeldQueue(): React.JSX.Element { const owner = useStructuredAgentSessionQueuedMessages({ + capturePresentation: () => () => true, enabled: true, queuedMessages, queuePause, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 6577535fda54..3b31b42100ed 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -70,6 +70,7 @@ function heldTransport(hostCommand = false): { }) return { transport: { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(() => dispatched), optionsSurface: { 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 9687c8e21458..9e79e3ea9a61 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 @@ -1,3 +1,4 @@ +import type { CaptureStructuredAgentSessionPresentation } from './use-structured-agent-session-mutate' import type { StructuredAgentSessionSendRequest } from './structured-agent-session-send-slot' import type { NativeChatComposerNotice } from './native-chat-composer-notice' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' @@ -33,6 +34,7 @@ export type NativeChatOptionPickerRequest = { } export type NativeChatStructuredComposerTransport = { + capturePresentation: CaptureStructuredAgentSessionPresentation conversationCommands?: readonly AgentSessionConversationCommand[] send: ( text: string, diff --git a/src/renderer/src/components/native-chat/native-chat-image-references.test.tsx b/src/renderer/src/components/native-chat/native-chat-image-references.test.tsx index 634e7513e8ef..ee216be37f84 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-references.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-image-references.test.tsx @@ -124,6 +124,7 @@ function fileReferences(document: JSONContent | undefined): string[] { function transport(send: NativeChatStructuredComposerTransport['send']) { const value: NativeChatStructuredComposerTransport = { + capturePresentation: () => () => true, send, dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), optionsSurface: { diff --git a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx index 026b75bbb2eb..84ac9a8bbaa3 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx @@ -92,6 +92,7 @@ function transport( overrides: Partial = {} ): NativeChatStructuredComposerTransport { return { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(async () => PASS_THROUGH), optionsSurface: { diff --git a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx index 8d6eff770c56..fd8210020486 100644 --- a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx @@ -82,6 +82,7 @@ function transport( setOption: () => Promise ): NativeChatStructuredComposerTransport { return { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: (text) => dispatchStructuredAgentSessionComposerCommand(text, { @@ -394,6 +395,7 @@ it('reveals a structured prompt answer at the press, before the host accepts it' const transcript = useReviewTranscript() const submits = useStructuredNativeChatSubmitReveal( { + capturePresentation: () => () => true, respond: async () => host.promise, queuedMessages: { queueCapable: true, diff --git a/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx b/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx index 6a1ba6d6e29d..57734ba59555 100644 --- a/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx +++ b/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx @@ -30,6 +30,7 @@ import type { NativeChatStructuredComposerTransport } from './native-chat-compos function transport(): NativeChatStructuredComposerTransport { return { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), optionsSurface: { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx index eb923ea24a45..234be500b316 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.test.tsx @@ -38,11 +38,13 @@ function harness(options: { imageAttachments?: NativeChatComposerImageAttachment[] /** The PTY lane has no structured transport at all. */ lane?: 'pty' + capturePresentation?: NativeChatStructuredComposerTransport['capturePresentation'] }) { const onError = vi.fn() const onSubmitted = vi.fn() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: submit reads only threadGoal, onError and onSubmitted. const structuredTransport = { + capturePresentation: options.capturePresentation ?? (() => () => true), onError, onSubmitted, ...(options.threadGoal ? { threadGoal: options.threadGoal } : {}) @@ -217,6 +219,26 @@ describe('composer goal mode', () => { expect(hook.result.current.goalMode.active).toBe(true) }) + it('consumes an accepted captured goal after closure without touching local controls', async () => { + const reply = Promise.withResolvers() + let current = true + const { hook, calls, type, onError } = harness({ + draft: '/go', + threadGoal: { setObjective: () => reply.promise }, + capturePresentation: () => () => current + }) + act(() => hook.result.current.goalMode.interceptPick(vi.fn())(GOAL_ITEM)) + type('Ship it', 0) + act(() => hook.result.current.send()) + calls.setCaret.mockClear() + hook.unmount() + current = false + await act(async () => reply.resolve(true)) + expect(readNativeChatDraftCache(SCOPE)).toBe('') + expect(calls.setCaret).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + }) + it('does not send while an image waits to be attached again', () => { const { hook, calls } = harness({ draft: 'see the screenshot', diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts index 3f3bc913c62b..3778e5d4129c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-submit.ts @@ -89,17 +89,20 @@ export function useNativeChatComposerSubmit(args: { return } structuredTransport.onSubmitted?.() + const isCurrent = structuredTransport.capturePresentation() const submitted = readNativeChatComposerDraft(draftScopeKey) void threadGoal.setObjective(objective).then((accepted) => { if (!accepted) { return } - structuredTransport.onError(null) if (!clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted)) { return } - setCaret(0) - setEntered(false) + if (isCurrent()) { + structuredTransport.onError(null) + setCaret(0) + setEntered(false) + } }) }, [draft, draftScopeKey, imageAttachments.length, setCaret, structuredTransport, threadGoal]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx index 5e7ce18d1aca..347ae2422443 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx @@ -55,6 +55,7 @@ const item = ( function input(): Input & { send: ReturnType } { return { sessionId: 'session', + capturePresentation: () => () => true, composerScopeKey: 'pane', state: { ...EMPTY_STRUCTURED_AGENT_SESSION, @@ -208,6 +209,30 @@ describe('structured chat rewind', () => { expect(view.result.current.disabledReason).toBeNull() }) + it.each([done, notDone(unknownRefusal)])( + 'returns captured content after closure for $kind', + async (outcome) => { + const props = input(), + answer = deferred() + props.send.mockReturnValue(answer.promise) + const onMessageReturned = vi.fn(), + view = render({ ...props, onMessageReturned }) + let request!: Promise + await act(async () => { + request = view.result.current.request('user', async () => true) + }) + view.unmount() + writeNativeChatDraftCache('pane', 'new writing') + await act(async () => { + answer.resolve(outcome) + await request + }) + expect(readNativeChatDraftCache('pane')).toBe('new writing\n\ntext of user') + expect(onMessageReturned).not.toHaveBeenCalled() + expect(toastError).not.toHaveBeenCalled() + } + ) + it('does not execute a confirmation after its pane unmounts', async () => { const props = input(), confirmation = deferred() diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts index cad01da38cc4..9ea3e2e19df5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts @@ -18,7 +18,10 @@ import { nativeChatRewindTimeoutCopy, nativeChatRewindUnavailableCopy } from './native-chat-rewind-copy' -import type { StructuredAgentSessionWriteOutcome } from './use-structured-agent-session-mutate' +import type { + CaptureStructuredAgentSessionPresentation, + StructuredAgentSessionWriteOutcome +} from './use-structured-agent-session-mutate' import { returnMessageToComposer } from './structured-agent-session-message-hand-back' import { nativeChatRewindOffered } from './native-chat-rewind-eligibility' import { @@ -38,6 +41,7 @@ export const NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS = 120_000 export type RewindInput = { sessionId: string + capturePresentation: CaptureStructuredAgentSessionPresentation /** The composer the discarded message returns to, after whatever is typed there. */ composerScopeKey?: string /** Called once the discarded message is back in the composer. */ @@ -132,15 +136,13 @@ function returnTargetToComposer(input: RewindInput, itemId: string): boolean { export function useNativeChatRewind(input: RewindInput) { const active = useRef(false) + const latest = useRef(input) useLayoutEffect(() => { + latest.current = input active.current = true return () => { active.current = false } - }, []) - const latest = useRef(input) - useLayoutEffect(() => { - latest.current = input }, [input]) // Single-flight from the click on; only `sending` (after Confirm) blocks anything else. const inFlight = useRef(false) @@ -193,6 +195,7 @@ export function useNativeChatRewind(input: RewindInput) { ) { return } + const isCurrent = captured.capturePresentation() const expectedEpoch = captured.state.epoch! inFlight.current = true let keepBlocked = false @@ -209,7 +212,12 @@ export function useNativeChatRewind(input: RewindInput) { cancelVariant: 'ghost' }) const current = latest.current - if (!confirmed || !active.current || current.sessionId !== captured.sessionId) { + if ( + !confirmed || + !active.current || + !isCurrent() || + current.sessionId !== captured.sessionId + ) { return } if ( @@ -227,17 +235,19 @@ export function useNativeChatRewind(input: RewindInput) { blockedRef.current = true setSending(true) const outcome = await current.send({ itemId, expectedEpoch }) - if (!active.current || latest.current.sessionId !== captured.sessionId) { + const reason = outcome.kind === 'not-done' ? rewindFailureReason(outcome.failure) : undefined + // Captured content belongs to the conversation even after its pane closes. + const returned = + (outcome.kind === 'done' || reason === 'outcome-unknown') && + returnTargetToComposer(captured, itemId) + if (!active.current || !isCurrent() || latest.current.sessionId !== captured.sessionId) { return } - const reset = latest.current.state.epoch !== expectedEpoch - const giveBack = () => { - if (returnTargetToComposer(captured, itemId)) { - latest.current.onMessageReturned?.() - } + if (returned) { + latest.current.onMessageReturned?.() } + const reset = latest.current.state.epoch !== expectedEpoch if (outcome.kind === 'done') { - giveBack() const caughtUp = reset || (outcome.value.sequence !== undefined && @@ -259,11 +269,6 @@ export function useNativeChatRewind(input: RewindInput) { if (outcome.kind === 'dropped') { return } - const reason = rewindFailureReason(outcome.failure) - // A rewind that may have run must never lose the message; a duplicate draft is harmless. - if (reason === 'outcome-unknown') { - giveBack() - } if (reset) { return } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index 0dfa024eee63..abd199e518f8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -25,6 +25,7 @@ function harness( onSubmitted?: () => void ) { const structuredTransport = { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: (text: string) => dispatchStructuredAgentSessionComposerCommand(text, { @@ -198,3 +199,20 @@ describe("says a failed send in Orca's words, with the error it hit apart", () = ) }) }) + +it('settles captured command text without publishing obsolete notices', async () => { + const { send, structuredTransport } = harness('codex', { setObjective: async () => true }) + const reply = + Promise.withResolvers>>() + let current = true + structuredTransport.capturePresentation = () => () => current + structuredTransport.dispatchCommand = () => reply.promise + updateNativeChatComposerDraft('tab-1:pane', { text: '/goal ship it' }, 'immediate') + const pending = send('/goal ship it', []) + current = false + updateNativeChatComposerDraft('tab-1:pane', { text: '/goal ship it next' }, 'immediate') + reply.resolve({ handled: true, accepted: true, error: null }) + await pending + expect(readNativeChatComposerDraft('tab-1:pane').text).toBe(' next') + expect(structuredTransport.onError).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index 1f1bf1b8e0e3..f5d337e225b2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -99,6 +99,7 @@ export function useNativeChatStructuredComposerSend({ if (hostCommand && structuredAgentSessionCommandChangesConversation(text)) { structuredTransport.onSubmitted?.() } + const isCurrent = structuredTransport.capturePresentation() const submitted = readNativeChatComposerDraft(draftScopeKey) await dispatchNativeChatStructuredComposerText( structuredTransport, @@ -107,11 +108,17 @@ export function useNativeChatStructuredComposerSend({ promptSkillRefs(submitted.document) ) .then(({ accepted, error, refusedWhile, revealsTranscript }) => { - structuredTransport.onError(error, refusedWhile ? { refusedWhile } : undefined) + if (!hostCommand || isCurrent()) { + structuredTransport.onError(error, refusedWhile ? { refusedWhile } : undefined) + } if (!accepted) { return } - completeNativeChatStructuredUserAction(agent, structuredTransport, revealsTranscript) + completeNativeChatStructuredUserAction( + agent, + structuredTransport, + revealsTranscript && (!hostCommand || isCurrent()) + ) // Why: the send settles after a round trip, while this or another composer of the same // conversation may have changed the draft; only what was sent leaves it. const left = nativeChatComposerDraftLeftAfterSend( @@ -121,6 +128,14 @@ export function useNativeChatStructuredComposerSend({ if (!left) { return } + if (hostCommand && !isCurrent()) { + updateNativeChatComposerDraft( + draftScopeKey, + { text: left.text, document: undefined, images: left.images }, + 'immediate' + ) + return + } updateNativeChatComposerDraft(draftScopeKey, { images: left.images }, 'immediate') // A live composition owns the field, which keeps only what it composed once cleared. const composing = isComposing() @@ -129,6 +144,9 @@ export function useNativeChatStructuredComposerSend({ clearSkillOrigin() }) .catch((error) => { + if (hostCommand && !isCurrent()) { + return + } const notice = nativeChatNoticeFromError( error, error instanceof SkillRefLimitError diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 64dfb4145d7b..e1242031bacf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -66,6 +66,7 @@ export function useNativeChatStructuredComposerTransport(args: { : Promise.resolve(false) : null return { + capturePresentation: controller.capturePresentation, send: (text, attachments, ...request) => sendBlockedReason === null && sendThroughLaunch(text, attachments.length > 0, () => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx index f1aaae9488d3..e753c510077f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx @@ -236,27 +236,7 @@ describe('a conversation command whose reply lands after the fence moved', () => }) }) - it('drops a reply the pane stopped waiting on: it left the chat and came back', async () => { - const answer = heldWrites() - const { result, rerender } = renderPane() - let sent: Promise<{ accepted: boolean; error: string | null }> = Promise.resolve({ - accepted: true, - error: null - }) - act(() => { - sent = result.current.runConversationCommand('clear') - }) - fence = 5 - rerender({ sessionId: 'session-2', transportEnabled: true }) - rerender({ sessionId: 'session-1', transportEnabled: true }) - await act(async () => { - answer(commandReply('clear', NOT_SIGNED_IN)) - await sent - }) - expect(await sent).toEqual({ accepted: false, error: null }) - }) - - it('drops a reply the pane stopped waiting on: a newer command replaced it', async () => { + it('settles each command independently when a newer one overlaps startup', async () => { const answers: ((reply: unknown) => void)[] = [] mocks.call.mockImplementation( () => @@ -284,57 +264,25 @@ describe('a conversation command whose reply lands after the fence moved', () => answers[1](commandReply('clear')) await Promise.all([older, newer]) }) - expect(await older).toEqual({ kind: 'dropped' }) + expect(await older).toEqual({ kind: 'done', value: commandReply('clear', NOT_SIGNED_IN).value }) expect(await newer).toEqual({ kind: 'done', value: commandReply('clear').value }) }) - - it('drops a reply once the pane has moved to another chat or closed', async () => { - for (const pane of [ - { sessionId: 'session-2', transportEnabled: true }, - { sessionId: 'session-1', transportEnabled: false } - ]) { - fence = 3 - expect( - await commandAcrossFenceMove('clear', commandReply('clear', NOT_SIGNED_IN), pane) - ).toEqual({ accepted: false, error: null }) - } - }) }) describe('any other write across a fence move', () => { it.each([ ['a host without the quiet repeated Stop', false], ['a host with it', true] - ])( - 'drops the result: it answered for the runtime this pane replaced (%s)', - async (_host, quiet) => { - mocks.quietRepeatedStop.mockResolvedValueOnce(quiet) - const answer = heldWrites() - const { result, rerender } = renderPane() - let stopped: Promise = Promise.resolve('unset') - act(() => { - stopped = result.current.cancel('turn-1') - }) - fence = 5 - rerender({ sessionId: 'session-1', transportEnabled: true }) - await act(async () => { - // The Stop reaches the host once the client knows the host's capabilities. - await vi.waitFor(() => expect(mocks.call).toHaveBeenCalled()) - answer({ ok: true, replayed: false, fence: 3, value: { cancelled: true } }) - await stopped - }) - expect(await stopped).toBeNull() - expect(mocks.toastError).not.toHaveBeenCalled() - } - ) - - it('keeps a result the same fence answers', async () => { + ])('keeps the Stop result after the provider starts (%s)', async (_host, quiet) => { + mocks.quietRepeatedStop.mockResolvedValueOnce(quiet) const answer = heldWrites() - const { result } = renderPane() + const { result, rerender } = renderPane() let stopped: Promise = Promise.resolve('unset') act(() => { stopped = result.current.cancel('turn-1') }) + fence = 5 + rerender({ sessionId: 'session-1', transportEnabled: true }) await act(async () => { // The Stop reaches the host once the client knows the host's capabilities. await vi.waitFor(() => expect(mocks.call).toHaveBeenCalled()) @@ -342,5 +290,6 @@ describe('any other write across a fence move', () => { await stopped }) expect(await stopped).toEqual({ cancelled: true }) + expect(mocks.toastError).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index dda53ba5cbdd..f21691ed3a25 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -155,9 +155,15 @@ describe('a write pressed again', () => { }) describe('a goal set while the agent rests', () => { - it('keeps the reply after the agent the host started for it moved the fence', async () => { + it.each([ + 'agentSession.threadGoal', + 'agentSession.rewind', + 'agentSession.conversationCommand', + 'agentSession.setOption' + ])('keeps %s and a concurrent goal reply across startup', async (method) => { const stateRef = { current: { fence: 3 as number | null } } mocks.call.mockImplementation(async () => { + await Promise.resolve() stateRef.current.fence = 4 return { ok: true, value: { change: 'set' } } }) @@ -171,9 +177,11 @@ describe('a goal set while the agent rests', () => { let answer: unknown await act(async () => { - answer = await current.mutate('agentSession.threadGoal', 'agentSession.threadGoal', { - change: { kind: 'set', objective: 'Ship the parser' } + const original = current.mutate(method, method, {}) + await current.mutate('agentSession.threadGoal', 'agentSession.threadGoal', { + change: { kind: 'clear' } }) + answer = await original }) expect(answer).toEqual({ change: 'set' }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 980d0334a0ea..71b43f1d69c4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -3,12 +3,9 @@ // Every call is its own action with its own client operation id, so a press is never answered // from an earlier one. The host answers a second Stop of a named turn or task quietly; against an // older host that does not, this client joins one still on its way instead of stopping twice. -// Every result is discarded unless the runtime fence it was issued against is still the current -// one, except the reply to the conversation command, rewind or goal change a pane still waits on, -// since each may start the agent and so move the fence itself. A write that did not happen is -// reported once, in the person's words, by the caller that knows where to say it; nothing latches. +// Command replies settle independently of provider generations; only late pane presentation expires. -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef } from 'react' import { toast } from 'sonner' import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire' import { @@ -25,13 +22,14 @@ import { callStructuredAgentSession, supportsStructuredAgentSessionQuietRepeatedStop } from '@/runtime/structured-agent-session-client' +import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' export type StructuredAgentSessionWriteOutcome = | { kind: 'done'; value: T } /** Refused or failed, with what to tell the person and the fact it was worded from. */ | { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } - /** Settled for an owner or session this pane no longer shows; there is nothing to say. */ + /** Not dispatched, or a joined duplicate Stop whose failure was already reported. */ | { kind: 'dropped' } type WriteArgs = [method: string, fingerprintMethod: string, fields: Record] @@ -43,12 +41,8 @@ export type StructuredAgentSessionWrite = ( /** A write whose failure is shown as a toast; the control that sent it is the way to try again. */ export type StructuredAgentSessionMutate = (...args: WriteArgs) => Promise -/** Writes the host may start the agent for before answering, moving the fence under the reply. */ -const MAY_START_THE_AGENT = new Set([ - 'agentSession.conversationCommand', - 'agentSession.rewind', - 'agentSession.threadGoal' -]) +/** Captures the issuing pane visit for late presentation, never command or draft settlement. */ +export type CaptureStructuredAgentSessionPresentation = () => () => boolean /** A Stop naming its turn or task; one naming neither acts on whatever runs when the host reaches * it, so a second press is a Stop of its own. */ @@ -82,28 +76,34 @@ export function useStructuredAgentSessionMutate(args: { sessionId: string target: RuntimeClientTarget enabled?: boolean - /** Read at settle time, not at call time: the fence can move while a request - * is in flight, and a result from the previous fence is not this session's. */ + /** Capture the original press fence for host admission, including older hosts. */ stateRef: { current: { fence: number | null } } /** The chat's agent, which a refusal about it names. */ agentName?: string }): { write: StructuredAgentSessionWrite mutate: StructuredAgentSessionMutate + capturePresentation: CaptureStructuredAgentSessionPresentation } { const { agentName, enabled = true, sessionId, stateRef, target } = args const inFlightStops = useRef( new Map>>() ) const enabledRef = useRef(enabled) - // The write that may start the agent this pane still waits on; only its reply may outlive a fence - // move. A newer one replaces it, and disabling the pane forgets it. - const commandInFlight = useRef(null) - useEffect(() => { - // Why: update the gate after commit so render stays free of ref mutations. + const presentation = useRef(null) + const hostKey = structuredAgentSessionHostKey(target) + useLayoutEffect(() => { enabledRef.current = enabled - commandInFlight.current = null - }, [enabled, sessionId]) + presentation.current = enabled ? {} : null + return () => { + enabledRef.current = false + presentation.current = null + } + }, [enabled, hostKey, sessionId]) + const capturePresentation = useCallback(() => { + const visit = presentation.current + return () => visit !== null && presentation.current === visit + }, []) const send = useCallback( async ( @@ -117,21 +117,6 @@ export function useStructuredAgentSessionMutate(args: { return { kind: 'dropped' } } const targetFence = pressedFence - const request = {} - if (MAY_START_THE_AGENT.has(fingerprintMethod)) { - commandInFlight.current = request - } - // Whether this call's answer is still for what the pane shows. Another fence's result is about - // a runtime the pane replaced; a write that may itself start or stop the agent moves the fence - // under its own reply, so that reply is kept while this pane still waits on it. Called once, - // when the call settles. - const settlesHere = (): boolean => { - const waitedOn = commandInFlight.current === request - if (waitedOn) { - commandInFlight.current = null - } - return enabledRef.current && (stateRef.current.fence === targetFence || waitedOn) - } let result: AgentSessionMutationResult try { result = await callStructuredAgentSession>(target, method, { @@ -148,30 +133,23 @@ export function useStructuredAgentSessionMutate(args: { ...fields }) } catch (error) { - return settlesHere() - ? notDone( - agentSessionThrownFailure( - error, - error instanceof RuntimeRpcCallError ? error.code : undefined - ), - fingerprintMethod, - fields, - agentName - ) - : { kind: 'dropped' } + return notDone( + agentSessionThrownFailure( + error, + error instanceof RuntimeRpcCallError ? error.code : undefined + ), + fingerprintMethod, + fields, + agentName + ) } if (!result.ok) { - return settlesHere() - ? notDone( - agentSessionRefusalFailure(result.refusal), - fingerprintMethod, - fields, - agentName - ) - : { kind: 'dropped' } - } - if (!settlesHere()) { - return { kind: 'dropped' } + return notDone( + agentSessionRefusalFailure(result.refusal), + fingerprintMethod, + fields, + agentName + ) } return { kind: 'done', value: result.value } }, @@ -218,14 +196,15 @@ export function useStructuredAgentSessionMutate(args: { const mutate = useCallback( async (...writeArgs: WriteArgs): Promise => { + const isCurrent = capturePresentation() const outcome = await write(...writeArgs) - if (outcome.kind === 'not-done') { + if (outcome.kind === 'not-done' && isCurrent()) { toast.error(outcome.notice) } return outcome.kind === 'done' ? outcome.value : null }, - [write] + [capturePresentation, write] ) - return { write, mutate } + return { write, mutate, capturePresentation } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index cb300c9bd82f..da17aaf68235 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -63,7 +63,7 @@ function renderController(initialProps: ControllerInput = {}) { const stateRef = { current: { fence: 1 } } return renderHook( (input: ControllerInput) => { - const { mutate, write } = useStructuredAgentSessionMutate({ + const { mutate, write, capturePresentation } = useStructuredAgentSessionMutate({ sessionId: 'session-1', target: { kind: 'local' }, stateRef @@ -77,6 +77,7 @@ function renderController(initialProps: ControllerInput = {}) { isWorking: false, composerScopeKey: undefined, mutate, + capturePresentation, editTransport: { ...NO_EDIT, write } }) }, @@ -193,12 +194,12 @@ function resumedResult(resumed = true) { /** The queue controller as a pane builds it, over the real mutation. */ function useQueue(sessionId = 'session-1', fence: number | null = 1) { - const { mutate } = useStructuredAgentSessionMutate({ + const { mutate, capturePresentation } = useStructuredAgentSessionMutate({ sessionId, target: { kind: 'local' }, stateRef: { current: { fence } } }) - return useStructuredAgentSessionQueuedMessages({ + const queue = useStructuredAgentSessionQueuedMessages({ enabled: true, queuedMessages: [card('held')], queuePause: { reason: 'stopped' }, @@ -207,8 +208,10 @@ function useQueue(sessionId = 'session-1', fence: number | null = 1) { isWorking: false, composerScopeKey: undefined, mutate, + capturePresentation, editTransport: NO_EDIT }) + return { ...queue, capturePresentation } } function renderRevealingController(sessionId = 'session-1', fence: number | null = 1) { @@ -216,7 +219,11 @@ function renderRevealingController(sessionId = 'session-1', fence: number | null ({ isVisible }) => { const queue = useQueue(sessionId, fence) const submits = useStructuredNativeChatSubmitReveal( - { queuedMessages: queue, respond: async () => null }, + { + queuedMessages: queue, + respond: async () => null, + capturePresentation: queue.capturePresentation + }, vi.fn() ) const scrollRef = useRef(document.createElement('div')) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index f0f691d8db31..aedbd554122e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -58,6 +58,7 @@ function createHarness( }) const rendered = renderHook(() => useStructuredAgentSessionQueuedMessages({ + capturePresentation: () => () => true, enabled: overrides.enabled ?? true, queuedMessages: overrides.queuedMessages ?? [draft('draft-1', 1), draft('draft-2', 2)], queuePause: null, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 04a320fc58fd..141617d55a1d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -28,7 +28,10 @@ import { sendingQueuedMessageCards, type QueuedMessageCard } from './structured-agent-session-queued-cards' -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { + CaptureStructuredAgentSessionPresentation, + StructuredAgentSessionMutate +} from './use-structured-agent-session-mutate' export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] @@ -90,10 +93,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { sending?: readonly StructuredAgentSessionPendingSend[] composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate + capturePresentation: CaptureStructuredAgentSessionPresentation editTransport: QueuedEditTransport }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - const { isWorking, queuePause } = args + const { capturePresentation, isWorking, queuePause } = args const sending = args.sending ?? NO_SENDS const cards = useMemo( @@ -162,18 +166,19 @@ export function useStructuredAgentSessionQueuedMessages(args: { const remove = useCallback( async (messageId: string): Promise => { + const isCurrent = capturePresentation() await run([messageId], 'delete', async () => { const result = await mutate( 'agentSession.queuedMessageDelete', 'agentSession.queuedMessageDelete', { messageId } ) - if (result && !result.deleted && result.disposition === 'dispatched') { + if (isCurrent() && result && !result.deleted && result.disposition === 'dispatched') { alreadySentNotice() } }) }, - [mutate, run] + [capturePresentation, mutate, run] ) const editing = useStructuredAgentSessionQueuedEdit({ @@ -222,6 +227,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { if (!clearCapable || messageIds.length === 0) { return false } + const isCurrent = capturePresentation() // Refused while any of these cards is in flight; each shows busy until the whole Clear settles. return run([QUEUE_KEY, ...messageIds], 'clear', async () => { const results = await mapSettledWithConcurrency( @@ -244,10 +250,10 @@ export function useStructuredAgentSessionQueuedMessages(args: { const failure = results.find( (result) => result.status === 'fulfilled' && result.value.kind === 'not-done' ) - if (failure?.status === 'fulfilled' && failure.value.kind === 'not-done') { + if (isCurrent() && failure?.status === 'fulfilled' && failure.value.kind === 'not-done') { toast.error(failure.value.notice) } - if (dispatched) { + if (isCurrent() && dispatched) { alreadySentNotice() } return ( @@ -255,7 +261,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { results.every((result) => result.status === 'fulfilled' && result.value.kind === 'done') ) }) - }, [args.editTransport, clearCapable, run]) + }, [args.editTransport, capturePresentation, clearCapable, run]) return { cards, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts index 9369c57f86fb..065c6fc7fbce 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-rewind.ts @@ -16,6 +16,7 @@ export function useStructuredAgentSessionRewind( ) { const { blocked, + capturePresentation, composerScopeKey, contextFloor, hostBlockedReason, @@ -34,6 +35,7 @@ export function useStructuredAgentSessionRewind( const input = useMemo( () => ({ sessionId, + capturePresentation, composerScopeKey, contextFloor, onMessageReturned, @@ -47,6 +49,7 @@ export function useStructuredAgentSessionRewind( }), [ blocked, + capturePresentation, composerScopeKey, contextFloor, hostBlockedReason, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index d18fad4a2a5d..6a9f1bef18e7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -390,7 +390,7 @@ describe('useStructuredAgentSession options', () => { ).toEqual([3, 4]) }) - it('ignores an option failure from a superseded fence', async () => { + it('reports an option failure across startup while its pane remains current', async () => { let reject!: (error: Error) => void const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise @@ -423,7 +423,7 @@ describe('useStructuredAgentSession options', () => { }) expect(result.current.error).toBeNull() - expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledOnce() }) it('includes one background task id in the cancel fingerprint and payload', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index b00f134c3821..99418433d96e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -93,6 +93,7 @@ export function useStructuredAgentSession(args: { loadOlder, mutate, write, + capturePresentation, providerVisible } = useStructuredAgentSessionTransport({ sessionId, @@ -104,6 +105,7 @@ export function useStructuredAgentSession(args: { const commandPending = useRef(false) const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId) const transportState = useStructuredAgentSessionTransportState(state, transportEnabled) + const journalItems = transportState.journalItems const { conversationCommands, optionSnapshot, @@ -113,7 +115,7 @@ export function useStructuredAgentSession(args: { unavailable, threadGoal: threadGoalSupport, reportedThreadGoal, - contextUsage: contextUsageSupport, + contextUsage: usageSupport, rewind: rewindSupport, acceptsImages } = useStructuredAgentSessionOptions({ @@ -167,13 +169,9 @@ export function useStructuredAgentSession(args: { sendOptions }) - const contextUsage = useStructuredAgentSessionContextUsage( - transportState.journalItems, - contextUsageSupport, - state.epoch - ) + const usage = useStructuredAgentSessionContextUsage(journalItems, usageSupport, state.epoch) - const contextFloor = contextUsageSupport?.contextFloor ?? threadGoalSupport?.contextFloor + const contextFloor = usageSupport?.contextFloor ?? threadGoalSupport?.contextFloor const railOutline = useStructuredAgentSessionRailOutline({ sessionId, target, @@ -198,18 +196,19 @@ export function useStructuredAgentSession(args: { contextFloor, // The host also refuses a rewind behind its queued cards, paused ones included. blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, - write + write, + capturePresentation }) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking || transportState.queueSendsNext // A message typed during a command queues behind it on the host. const send = (...input: Parameters) => // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. - (!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) && + (!commandPending.current || hostStatesTurnScopes(journalItems)) && rewind.admitsSend() && sends.send(...input) const threadGoal = useStructuredAgentSessionThreadGoal({ - journalItems: transportState.journalItems, + journalItems, journalEpoch: state.epoch, support: threadGoalSupport, reported: reportedThreadGoal, @@ -220,7 +219,7 @@ export function useStructuredAgentSession(args: { () => pendingSendsOutsideQueuedCards(pending, queuedMessageIds, isWorking), [isWorking, pending, queuedMessageIds] ) - const contextClear = useNativeChatContextClearFloor(state.epoch, transportState.journalItems) + const contextClear = useNativeChatContextClearFloor(state.epoch, journalItems) // What the transcript reads: the journal since the latest /clear, plus the one notice a cut turn // with no row gets. const transcriptItems = useMemo( @@ -248,7 +247,8 @@ export function useStructuredAgentSession(args: { sending: pending, composerScopeKey, mutate, - editTransport + editTransport, + capturePresentation }) return { epoch: state.epoch, @@ -312,6 +312,7 @@ export function useStructuredAgentSession(args: { }), stopPressed: stopControl.pressed, queuedMessages: queuedController, + capturePresentation, /** A send made now while the agent works is held as a queued card: the host queues, and this * send asks it to (the setting is on and no pending prompt blocks the queue). */ sendsQueue: structuredAgentSessionNewSendsQueue(queue), @@ -332,7 +333,7 @@ export function useStructuredAgentSession(args: { /** Why the host's catalog probe says no chat can start here; the chat shows it as a notice. */ unavailable, threadGoal, - contextUsage, + contextUsage: usage, /** Whether this session takes images, by its options report; undefined until it answers. */ acceptsImages } diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts index 2390979b6f32..8e87f0dadf38 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts @@ -15,6 +15,7 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ it('reveals before launch Retry, but waits for a successful queue Resume', async () => { const order: string[] = [] const controller = { + capturePresentation: () => () => true, respond: vi.fn(async () => null), queuedMessages: { queueCapable: true, @@ -68,7 +69,11 @@ it('sends no queued card and retries no start on a host held for its permission editor: undefined, steerNewest: vi.fn(() => true) } - const controller = { respond: vi.fn(async () => null), queuedMessages } + const controller = { + capturePresentation: () => () => true, + respond: vi.fn(async () => null), + queuedMessages + } const retryLaunch = vi.fn() useAppStore.setState({ settings: { ...useAppStore.getState().settings!, nativeChatPermissionMode: 'ask' } diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts index 60082d7c63fa..891f1844a9ab 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.ts @@ -12,7 +12,7 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc type StructuredController = ReturnType export function useStructuredNativeChatSubmitReveal( - controller: Pick, + controller: Pick, /** Relaunches a start that failed. */ retryLaunch: () => void, /** The host may take no turn: no card is sent now or resumed, and no failed start retried. */ @@ -26,7 +26,7 @@ export function useStructuredNativeChatSubmitReveal( queuedMessages: StructuredAgentSessionQueuedMessagesController } { const { messageListRef, revealLatest } = useNativeChatRevealLatest() - const { respond, queuedMessages } = controller + const { respond, queuedMessages, capturePresentation } = controller const revealingRetryLaunch = useCallback(() => { if (startsHeld) { return @@ -44,8 +44,9 @@ export function useStructuredNativeChatSubmitReveal( const revealingQueue = useMemo(() => { // Resume can be refused or find nothing paused, so it reveals only once the host lifts it. const revealing = (lift: () => Promise) => async (): Promise => { + const isCurrent = capturePresentation() const resumed = await lift() - if (resumed) { + if (resumed && isCurrent()) { revealLatest() } return resumed @@ -73,7 +74,7 @@ export function useStructuredNativeChatSubmitReveal( return steered } } - }, [queuedMessages, revealLatest, startsHeld]) + }, [capturePresentation, queuedMessages, revealLatest, startsHeld]) return { messageListRef, revealLatest, From c44adb6784e353d74c23afbd956832b275669551 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:48:30 -0700 Subject: [PATCH 03/11] fix(native-chat): scope rewind timeout feedback to its issuing pane --- .../native-chat/use-native-chat-rewind.test.tsx | 14 +++++++++++--- .../native-chat/use-native-chat-rewind.ts | 10 +++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx index 347ae2422443..5aaff91eb644 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx @@ -372,15 +372,23 @@ describe('structured chat rewind', () => { } }) - it.each(['hidden throughout', 'hidden part way'] as const)( - 'lets go silently when the pane was %s, since a hidden pane reads nothing', + it.each(['hidden throughout', 'hidden part way', 'presentation replaced'] as const)( + 'lets go silently when the pane was %s', async (mode) => { vi.useFakeTimers() try { - const props: Input = { ...input(), isVisible: mode !== 'hidden throughout' } + let current = true + const props: Input = { + ...input(), + isVisible: mode !== 'hidden throughout', + capturePresentation: () => () => current + } const view = render(props) await act(() => view.result.current.request('user', async () => true)) expect(view.result.current.blockedRef.current).toBe(true) + if (mode === 'presentation replaced') { + current = false + } if (mode === 'hidden part way') { await act(() => vi.advanceTimersByTimeAsync(1_000)) view.rerender({ ...props, isVisible: false }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts index 9ea3e2e19df5..ad63590e33eb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts @@ -152,6 +152,7 @@ export function useNativeChatRewind(input: RewindInput) { sessionId: string epoch: string sequence?: number + isCurrent: () => boolean } | null>(null) const awaitingReset = awaiting?.sessionId === input.sessionId && @@ -161,9 +162,7 @@ export function useNativeChatRewind(input: RewindInput) { const hiddenWhileAwaiting = useRef(false) const visible = input.isVisible !== false useLayoutEffect(() => { - if (!visible) { - hiddenWhileAwaiting.current = true - } + hiddenWhileAwaiting.current ||= !visible }, [visible]) useEffect(() => { if (!awaitingReset) { @@ -172,12 +171,12 @@ export function useNativeChatRewind(input: RewindInput) { hiddenWhileAwaiting.current = latest.current.isVisible === false const timer = setTimeout(() => { setAwaiting(null) - if (!hiddenWhileAwaiting.current) { + if (!hiddenWhileAwaiting.current && awaiting?.isCurrent()) { toast.error(nativeChatRewindTimeoutCopy()) } }, NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS) return () => clearTimeout(timer) - }, [awaitingReset]) + }, [awaiting, awaitingReset]) const pending = sending || awaitingReset const disabledReason = pending ? nativeChatRewindPendingCopy() : blockedReason(input) const blockedRef = useRef(false) @@ -259,6 +258,7 @@ export function useNativeChatRewind(input: RewindInput) { : { sessionId: captured.sessionId, epoch: expectedEpoch, + isCurrent, ...(outcome.value.sequence !== undefined ? { sequence: outcome.value.sequence } : {}) From c08c7a6a2cedf8be9b3c4b97086f967e10b0fcf9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:00:05 -0700 Subject: [PATCH 04/11] test: use checked native chat command fixtures --- .../use-native-chat-structured-composer-send.test.tsx | 10 ++++++++-- .../use-structured-agent-session-mutate.test.tsx | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index abd199e518f8..1b37dc5bdfb9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -24,7 +24,7 @@ function harness( threadGoal?: NativeChatStructuredComposerTransport['threadGoal'], onSubmitted?: () => void ) { - const structuredTransport = { + const structuredTransport: NativeChatStructuredComposerTransport = { capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: (text: string) => @@ -36,12 +36,18 @@ function harness( conversationCommands: ['clear', 'compact'], runConversationCommand: async () => ({ accepted: true, error: null }) }), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, optionSnapshot: [], onError: vi.fn(), runtime: 'local', sessionId: 'session-test', runtimeEnvironmentId: null - } as unknown as NativeChatStructuredComposerTransport + } if (threadGoal) { structuredTransport.threadGoal = threadGoal } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index f21691ed3a25..e49d4ad97ea5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -161,7 +161,7 @@ describe('a goal set while the agent rests', () => { 'agentSession.conversationCommand', 'agentSession.setOption' ])('keeps %s and a concurrent goal reply across startup', async (method) => { - const stateRef = { current: { fence: 3 as number | null } } + const stateRef: { current: { fence: number | null } } = { current: { fence: 3 } } mocks.call.mockImplementation(async () => { await Promise.resolve() stateRef.current.fence = 4 From c9dca35db45c21379310cfbdd297f8738770dce8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:08:05 -0700 Subject: [PATCH 05/11] test: complete native chat presentation fixtures --- .../NativeChatComposer.paste-remount.test.tsx | 1 + .../native-chat/NativeChatComposer.test.tsx | 33 +++++++++---------- .../NativeChatInterruptedContinue.test.tsx | 1 + ...ative-chat-composer-prompt-recall.test.tsx | 1 + ...gent-session-clear-old-client.unit.test.ts | 1 + 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx index 59b3f193e472..1f8da05877ba 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -116,6 +116,7 @@ function composer( targetPtyId={null} agent="claude" structuredTransport={{ + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), optionsSurface: { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index bfcc34d990aa..f2f73c7646a7 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -160,6 +160,15 @@ vi.mock('./use-native-chat-send-lifecycle', () => ({ import { NativeChatComposer } from './NativeChatComposer' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +function createOptionsSurface(): SessionOptionsSurface { + return { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + } +} + describe('NativeChatComposer', () => { beforeEach(() => { vi.clearAllMocks() @@ -288,12 +297,7 @@ describe('NativeChatComposer', () => { accepted: false, error: null })) - const optionsSurface = { - getSnapshot: () => [], - setOption: vi.fn(), - invokeAction: vi.fn(), - subscribe: () => () => {} - } satisfies SessionOptionsSurface + const optionsSurface = createOptionsSurface() const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[] const composer = (sessionPending = false) => ( { targetPtyId={null} agent="codex" structuredTransport={{ + capturePresentation: () => () => true, send, dispatchCommand, optionsSurface, @@ -347,14 +352,10 @@ describe('NativeChatComposer', () => { targetPtyId={null} agent={agent} structuredTransport={{ + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), - optionsSurface: { - getSnapshot: () => [], - setOption: vi.fn(), - invokeAction: vi.fn(), - subscribe: () => () => {} - }, + optionsSurface: createOptionsSurface(), optionSnapshot: [], onError: vi.fn(), runtime: 'local', @@ -382,18 +383,14 @@ describe('NativeChatComposer', () => { targetPtyId={null} agent="codex" structuredTransport={{ + capturePresentation: () => () => true, send, dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), - optionsSurface: { - getSnapshot: () => [], - setOption: vi.fn(), - invokeAction: vi.fn(), - subscribe: () => () => {} - }, + optionsSurface: createOptionsSurface(), optionSnapshot: [], worktreeId: 'wt-1', onError: vi.fn(), diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index c7f6e020b15d..fb1131566c8c 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -135,6 +135,7 @@ function Harness(props: Props): React.JSX.Element { isWorking: props.isWorking ?? false, pending: props.pending ?? [], transport: props.transport ?? { + capturePresentation: () => () => true, send: () => true, dispatchCommand: async () => ({ handled: false, accepted: false, error: null }), optionsSurface, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx index a38edf40ba83..bc44e2d7d162 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx @@ -90,6 +90,7 @@ function renderComposer(prompts: string[]): HTMLElement { targetPtyId={null} agent="codex" structuredTransport={{ + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), optionsSurface: { diff --git a/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts index d43214047c6b..a7a48bd6927c 100644 --- a/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-clear-old-client.unit.test.ts @@ -157,6 +157,7 @@ test('an older rewind-capable desktop releases its temporary same-epoch hold wit vi.useFakeTimers() const input: RewindInput = { sessionId: SESSION, + capturePresentation: () => () => true, composerScopeKey: 'old-rewind-clear-compat', state: { ...EMPTY_STRUCTURED_AGENT_SESSION, From f5cba8033706e2f581ca1a308d0cea6b06f3411e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:22:47 -0700 Subject: [PATCH 06/11] fix(native-chat): admit Stop before capability discovery --- ...e-structured-agent-session-mutate.test.tsx | 32 +++++++++++++++++++ .../use-structured-agent-session-mutate.ts | 15 ++++----- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index e49d4ad97ea5..ff16df593d94 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -237,6 +237,38 @@ function heldStops() { const stop = (current: ReturnType['current'], turnId = 'turn-1') => current.mutate('agentSession.cancel', 'agentSession.cancel', { turnId }) +it.each([ + [false, { turnId: 'turn-1' }], + [true, { turnId: 'turn-1' }], + [true, { turnId: 'background-tasks', scope: 'background-tasks', taskId: 'task-command' }] +] as const)('keeps an admitted Stop after closure (quiet=%s, %j)', async (quiet, fields) => { + const capability = Promise.withResolvers() + mocks.hostAnswersRepeatedStops.mockReturnValue(capability.promise) + mocks.call.mockResolvedValue({ ok: true, value: { cancelled: true } }) + const stateRef = { current: { fence: 3 } } + const target = { kind: 'environment', environmentId: 'host-A' } as const + const view = renderHook(() => + useStructuredAgentSessionMutate({ sessionId: 'session-A', target, stateRef }) + ) + const isCurrent = view.result.current.capturePresentation() + const pending = view.result.current.write('agentSession.cancel', 'agentSession.cancel', fields) + expect(mocks.call).not.toHaveBeenCalled() + stateRef.current.fence = 4 + view.unmount() + capability.resolve(quiet) + expect(await pending).toEqual({ kind: 'done', value: { cancelled: true } }) + expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.cancel', { + ...fields, + envelope: { + sessionId: 'session-A', + clientOperationId: 'operation-1', + expectedRuntimeFence: 3, + payloadFingerprint: expect.any(String) + } + }) + expect(isCurrent()).toBe(false) +}) + describe('a Stop pressed again, against a host without the quiet repeated Stop', () => { it('joins the one still in flight instead of sending a second', async () => { const host = heldStops() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 71b43f1d69c4..63a2a74285c8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -111,19 +111,15 @@ export function useStructuredAgentSessionMutate(args: { fingerprintMethod: string, fields: Record, /** The fence when the press happened, read before any await, so a write never retargets. */ - pressedFence: number | null = stateRef.current.fence + pressedFence: number ): Promise> => { - if (!enabled || !enabledRef.current || pressedFence === null) { - return { kind: 'dropped' } - } - const targetFence = pressedFence let result: AgentSessionMutationResult try { result = await callStructuredAgentSession>(target, method, { envelope: { sessionId, clientOperationId: structuredSessionOperationId(), - expectedRuntimeFence: targetFence, + expectedRuntimeFence: pressedFence, payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: fingerprintMethod, sessionId, @@ -153,7 +149,7 @@ export function useStructuredAgentSessionMutate(args: { } return { kind: 'done', value: result.value } }, - [agentName, enabled, sessionId, stateRef, target] + [agentName, sessionId, target] ) const write = useCallback( @@ -163,6 +159,9 @@ export function useStructuredAgentSessionMutate(args: { fields: Record ): Promise> => { const pressedFence = stateRef.current.fence + if (!enabled || !enabledRef.current || pressedFence === null) { + return { kind: 'dropped' } + } if ( !namesWhatItStops(fingerprintMethod, fields) || (await supportsStructuredAgentSessionQuietRepeatedStop(target)) @@ -191,7 +190,7 @@ export function useStructuredAgentSessionMutate(args: { }) return stopping }, - [send, sessionId, stateRef, target] + [enabled, send, sessionId, stateRef, target] ) const mutate = useCallback( From 915f5af6465eeb838ccdc40f2231f5969d7fd449 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:27:40 -0700 Subject: [PATCH 07/11] test(native-chat): consolidate overlapping startup coverage --- ...ent-session-command-start-failure.test.tsx | 17 ++++++---- ...e-structured-agent-session-mutate.test.tsx | 34 ------------------- 2 files changed, 10 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx index e753c510077f..a93570077f81 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx @@ -236,7 +236,13 @@ describe('a conversation command whose reply lands after the fence moved', () => }) }) - it('settles each command independently when a newer one overlaps startup', async () => { + it.each([ + ['agentSession.threadGoal', 'agentSession.threadGoal'], + ['agentSession.rewind', 'agentSession.threadGoal'], + ['agentSession.conversationCommand', 'agentSession.threadGoal'], + ['agentSession.setOption', 'agentSession.threadGoal'], + ['agentSession.conversationCommand', 'agentSession.conversationCommand'] + ])('settles %s independently when %s overlaps startup', async (earlier, later) => { const answers: ((reply: unknown) => void)[] = [] mocks.call.mockImplementation( () => @@ -252,12 +258,9 @@ describe('a conversation command whose reply lands after the fence moved', () => stateRef }) ) - const command = (): Promise => - result.current.write('agentSession.conversationCommand', 'agentSession.conversationCommand', { - command: 'clear' - }) - const older = command() - const newer = command() + const command = (name: string): Promise => result.current.write(name, name, {}) + const older = command(earlier) + const newer = command(later) stateRef.current.fence = 5 await act(async () => { answers[0](commandReply('clear', NOT_SIGNED_IN)) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index ff16df593d94..ffc541eb3525 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -154,40 +154,6 @@ describe('a write pressed again', () => { }) }) -describe('a goal set while the agent rests', () => { - it.each([ - 'agentSession.threadGoal', - 'agentSession.rewind', - 'agentSession.conversationCommand', - 'agentSession.setOption' - ])('keeps %s and a concurrent goal reply across startup', async (method) => { - const stateRef: { current: { fence: number | null } } = { current: { fence: 3 } } - mocks.call.mockImplementation(async () => { - await Promise.resolve() - stateRef.current.fence = 4 - return { ok: true, value: { change: 'set' } } - }) - const { current } = renderHook(() => - useStructuredAgentSessionMutate({ - sessionId: 'session-1', - target: { kind: 'local' }, - stateRef - }) - ).result - - let answer: unknown - await act(async () => { - const original = current.mutate(method, method, {}) - await current.mutate('agentSession.threadGoal', 'agentSession.threadGoal', { - change: { kind: 'clear' } - }) - answer = await original - }) - - expect(answer).toEqual({ change: 'set' }) - }) -}) - describe('a conversation command typed again', () => { it('goes out as a new command, which the host resumes or runs', async () => { const ids: string[] = [] From 47f132bf14e838933c24c60890aa7db70b3e837b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:04:01 -0700 Subject: [PATCH 08/11] test(native-chat): preserve IME composition on goal acceptance --- ...structured-send-composition-clear.test.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx index 84ac9a8bbaa3..c494363dfc90 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx @@ -148,26 +148,33 @@ afterEach(() => cleanup()) describe('structured send racing the next IME composition', () => { // The regression: the RPC's clear lands while the NEXT composition is live, so the DOM sync // drops it and settlement used to adopt the sent text back into the composer (#17359). - it('does not resurrect the sent message when the clear lands mid-composition', async () => { + it('preserves the next composition when a typed goal is accepted', async () => { const dispatch = deferred() - const structured = transport({ dispatchCommand: vi.fn(() => dispatch.promise) }) + const structured = transport({ + dispatchCommand: vi.fn(async () => PASS_THROUGH).mockReturnValueOnce(dispatch.promise), + threadGoal: { setObjective: vi.fn() } + }) renderComposer(structured) + const pane = `tab-${paneCounter}:structured` const input = textarea() - changePrompt(input, '안녕') + changePrompt(input, '/goal 안녕') pressEnter(input) - expect(structured.dispatchCommand).toHaveBeenCalledWith('안녕') + expect(structured.dispatchCommand).toHaveBeenCalledWith('/goal 안녕') fireEvent.compositionStart(input) - changePrompt(input, '안녕하') + changePrompt(input, '/goal 안녕하') await act(async () => { - dispatch.resolve(PASS_THROUGH) + dispatch.resolve({ handled: true, accepted: true, error: null }) await dispatch.promise }) + expect(promptValue(input)).toBe('/goal 안녕하') + expect(structured.send).not.toHaveBeenCalled() fireEvent.compositionEnd(input, { data: '하' }) expect(promptValue(input)).toBe('하') + expect(readNativeChatDraftCache(pane)).toBe('하') await act(async () => pressEnter(input)) expect(structured.send).toHaveBeenLastCalledWith('하', []) From d13e138410b83c33c8d94b3542256459411d10f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:06:23 -0700 Subject: [PATCH 09/11] fix(native-chat): capture each action before async preparation --- ...veChatQueuedMessageList.in-flight.test.tsx | 1 + .../NativeChatQueuedMessageList.test.tsx | 1 + ...tructured-agent-session-prompt-response.ts | 43 ++++++------ ...e-structured-agent-session-mutate.test.tsx | 21 ++++++ .../use-structured-agent-session-mutate.ts | 52 ++++++++------ ...ed-agent-session-question-answers.test.tsx | 70 ++++++++++++++----- ...nt-session-queued-messages.resume.test.tsx | 6 +- ...red-agent-session-queued-messages.test.tsx | 55 ++++++++++----- ...tructured-agent-session-queued-messages.ts | 12 +++- .../use-structured-agent-session.ts | 17 ++--- 10 files changed, 191 insertions(+), 87 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx index 1733c5a21b3b..b5b57e43c38a 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.in-flight.test.tsx @@ -45,6 +45,7 @@ function renderHeldQueue( ): void { function HeldQueue(): React.JSX.Element { const owner = useStructuredAgentSessionQueuedMessages({ + admit: () => ({ target: { kind: 'local' }, write: write, mutate: async () => null }), capturePresentation: () => () => true, enabled: true, queuedMessages, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index a20e4108ed35..6ca34cfa942f 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -118,6 +118,7 @@ function renderHeldQueue( const mutate = vi.fn(async (..._call: [string, string, Record]) => null) function HeldQueue(): React.JSX.Element { const owner = useStructuredAgentSessionQueuedMessages({ + admit: () => null, capturePresentation: () => () => true, enabled: true, queuedMessages, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts b/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts index ab0dc5e6777b..10b0f636e1c3 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts @@ -1,4 +1,3 @@ -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { supportsStructuredAgentSessionQuestionAnswers, supportsStructuredAgentSessionPromptCancel @@ -9,55 +8,57 @@ import { } from '../../../../shared/agent-session-question-answer' import type { AgentSessionPromptResult } from '../../../../shared/agent-session-wire' import type { StructuredPromptItem } from './structured-agent-session-message-projection' -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { StructuredAgentSessionAction } from './use-structured-agent-session-mutate' export async function respondToStructuredAgentSessionPrompt({ item, response, - target, - mutate + action }: { item: StructuredPromptItem response: AgentSessionPromptResponse - target: RuntimeClientTarget - mutate: StructuredAgentSessionMutate + action: StructuredAgentSessionAction | null }): Promise { + if (!action) { + return null + } + const { body } = item + const answers = response.kind === 'answers' ? response.answers : [] const promptTarget = { itemId: item.itemId, expectedRevision: item.revision } let fields: Record if (response.kind === 'option') { fields = { ...promptTarget, optionId: response.optionId } - } else if (await supportsStructuredAgentSessionQuestionAnswers(target)) { + } else if (await supportsStructuredAgentSessionQuestionAnswers(action.target)) { // Negotiated before mutate fingerprints the call: older hosts reject the strict field. - fields = { ...promptTarget, answers: response.answers } + fields = { ...promptTarget, answers } } else { const optionId = - item.body.kind === 'question' - ? legacyAgentSessionSelectedOptionId(item.body, response.answers) - : null + body.kind === 'question' ? legacyAgentSessionSelectedOptionId(body, answers) : null if (optionId === null) { return null } fields = { ...promptTarget, optionId } } - return mutate( - item.body.kind === 'approval' - ? 'agentSession.respondToApproval' - : 'agentSession.respondToQuestion', - `agentSession.respondTo:${item.body.kind}`, + return action.mutate( + body.kind === 'approval' ? 'agentSession.respondToApproval' : 'agentSession.respondToQuestion', + `agentSession.respondTo:${body.kind}`, fields ) } export async function cancelStructuredAgentSessionPrompt( - target: RuntimeClientTarget, - mutate: StructuredAgentSessionMutate, + action: StructuredAgentSessionAction | null, turnId: string | undefined, prompt?: { itemId: string; expectedRevision: number } ) { + if (!action) { + return null + } + const promptTarget = prompt && { ...prompt } const promptSupported = - prompt !== undefined && (await supportsStructuredAgentSessionPromptCancel(target)) - return mutate('agentSession.cancel', 'agentSession.cancel', { + promptTarget !== undefined && (await supportsStructuredAgentSessionPromptCancel(action.target)) + return action.mutate('agentSession.cancel', 'agentSession.cancel', { ...(turnId ? { turnId } : {}), - ...(promptSupported ? { prompt } : {}) + ...(promptSupported ? { prompt: promptTarget } : {}) }) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index ffc541eb3525..28ce644de1ac 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -235,6 +235,27 @@ it.each([ expect(isCurrent()).toBe(false) }) +it('keeps pending legacy Stops independent across semantic hosts', async () => { + const first = Promise.withResolvers() + mocks.call.mockReturnValueOnce(first.promise).mockResolvedValue({ ok: true, value: 'B' }) + const view = renderHook( + ({ environmentId }) => + useStructuredAgentSessionMutate({ + sessionId: 'session-1', + target: { kind: 'environment', environmentId }, + stateRef: { current: { fence: 3 } } + }), + { initialProps: { environmentId: 'A' } } + ) + const pendingA = stop(view.result.current) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + view.rerender({ environmentId: 'B' }) + expect(await stop(view.result.current)).toBe('B') + expect(mocks.call.mock.calls.map(([target]) => target.environmentId)).toEqual(['A', 'B']) + first.resolve({ ok: true, value: 'A' }) + expect(await pendingA).toBe('A') +}) + describe('a Stop pressed again, against a host without the quiet repeated Stop', () => { it('joins the one still in flight instead of sending a second', async () => { const host = heldStops() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 63a2a74285c8..9e55deca595d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -41,6 +41,13 @@ export type StructuredAgentSessionWrite = ( /** A write whose failure is shown as a toast; the control that sent it is the way to try again. */ export type StructuredAgentSessionMutate = (...args: WriteArgs) => Promise +export type StructuredAgentSessionAction = { + target: RuntimeClientTarget + write: StructuredAgentSessionWrite + mutate: StructuredAgentSessionMutate +} +export type AdmitStructuredAgentSessionAction = () => StructuredAgentSessionAction | null + /** Captures the issuing pane visit for late presentation, never command or draft settlement. */ export type CaptureStructuredAgentSessionPresentation = () => () => boolean @@ -83,20 +90,18 @@ export function useStructuredAgentSessionMutate(args: { }): { write: StructuredAgentSessionWrite mutate: StructuredAgentSessionMutate + admit: AdmitStructuredAgentSessionAction capturePresentation: CaptureStructuredAgentSessionPresentation } { const { agentName, enabled = true, sessionId, stateRef, target } = args const inFlightStops = useRef( new Map>>() ) - const enabledRef = useRef(enabled) const presentation = useRef(null) const hostKey = structuredAgentSessionHostKey(target) useLayoutEffect(() => { - enabledRef.current = enabled presentation.current = enabled ? {} : null return () => { - enabledRef.current = false presentation.current = null } }, [enabled, hostKey, sessionId]) @@ -152,16 +157,17 @@ export function useStructuredAgentSessionMutate(args: { [agentName, sessionId, target] ) - const write = useCallback( - async ( + const admit = useCallback((): StructuredAgentSessionAction | null => { + const pressedFence = stateRef.current.fence + if (!enabled || presentation.current === null || pressedFence === null) { + return null + } + const isCurrent = capturePresentation() + const write: StructuredAgentSessionWrite = async ( method: string, fingerprintMethod: string, fields: Record ): Promise> => { - const pressedFence = stateRef.current.fence - if (!enabled || !enabledRef.current || pressedFence === null) { - return { kind: 'dropped' } - } if ( !namesWhatItStops(fingerprintMethod, fields) || (await supportsStructuredAgentSessionQuietRepeatedStop(target)) @@ -169,7 +175,7 @@ export function useStructuredAgentSessionMutate(args: { return send(method, fingerprintMethod, fields, pressedFence) } // Temporary, for a host that predates the quiet repeated Stop: remove once none is supported. - const key = `${sessionId}:${method}:${JSON.stringify(fields)}` + const key = `${hostKey}:${sessionId}:${method}:${JSON.stringify(fields)}` const joined = inFlightStops.current.get(key) if (joined) { // The first press reports a failure; the one that joined it stays quiet. @@ -189,21 +195,27 @@ export function useStructuredAgentSessionMutate(args: { } }) return stopping - }, - [enabled, send, sessionId, stateRef, target] - ) - - const mutate = useCallback( - async (...writeArgs: WriteArgs): Promise => { - const isCurrent = capturePresentation() + } + const mutate: StructuredAgentSessionMutate = async (...writeArgs: WriteArgs) => { const outcome = await write(...writeArgs) if (outcome.kind === 'not-done' && isCurrent()) { toast.error(outcome.notice) } return outcome.kind === 'done' ? outcome.value : null - }, - [capturePresentation, write] + } + return { target, write, mutate } + }, [capturePresentation, enabled, hostKey, send, sessionId, stateRef, target]) + + const write = useCallback( + async (...writeArgs: WriteArgs): Promise> => + admit()?.write(...writeArgs) ?? { kind: 'dropped' }, + [admit] + ) + const mutate = useCallback( + async (...writeArgs: WriteArgs): Promise => + admit()?.mutate(...writeArgs) ?? null, + [admit] ) - return { write, mutate, capturePresentation } + return { write, mutate, admit, capturePresentation } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx index 5d43c6e3b777..d801fb85f6f6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx @@ -5,20 +5,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn(), + toast: vi.fn(), promptCancelSupported: vi.fn(), questionAnswersSupported: vi.fn(), operationId: vi.fn(() => 'operation-1') })) +vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionQuietRepeatedStop: async () => true, supportsStructuredAgentSessionPromptCancel: mocks.promptCancelSupported, supportsStructuredAgentSessionQuestionAnswers: mocks.questionAnswersSupported })) vi.mock('./use-structured-agent-session-read', () => ({ useStructuredAgentSessionRead: () => ({ state: { - fence: 3, + fence, items, submissions: [], status: 'ready', @@ -49,6 +52,7 @@ import { type StructuredPromptItem } from './use-structured-agent-session' +let fence = 3 let items: AgentJournalRenderItem[] = [] const target = { kind: 'local' } as const @@ -99,28 +103,60 @@ function respondCall(): Record | undefined { describe('desktop structured prompt answers', () => { beforeEach(() => { vi.clearAllMocks() + fence = 3 items = [question, approval] mocks.call.mockResolvedValue({ ok: true, value: { itemId: 'question-1', revision: 3 } }) }) - it('sends structured answers to a host that takes them', async () => { - mocks.questionAnswersSupported.mockResolvedValue(true) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - target, - agent: 'claude', - isVisible: true - }) + it.each(['answers', 'cancel'] as const)('owns %s through preparation', async (kind) => { + const support = Promise.withResolvers() + mocks.questionAnswersSupported.mockReturnValue(support.promise) + mocks.promptCancelSupported.mockReturnValue(support.promise) + const original = { kind: 'environment' as const, environmentId: 'host-A' } + const view = renderHook( + ({ target }) => + useStructuredAgentSession({ + sessionId: 'session-1', + target, + agent: 'claude', + isVisible: true + }), + { initialProps: { target: original } } ) - - await act(async () => { - await result.current.respond(question, { kind: 'answers', answers }) + const prompt = { itemId: question.itemId, expectedRevision: question.revision } + const pending = + kind === 'answers' + ? view.result.current.respond(question, { kind: 'answers', answers }) + : view.result.current.cancel('turn-1', prompt) + expect( + mocks.call.mock.calls.filter( + ([, method]) => + method === (kind === 'answers' ? 'agentSession.respondToQuestion' : 'agentSession.cancel') + ) + ).toEqual([]) + fence = 4 + view.rerender({ target: { ...original, environmentId: 'host-B' } }) + view.unmount() + if (kind === 'cancel') { + mocks.call.mockResolvedValue({ ok: false, refusal: { code: 'agent_session_conflict' } }) + } + support.resolve(true) + expect(await pending).toEqual(kind === 'answers' ? { itemId: 'question-1', revision: 3 } : null) + const calls = mocks.call.mock.calls.filter( + ([, method]) => + method === (kind === 'answers' ? 'agentSession.respondToQuestion' : 'agentSession.cancel') + ) + expect(calls).toHaveLength(1) + const [rpcTarget, method, params] = calls[0] + expect(rpcTarget).toEqual(original) + expect(method).toBe( + kind === 'answers' ? 'agentSession.respondToQuestion' : 'agentSession.cancel' + ) + expect(params).toEqual({ + envelope: expect.objectContaining({ sessionId: 'session-1', expectedRuntimeFence: 3 }), + ...(kind === 'answers' ? { ...prompt, answers } : { turnId: 'turn-1', prompt }) }) - - expect(mocks.questionAnswersSupported).toHaveBeenCalledWith(target) - expect(respondCall()).toMatchObject({ itemId: 'question-1', expectedRevision: 2, answers }) - expect(respondCall()).not.toHaveProperty('optionId') + expect(mocks.toast).not.toHaveBeenCalled() }) it('packs the answer into an option id for a host that predates structured answers', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index da17aaf68235..cabce2784e73 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -63,7 +63,7 @@ function renderController(initialProps: ControllerInput = {}) { const stateRef = { current: { fence: 1 } } return renderHook( (input: ControllerInput) => { - const { mutate, write, capturePresentation } = useStructuredAgentSessionMutate({ + const { mutate, write, admit, capturePresentation } = useStructuredAgentSessionMutate({ sessionId: 'session-1', target: { kind: 'local' }, stateRef @@ -77,6 +77,7 @@ function renderController(initialProps: ControllerInput = {}) { isWorking: false, composerScopeKey: undefined, mutate, + admit, capturePresentation, editTransport: { ...NO_EDIT, write } }) @@ -194,7 +195,7 @@ function resumedResult(resumed = true) { /** The queue controller as a pane builds it, over the real mutation. */ function useQueue(sessionId = 'session-1', fence: number | null = 1) { - const { mutate, capturePresentation } = useStructuredAgentSessionMutate({ + const { mutate, admit, capturePresentation } = useStructuredAgentSessionMutate({ sessionId, target: { kind: 'local' }, stateRef: { current: { fence } } @@ -208,6 +209,7 @@ function useQueue(sessionId = 'session-1', fence: number | null = 1) { isWorking: false, composerScopeKey: undefined, mutate, + admit, capturePresentation, editTransport: NO_EDIT }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index aedbd554122e..a857ca6c9300 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -3,11 +3,14 @@ // The queued-message controller: which RPC each card action issues, and that a host which cannot // edit in place gets no Edit at all, never the old copy-and-delete. +import { toast } from 'sonner' import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -16,6 +19,7 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' +import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import type { StructuredAgentSessionMutate, StructuredAgentSessionWrite @@ -40,25 +44,25 @@ function createHarness( enabled?: boolean composerScopeKey?: string | undefined mutateResult?: (call: MutateCall) => unknown - clearPending?: Promise sending?: StructuredAgentSessionPendingSend[] } = {} ) { const mutateCalls: MutateCall[] = [] - const writeCalls: MutateCall[] = [] - const clearWrite: StructuredAgentSessionWrite = async (...call: MutateCall) => { - writeCalls.push(call) - await overrides.clearPending - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Clear calls this test writer only for queuedMessageDelete. - return { kind: 'done', value: { deleted: true, messageId: call[2].messageId } as T } - } const mutate = vi.fn(async (...call: MutateCall) => { mutateCalls.push(call) return overrides.mutateResult ? overrides.mutateResult(call) : null }) + const mutation = renderHook(() => + useStructuredAgentSessionMutate({ + sessionId: 'session', + target: { kind: 'local' }, + stateRef: { current: { fence: 3 } } + }) + ) const rendered = renderHook(() => useStructuredAgentSessionQueuedMessages({ - capturePresentation: () => () => true, + capturePresentation: mutation.result.current.capturePresentation, + admit: mutation.result.current.admit, enabled: overrides.enabled ?? true, queuedMessages: overrides.queuedMessages ?? [draft('draft-1', 1), draft('draft-2', 2)], queuePause: null, @@ -73,11 +77,11 @@ function createHarness( target: { kind: 'local' }, sessionId: 'session', capable: false, - write: clearWrite + write: mutation.result.current.write } }) ) - return { ...rendered, mutate, mutateCalls, writeCalls, clearWrite } + return { ...rendered, mutate, mutateCalls, dispose: mutation.unmount } } beforeEach(() => { @@ -89,11 +93,20 @@ afterEach(() => { }) describe('queued message actions', () => { - it('Clear deletes eight at a time and a repeated Clear sends nothing more', async () => { + it.each(['success', 'current', 'obsolete'])('Clear owns ten deletes: %s', async (outcome) => { const ids = Array.from({ length: 10 }, (_, index) => `draft-${index + 1}`) const release = Promise.withResolvers() + let active = 0 + let maximum = 0 + rpc.mockImplementation(async (_target, _method, params) => { + maximum = Math.max(maximum, ++active) + await release.promise + active -= 1 + return outcome !== 'success' && params.messageId === ids[9] + ? { ok: false, refusal: { code: 'agent_session_conflict' } } + : { ok: true, value: { deleted: true } } + }) const harness = createHarness({ - clearPending: release.promise, queuedMessages: ids.map((id, index) => draft(id, index + 1)) }) let pending: Promise = Promise.resolve(false) @@ -103,11 +116,21 @@ describe('queued message actions', () => { expect(await harness.result.current.resume()).toBe(false) }) expect(harness.mutateCalls).toEqual([]) - expect(harness.writeCalls.map(([, , fields]) => fields.messageId)).toEqual(ids.slice(0, 8)) + expect(rpc.mock.calls.map(([, , fields]) => fields.messageId)).toEqual(ids.slice(0, 8)) expect(harness.result.current.queuePending).toBe('clear') + if (outcome === 'obsolete') { + harness.dispose() + } release.resolve() - await act(async () => expect(await pending).toBe(true)) - expect(harness.writeCalls).toHaveLength(ids.length) + await act(async () => expect(await pending).toBe(outcome === 'success')) + expect(rpc.mock.calls.map(([, , fields]) => fields.messageId)).toEqual(ids) + expect(maximum).toBe(8) + for (const [target, , fields] of rpc.mock.calls) { + expect(target).toEqual({ kind: 'local' }) + expect(fields.envelope).toMatchObject({ sessionId: 'session', expectedRuntimeFence: 3 }) + } + expect(ids.every((id) => harness.result.current.pending(id) === null)).toBe(true) + expect(toast.error).toHaveBeenCalledTimes(outcome === 'current' ? 1 : 0) expect(harness.result.current.queuePending).toBeNull() }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 141617d55a1d..340273556582 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -29,6 +29,7 @@ import { type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { + AdmitStructuredAgentSessionAction, CaptureStructuredAgentSessionPresentation, StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -93,11 +94,12 @@ export function useStructuredAgentSessionQueuedMessages(args: { sending?: readonly StructuredAgentSessionPendingSend[] composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate + admit: AdmitStructuredAgentSessionAction capturePresentation: CaptureStructuredAgentSessionPresentation editTransport: QueuedEditTransport }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - const { capturePresentation, isWorking, queuePause } = args + const { admit, capturePresentation, isWorking, queuePause } = args const sending = args.sending ?? NO_SENDS const cards = useMemo( @@ -230,11 +232,15 @@ export function useStructuredAgentSessionQueuedMessages(args: { const isCurrent = capturePresentation() // Refused while any of these cards is in flight; each shows busy until the whole Clear settles. return run([QUEUE_KEY, ...messageIds], 'clear', async () => { + const action = admit() + if (!action) { + return false + } const results = await mapSettledWithConcurrency( messageIds, CLEAR_DELETE_CONCURRENCY, (messageId) => - args.editTransport.write( + action.write( 'agentSession.queuedMessageDelete', 'agentSession.queuedMessageDelete', { messageId } @@ -261,7 +267,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { results.every((result) => result.status === 'fulfilled' && result.value.kind === 'done') ) }) - }, [args.editTransport, capturePresentation, clearCapable, run]) + }, [admit, capturePresentation, clearCapable, run]) return { cards, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 99418433d96e..5279ed3a4aca 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -74,9 +74,7 @@ export function useStructuredAgentSession(args: { }) { const { agent, - composerScopeKey, hostStopping = false, - isVisible, launch, providerStarting = false, queueFollowUps = true, @@ -93,12 +91,13 @@ export function useStructuredAgentSession(args: { loadOlder, mutate, write, + admit, capturePresentation, providerVisible } = useStructuredAgentSessionTransport({ sessionId, target, - isVisible, + isVisible: args.isVisible, enabled: transportEnabled, agentName: structuredAgentLabel(agent) }) @@ -123,7 +122,7 @@ export function useStructuredAgentSession(args: { sessionId, target, transportEnabled, - isVisible, + isVisible: args.isVisible, providerVisible, providerStarting, ...(args.providerRunning ? { providerRunning: true } : {}), @@ -189,7 +188,7 @@ export function useStructuredAgentSession(args: { const rewind = useStructuredAgentSessionRewind({ sessionId, target, - composerScopeKey, + composerScopeKey: args.composerScopeKey, ...args.rewind, state, support: transportEnabled ? rewindSupport : undefined, @@ -245,9 +244,10 @@ export function useStructuredAgentSession(args: { isWorking, // Hidden from the transcript, a queue send on its way reads as sending among the cards. sending: pending, - composerScopeKey, + composerScopeKey: args.composerScopeKey, mutate, editTransport, + admit, capturePresentation }) return { @@ -316,7 +316,8 @@ export function useStructuredAgentSession(args: { /** A send made now while the agent works is held as a queued card: the host queues, and this * send asks it to (the setting is on and no pending prompt blocks the queue). */ sendsQueue: structuredAgentSessionNewSendsQueue(queue), - cancel: cancelStructuredAgentSessionPrompt.bind(null, target, mutate), + cancel: (turnId: string | undefined, prompt?: { itemId: string; expectedRevision: number }) => + cancelStructuredAgentSessionPrompt(admit(), turnId, prompt), stopBackgroundTask: (taskId?: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId: 'background-tasks', @@ -324,7 +325,7 @@ export function useStructuredAgentSession(args: { ...(taskId ? { taskId } : {}) }), respond: (item: StructuredPromptItem, response: AgentSessionPromptResponse) => - respondToStructuredAgentSessionPrompt({ item, response, target, mutate }), + respondToStructuredAgentSessionPrompt({ item, response, action: admit() }), optionSnapshot, optionSurface, sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined, From 17fd9585dfafc1e9280fd5f3eaf84f1d3910d4cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:29:26 -0700 Subject: [PATCH 10/11] fix(native-chat): preserve shared Stop failure reporting --- ...e-structured-agent-session-mutate.test.tsx | 51 +++++++++++++++++-- .../use-structured-agent-session-mutate.ts | 38 +++++++------- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index 28ce644de1ac..803bfd1930ea 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -36,6 +36,10 @@ type Answer = | { ok: false; refusal: { code: string; message: string } } const LOST = new Error('the connection dropped before the host answered') +const CONFLICT = { + ok: false, + refusal: { code: 'agent_session_conflict', message: 'The chat changed.' } +} as const /** The host's ledger: an id it already ran gets that answer again, and nothing runs. */ function ledgerHost(run: (method: string, params: Params) => Answer) { @@ -235,7 +239,7 @@ it.each([ expect(isCurrent()).toBe(false) }) -it('keeps pending legacy Stops independent across semantic hosts', async () => { +it.each([false, true])('reports only current host Stops (%s)', async (reportCurrent) => { const first = Promise.withResolvers() mocks.call.mockReturnValueOnce(first.promise).mockResolvedValue({ ok: true, value: 'B' }) const view = renderHook( @@ -251,9 +255,29 @@ it('keeps pending legacy Stops independent across semantic hosts', async () => { await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) view.rerender({ environmentId: 'B' }) expect(await stop(view.result.current)).toBe('B') + view.rerender({ environmentId: 'A' }) + const currentPresses = reportCurrent ? [stop(view.result.current), stop(view.result.current)] : [] + const raw = view.result.current.write('agentSession.cancel', 'agentSession.cancel', { + turnId: 'turn-1' + }) + await vi.waitFor(() => + expect(mocks.hostAnswersRepeatedStops).toHaveBeenCalledTimes(reportCurrent ? 5 : 3) + ) + first.resolve(CONFLICT) + expect(await Promise.all([pendingA, ...currentPresses])).toEqual( + reportCurrent ? [null, null, null] : [null] + ) + const outcome = await raw + expect(outcome).toMatchObject({ + kind: 'not-done', + notice: expect.any(String), + failure: { kind: 'refused', code: 'agent_session_conflict' } + }) + expect(mocks.toastError).toHaveBeenCalledTimes(reportCurrent ? 1 : 0) + if (reportCurrent && outcome.kind === 'not-done') { + expect(mocks.toastError).toHaveBeenCalledWith(outcome.notice) + } expect(mocks.call.mock.calls.map(([target]) => target.environmentId)).toEqual(['A', 'B']) - first.resolve({ ok: true, value: 'A' }) - expect(await pendingA).toBe('A') }) describe('a Stop pressed again, against a host without the quiet repeated Stop', () => { @@ -294,6 +318,27 @@ describe('a Stop pressed again, against a host without the quiet repeated Stop', expect(mocks.toastError).toHaveBeenCalledOnce() }) + it('keeps repeated raw failures truthful without reporting them', async () => { + const failure = Promise.withResolvers() + mocks.call.mockReturnValue(failure.promise) + const { current } = render() + const writes = [ + current.write('agentSession.cancel', 'agentSession.cancel', { turnId: 'turn-1' }), + current.write('agentSession.cancel', 'agentSession.cancel', { turnId: 'turn-1' }) + ] + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + failure.resolve(CONFLICT) + for (const outcome of await Promise.all(writes)) { + expect(outcome).toMatchObject({ + kind: 'not-done', + notice: expect.any(String), + failure: { kind: 'refused', code: 'agent_session_conflict' } + }) + } + expect(mocks.call).toHaveBeenCalledOnce() + expect(mocks.toastError).not.toHaveBeenCalled() + }) + it('sends a new Stop once the first has settled', async () => { const host = heldStops() const { current } = render() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 9e55deca595d..a7987798f7c8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -29,9 +29,13 @@ export type StructuredAgentSessionWriteOutcome = | { kind: 'done'; value: T } /** Refused or failed, with what to tell the person and the fact it was worded from. */ | { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } - /** Not dispatched, or a joined duplicate Stop whose failure was already reported. */ + /** Unavailable at entry, so nothing was dispatched. */ | { kind: 'dropped' } +type ExecutionResult = StructuredAgentSessionWriteOutcome & { + reportFailure?: (notice: string) => void +} + type WriteArgs = [method: string, fingerprintMethod: string, fields: Record] export type StructuredAgentSessionWrite = ( @@ -67,7 +71,7 @@ function notDone( fingerprintMethod: string, fields: Record, agentName: string | undefined -): { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } { +): ExecutionResult { return { kind: 'not-done', notice: agentSessionWriteFailureText( @@ -75,7 +79,8 @@ function notDone( writeKind(fingerprintMethod, fields), agentName ? { agentName } : {} ), - failure + failure, + reportFailure: toast.error } } @@ -94,9 +99,7 @@ export function useStructuredAgentSessionMutate(args: { capturePresentation: CaptureStructuredAgentSessionPresentation } { const { agentName, enabled = true, sessionId, stateRef, target } = args - const inFlightStops = useRef( - new Map>>() - ) + const inFlightStops = useRef(new Map>>()) const presentation = useRef(null) const hostKey = structuredAgentSessionHostKey(target) useLayoutEffect(() => { @@ -117,7 +120,7 @@ export function useStructuredAgentSessionMutate(args: { fields: Record, /** The fence when the press happened, read before any await, so a write never retargets. */ pressedFence: number - ): Promise> => { + ): Promise> => { let result: AgentSessionMutationResult try { result = await callStructuredAgentSession>(target, method, { @@ -163,11 +166,11 @@ export function useStructuredAgentSessionMutate(args: { return null } const isCurrent = capturePresentation() - const write: StructuredAgentSessionWrite = async ( + const execute = async ( method: string, fingerprintMethod: string, fields: Record - ): Promise> => { + ): Promise> => { if ( !namesWhatItStops(fingerprintMethod, fields) || (await supportsStructuredAgentSessionQuietRepeatedStop(target)) @@ -178,13 +181,8 @@ export function useStructuredAgentSessionMutate(args: { const key = `${hostKey}:${sessionId}:${method}:${JSON.stringify(fields)}` const joined = inFlightStops.current.get(key) if (joined) { - // The first press reports a failure; the one that joined it stays quiet. - return joined.then((outcome) => - outcome.kind === 'not-done' - ? { kind: 'dropped' } - : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T. - (outcome as StructuredAgentSessionWriteOutcome) - ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key names the method and fields, so the joined promise is this very call and settles to its T. + return joined as Promise> } const stopping = send(method, fingerprintMethod, fields, pressedFence) inFlightStops.current.set(key, stopping) @@ -197,13 +195,15 @@ export function useStructuredAgentSessionMutate(args: { return stopping } const mutate: StructuredAgentSessionMutate = async (...writeArgs: WriteArgs) => { - const outcome = await write(...writeArgs) + const outcome = await execute(...writeArgs) if (outcome.kind === 'not-done' && isCurrent()) { - toast.error(outcome.notice) + const report = outcome.reportFailure + outcome.reportFailure = undefined + report?.(outcome.notice) } return outcome.kind === 'done' ? outcome.value : null } - return { target, write, mutate } + return { target, write: execute, mutate } }, [capturePresentation, enabled, hostKey, send, sessionId, stateRef, target]) const write = useCallback( From f8269878a1af8395436ab49d22edfcd1183be0d0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:20:43 -0700 Subject: [PATCH 11/11] test(native-chat): cover already-sent queue notices after disposal --- ...red-agent-session-queued-messages.test.tsx | 61 ++++++++++++++++--- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index a857ca6c9300..26badfdc8089 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -6,7 +6,10 @@ import { toast } from 'sonner' import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuedMessageDeleteResult +} from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' const rpc = vi.hoisted(() => vi.fn()) @@ -93,7 +96,14 @@ afterEach(() => { }) describe('queued message actions', () => { - it.each(['success', 'current', 'obsolete'])('Clear owns ten deletes: %s', async (outcome) => { + const clearOutcomes = [ + 'success', + 'current', + 'obsolete', + 'dispatched-current', + 'dispatched-obsolete' + ] + it.each(clearOutcomes)('Clear owns ten deletes: %s', async (outcome) => { const ids = Array.from({ length: 10 }, (_, index) => `draft-${index + 1}`) const release = Promise.withResolvers() let active = 0 @@ -102,9 +112,18 @@ describe('queued message actions', () => { maximum = Math.max(maximum, ++active) await release.promise active -= 1 - return outcome !== 'success' && params.messageId === ids[9] - ? { ok: false, refusal: { code: 'agent_session_conflict' } } - : { ok: true, value: { deleted: true } } + if (outcome === 'success' || params.messageId !== ids[9]) { + return { ok: true, value: { deleted: true, messageId: params.messageId } } + } + return outcome.startsWith('dispatched') + ? { + ok: true, + value: { deleted: false, messageId: params.messageId, disposition: 'dispatched' } + } + : { + ok: false, + refusal: { code: 'agent_session_conflict', message: 'The session moved on.' } + } }) const harness = createHarness({ queuedMessages: ids.map((id, index) => draft(id, index + 1)) @@ -118,7 +137,7 @@ describe('queued message actions', () => { expect(harness.mutateCalls).toEqual([]) expect(rpc.mock.calls.map(([, , fields]) => fields.messageId)).toEqual(ids.slice(0, 8)) expect(harness.result.current.queuePending).toBe('clear') - if (outcome === 'obsolete') { + if (outcome.endsWith('obsolete')) { harness.dispose() } release.resolve() @@ -130,8 +149,12 @@ describe('queued message actions', () => { expect(fields.envelope).toMatchObject({ sessionId: 'session', expectedRuntimeFence: 3 }) } expect(ids.every((id) => harness.result.current.pending(id) === null)).toBe(true) - expect(toast.error).toHaveBeenCalledTimes(outcome === 'current' ? 1 : 0) + expect(toast.error).toHaveBeenCalledTimes(outcome.endsWith('current') ? 1 : 0) + if (outcome === 'dispatched-current') { + expect(toast.error).toHaveBeenCalledWith('This message was already sent.') + } expect(harness.result.current.queuePending).toBeNull() + expect(harness.result.current.clearCapable).toBe(true) }) it('keeps Clear all unavailable while a local send has no host card', async () => { @@ -178,14 +201,32 @@ describe('queued message actions', () => { expect(disabled.result.current.steerNewest()).toBe(false) }) - it('Delete withdraws through queuedMessageDelete without touching the composer', async () => { - const harness = createHarness() - await act(() => harness.result.current.remove('draft-1')) + it.each(['current', 'obsolete'])('Delete already sent: %s', async (presentation) => { + const answer = Promise.withResolvers() + const harness = createHarness({ mutateResult: () => answer.promise }) + let pending: Promise = Promise.resolve() + act(() => { + pending = harness.result.current.remove('draft-1') + }) + expect(harness.result.current.pending('draft-1')).toBe('delete') + expect(harness.result.current.clearCapable).toBe(false) expect(harness.mutate).toHaveBeenCalledWith( 'agentSession.queuedMessageDelete', 'agentSession.queuedMessageDelete', { messageId: 'draft-1' } ) + if (presentation === 'obsolete') { + harness.dispose() + } + await act(async () => { + answer.resolve({ deleted: false, messageId: 'draft-1', disposition: 'dispatched' }) + await pending + }) + expect(vi.mocked(toast.error).mock.calls).toEqual( + presentation === 'current' ? [['This message was already sent.']] : [] + ) + expect(harness.result.current.pending('draft-1')).toBeNull() + expect(harness.result.current.clearCapable).toBe(true) expect(readNativeChatDraftCache(SCOPE)).toBe('') })