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,