From c84817e8f916a879c3ff082f2ae835a8d8a5c1fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:11:05 -0700 Subject: [PATCH] fix(native-chat): preserve accepted command outcomes across agent startup (#31605) * 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. * fix(native-chat): settle each command independently of runtime changes * fix(native-chat): scope rewind timeout feedback to its issuing pane * test: use checked native chat command fixtures * test: complete native chat presentation fixtures * fix(native-chat): admit Stop before capability discovery * test(native-chat): consolidate overlapping startup coverage * test(native-chat): preserve IME composition on goal acceptance * fix(native-chat): capture each action before async preparation * fix(native-chat): preserve shared Stop failure reporting * test(native-chat): cover already-sent queue notices after disposal --- .../NativeChatComposer.paste-remount.test.tsx | 1 + ...NativeChatComposer.resume-command.test.tsx | 1 + .../native-chat/NativeChatComposer.test.tsx | 33 ++-- .../NativeChatInterruptedContinue.test.tsx | 3 + ...veChatQueuedMessageList.in-flight.test.tsx | 2 + .../NativeChatQueuedMessageList.test.tsx | 2 + ...ive-chat-composer-draft-lifecycle.test.tsx | 1 + ...ative-chat-composer-prompt-recall.test.tsx | 1 + .../native-chat/native-chat-composer-types.ts | 2 + .../native-chat-image-references.test.tsx | 1 + ...structured-send-composition-clear.test.tsx | 20 +- ...native-chat-submission-navigation.test.tsx | 2 + ...tructured-agent-session-prompt-response.ts | 43 +++-- ...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 | 39 +++- .../native-chat/use-native-chat-rewind.ts | 51 ++--- ...ive-chat-structured-composer-send.test.tsx | 28 ++- ...se-native-chat-structured-composer-send.ts | 22 ++- ...tive-chat-structured-composer-transport.ts | 1 + ...ent-session-command-start-failure.test.tsx | 82 ++------ ...e-structured-agent-session-mutate.test.tsx | 98 ++++++++++ .../use-structured-agent-session-mutate.ts | 182 +++++++++--------- ...ed-agent-session-question-answers.test.tsx | 70 +++++-- ...nt-session-queued-messages.resume.test.tsx | 17 +- ...red-agent-session-queued-messages.test.tsx | 103 ++++++++-- ...tructured-agent-session-queued-messages.ts | 28 ++- .../use-structured-agent-session-rewind.ts | 3 + .../use-structured-agent-session.test.tsx | 4 +- .../use-structured-agent-session.ts | 44 +++-- ...ructured-native-chat-submit-reveal.test.ts | 7 +- ...se-structured-native-chat-submit-reveal.ts | 9 +- ...gent-session-clear-old-client.unit.test.ts | 1 + 34 files changed, 620 insertions(+), 313 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 33ad68c2d74..e6fbf1adac9 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 @@ -117,6 +117,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.resume-command.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx index da02a707470..7e011d5733e 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/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index bfcc34d990a..f2f73c7646a 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 c29edceb3ee..fb1131566c8 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, @@ -388,6 +389,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 +516,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 0340ae3a757..b5b57e43c38 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,8 @@ 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, 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 75ea2a0ad79..6ca34cfa942 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -118,6 +118,8 @@ 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, 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 6577535fda5..3b31b42100e 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-prompt-recall.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx index a38edf40ba8..bc44e2d7d16 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/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 9687c8e2145..9e79e3ea9a6 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 634e7513e8e..ee216be37f8 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 be12cbc7c86..c447371500b 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 @@ -93,6 +93,7 @@ function transport( overrides: Partial = {} ): NativeChatStructuredComposerTransport { return { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: vi.fn(async () => PASS_THROUGH), optionsSurface: { @@ -148,26 +149,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('하', []) 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 b2900615796..3a3795780a8 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, { @@ -395,6 +396,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-agent-session-prompt-response.ts b/src/renderer/src/components/native-chat/structured-agent-session-prompt-response.ts index ab0dc5e6777..10b0f636e1c 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/structured-session-takeover-report.test.tsx b/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx index 6a1ba6d6e29..57734ba5955 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 2816b288e57..10527158d61 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 @@ -40,11 +40,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 } : {}) @@ -255,6 +257,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 a7093779628..c86f67a4b1c 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 @@ -96,17 +96,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 5e7ce18d1ac..5aaff91eb64 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() @@ -347,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 cad01da38cc..ad63590e33e 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) @@ -150,6 +152,7 @@ export function useNativeChatRewind(input: RewindInput) { sessionId: string epoch: string sequence?: number + isCurrent: () => boolean } | null>(null) const awaitingReset = awaiting?.sessionId === input.sessionId && @@ -159,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) { @@ -170,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) @@ -193,6 +194,7 @@ export function useNativeChatRewind(input: RewindInput) { ) { return } + const isCurrent = captured.capturePresentation() const expectedEpoch = captured.state.epoch! inFlight.current = true let keepBlocked = false @@ -209,7 +211,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 +234,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 && @@ -249,6 +258,7 @@ export function useNativeChatRewind(input: RewindInput) { : { sessionId: captured.sessionId, epoch: expectedEpoch, + isCurrent, ...(outcome.value.sequence !== undefined ? { sequence: outcome.value.sequence } : {}) @@ -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 5467bb6cc4e..4df7df2f52d 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,7 +25,8 @@ function harness( threadGoal?: NativeChatStructuredComposerTransport['threadGoal'], onSubmitted?: () => void ) { - const structuredTransport = { + const structuredTransport: NativeChatStructuredComposerTransport = { + capturePresentation: () => () => true, send: vi.fn(() => true), dispatchCommand: (text: string) => dispatchStructuredAgentSessionComposerCommand(text, { @@ -36,12 +37,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 } @@ -201,6 +208,23 @@ 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() +}) + it('checks both the picked command and its draft, and projects completed paths', async () => { const { send, structuredTransport } = harness('claude') const id = '00000000-0000-4000-8000-000000000000' 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 ca0cb2f70e1..0bf3f9fb648 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 @@ -107,6 +107,7 @@ export function useNativeChatStructuredComposerSend({ if (hostCommand && structuredAgentSessionCommandChangesConversation(text)) { structuredTransport.onSubmitted?.() } + const isCurrent = structuredTransport.capturePresentation() const submitted = readNativeChatComposerDraft(draftScopeKey) await dispatchNativeChatStructuredComposerText( structuredTransport, @@ -115,11 +116,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( @@ -129,6 +136,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() @@ -137,6 +152,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 64dfb4145d7..e1242031bac 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 f1aaae9488d..a93570077f8 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,13 @@ 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.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( () => @@ -272,69 +258,34 @@ 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)) 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 +293,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 f3df62da35a..803bfd1930e 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) { @@ -203,6 +207,79 @@ 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) +}) + +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( + ({ 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') + 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']) +}) + 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() @@ -241,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 bdbbe492dc1..a7987798f7c 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 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. +// 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,15 +22,20 @@ 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. */ + /** 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 = ( @@ -43,6 +45,16 @@ 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 + /** 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 { @@ -59,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( @@ -67,7 +79,8 @@ function notDone( writeKind(fingerprintMethod, fields), agentName ? { agentName } : {} ), - failure + failure, + reportFailure: toast.error } } @@ -75,28 +88,30 @@ 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 + admit: AdmitStructuredAgentSessionAction + capturePresentation: CaptureStructuredAgentSessionPresentation } { const { agentName, enabled = true, sessionId, stateRef, target } = args - const inFlightStops = useRef( - new Map>>() - ) - const enabledRef = useRef(enabled) - // The conversation command or rewind 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. - enabledRef.current = enabled - commandInFlight.current = null - }, [enabled, sessionId]) + const inFlightStops = useRef(new Map>>()) + const presentation = useRef(null) + const hostKey = structuredAgentSessionHostKey(target) + useLayoutEffect(() => { + presentation.current = enabled ? {} : null + return () => { + presentation.current = null + } + }, [enabled, hostKey, sessionId]) + const capturePresentation = useCallback(() => { + const visit = presentation.current + return () => visit !== null && presentation.current === visit + }, []) const send = useCallback( async ( @@ -104,37 +119,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 - ): Promise> => { - if (!enabled || !enabledRef.current || pressedFence === null) { - return { kind: 'dropped' } - } - const targetFence = pressedFence - const request = {} - if ( - fingerprintMethod === 'agentSession.conversationCommand' || - fingerprintMethod === 'agentSession.rewind' - ) { - 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. - const settlesHere = (): boolean => { - const waitedOn = commandInFlight.current === request - if (waitedOn) { - commandInFlight.current = null - } - return enabledRef.current && (stateRef.current.fence === targetFence || waitedOn) - } + pressedFence: number + ): Promise> => { let result: AgentSessionMutationResult try { result = await callStructuredAgentSession>(target, method, { envelope: { sessionId, clientOperationId: structuredSessionOperationId(), - expectedRuntimeFence: targetFence, + expectedRuntimeFence: pressedFence, payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: fingerprintMethod, sessionId, @@ -144,43 +137,40 @@ 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 } }, - [agentName, enabled, sessionId, stateRef, target] + [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 execute = async ( method: string, fingerprintMethod: string, fields: Record - ): Promise> => { - const pressedFence = stateRef.current.fence + ): Promise> => { if ( !namesWhatItStops(fingerprintMethod, fields) || (await supportsStructuredAgentSessionQuietRepeatedStop(target)) @@ -188,16 +178,11 @@ 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. - 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) @@ -208,20 +193,29 @@ export function useStructuredAgentSessionMutate(args: { } }) return stopping - }, - [send, sessionId, stateRef, target] - ) - - const mutate = useCallback( - async (...writeArgs: WriteArgs): Promise => { - const outcome = await write(...writeArgs) - if (outcome.kind === 'not-done') { - toast.error(outcome.notice) + } + const mutate: StructuredAgentSessionMutate = async (...writeArgs: WriteArgs) => { + const outcome = await execute(...writeArgs) + if (outcome.kind === 'not-done' && isCurrent()) { + const report = outcome.reportFailure + outcome.reportFailure = undefined + report?.(outcome.notice) } return outcome.kind === 'done' ? outcome.value : null - }, - [write] + } + return { target, write: execute, 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 } + 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 5d43c6e3b77..d801fb85f6f 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 cb300c9bd82..cabce2784e7 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, admit, capturePresentation } = useStructuredAgentSessionMutate({ sessionId: 'session-1', target: { kind: 'local' }, stateRef @@ -77,6 +77,8 @@ function renderController(initialProps: ControllerInput = {}) { isWorking: false, composerScopeKey: undefined, mutate, + admit, + capturePresentation, editTransport: { ...NO_EDIT, write } }) }, @@ -193,12 +195,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, admit, capturePresentation } = useStructuredAgentSessionMutate({ sessionId, target: { kind: 'local' }, stateRef: { current: { fence } } }) - return useStructuredAgentSessionQueuedMessages({ + const queue = useStructuredAgentSessionQueuedMessages({ enabled: true, queuedMessages: [card('held')], queuePause: { reason: 'stopped' }, @@ -207,8 +209,11 @@ function useQueue(sessionId = 'session-1', fence: number | null = 1) { isWorking: false, composerScopeKey: undefined, mutate, + admit, + capturePresentation, editTransport: NO_EDIT }) + return { ...queue, capturePresentation } } function renderRevealingController(sessionId = 'session-1', fence: number | null = 1) { @@ -216,7 +221,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 f0f691d8db3..26badfdc808 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,17 @@ // 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 { + AgentSessionQueuedMessage, + AgentSessionQueuedMessageDeleteResult +} 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 +22,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,24 +47,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: 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, @@ -72,11 +80,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(() => { @@ -88,11 +96,36 @@ afterEach(() => { }) describe('queued message actions', () => { - it('Clear deletes eight at a time and a repeated Clear sends nothing more', async () => { + 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 + let maximum = 0 + rpc.mockImplementation(async (_target, _method, params) => { + maximum = Math.max(maximum, ++active) + await release.promise + active -= 1 + 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({ - clearPending: release.promise, queuedMessages: ids.map((id, index) => draft(id, index + 1)) }) let pending: Promise = Promise.resolve(false) @@ -102,12 +135,26 @@ 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.endsWith('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.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 () => { @@ -154,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('') }) 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 04a320fc58f..34027355658 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,11 @@ import { sendingQueuedMessageCards, type QueuedMessageCard } from './structured-agent-session-queued-cards' -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { + AdmitStructuredAgentSessionAction, + CaptureStructuredAgentSessionPresentation, + StructuredAgentSessionMutate +} from './use-structured-agent-session-mutate' export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] @@ -90,10 +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 { isWorking, queuePause } = args + const { admit, capturePresentation, isWorking, queuePause } = args const sending = args.sending ?? NO_SENDS const cards = useMemo( @@ -162,18 +168,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,13 +229,18 @@ 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 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 } @@ -244,10 +256,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 +267,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { results.every((result) => result.status === 'fulfilled' && result.value.kind === 'done') ) }) - }, [args.editTransport, clearCapable, run]) + }, [admit, 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 9369c57f86f..065c6fc7fbc 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 db4d2324b59..1d71dd69e68 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 @@ -387,7 +387,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 @@ -420,7 +420,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 b00f134c382..5279ed3a4ac 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,17 +91,20 @@ export function useStructuredAgentSession(args: { loadOlder, mutate, write, + admit, + capturePresentation, providerVisible } = useStructuredAgentSessionTransport({ sessionId, target, - isVisible, + isVisible: args.isVisible, enabled: transportEnabled, agentName: structuredAgentLabel(agent) }) const commandPending = useRef(false) const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId) const transportState = useStructuredAgentSessionTransportState(state, transportEnabled) + const journalItems = transportState.journalItems const { conversationCommands, optionSnapshot, @@ -113,7 +114,7 @@ export function useStructuredAgentSession(args: { unavailable, threadGoal: threadGoalSupport, reportedThreadGoal, - contextUsage: contextUsageSupport, + contextUsage: usageSupport, rewind: rewindSupport, acceptsImages } = useStructuredAgentSessionOptions({ @@ -121,7 +122,7 @@ export function useStructuredAgentSession(args: { sessionId, target, transportEnabled, - isVisible, + isVisible: args.isVisible, providerVisible, providerStarting, ...(args.providerRunning ? { providerRunning: true } : {}), @@ -167,13 +168,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, @@ -191,25 +188,26 @@ export function useStructuredAgentSession(args: { const rewind = useStructuredAgentSessionRewind({ sessionId, target, - composerScopeKey, + composerScopeKey: args.composerScopeKey, ...args.rewind, state, support: transportEnabled ? rewindSupport : undefined, 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 +218,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( @@ -246,9 +244,11 @@ 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 + editTransport, + admit, + capturePresentation }) return { epoch: state.epoch, @@ -312,10 +312,12 @@ 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), - 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', @@ -323,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, @@ -332,7 +334,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 2390979b6f3..8e87f0dadf3 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 60082d7c63f..891f1844a9a 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, 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 d43214047c6..a7a48bd6927 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,