From f26fb1cfdaad94f0935be80e630b61c7eddd4fa1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:33:23 -0700 Subject: [PATCH] Keep async question controls available after agent notices (#35758) * fix(chat): keep async questions open across agent notices * test(chat): cover answering async questions after agent notices --- .../use-mobile-native-chat-prompts.test.ts | 48 ++++++++- ...NativeChatStructuredAsyncQuestion.test.tsx | 100 ++++++++++++++---- .../native-chat/input/native-chat-ask.test.ts | 62 +++++++++-- .../native-chat/input/native-chat-ask.ts | 4 +- 4 files changed, 184 insertions(+), 30 deletions(-) diff --git a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts index 14460896674..eb8f9ddab2e 100644 --- a/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/native-chat/input/use-mobile-native-chat-prompts.test.ts @@ -145,27 +145,69 @@ describe('useMobileNativeChatPrompts in a structured chat', () => { timestamp: null, source: 'transcript' } + const notice: NativeChatMessage = { + id: 'mail', + role: 'user', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }], + timestamp: 2, + source: 'transcript', + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'term_b', + dispatchId: null, + messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }] + } + } + } + let messages = [message] let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ enabled: true, structured: { permission: null, question: null, sendWithOutcome }, status: null, - messages: [message], + messages, transcriptLoading: false }) return null } + let renderer: ReactTestRenderer TestRenderer.act(() => { - renderers.push(TestRenderer.create(createElement(Probe))) + renderer = TestRenderer.create(createElement(Probe)) + renderers.push(renderer) }) + const initialAsk = captured?.ask + expect(initialAsk?.questions[0]?.question).toBe('Which color?') + messages = [message, notice] + TestRenderer.act(() => renderer.update(createElement(Probe))) const ask = captured?.ask - expect(ask?.questions[0]?.question).toBe('Which color?') + expect(ask).toEqual(initialAsk) + expect(ask?.questions[0]?.options.map((option) => option.label)).toEqual(['Red', 'Blue']) await expect(ask && captured?.structuredAnswer?.(ask, [{ indices: [1] }])).resolves.toBe( accepted ) expect(sendWithOutcome).toHaveBeenCalledWith('> Which color?\n\nBlue') + messages = [ + ...messages, + { + id: 'human-answer', + role: 'user', + blocks: [{ type: 'text', text: 'Blue' }], + timestamp: 3, + source: 'transcript' + } + ] + TestRenderer.act(() => renderer.update(createElement(Probe))) + expect(captured?.ask).toBeNull() } ) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.test.tsx index e7aa93b79f5..4d92ce46059 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.test.tsx @@ -3,6 +3,7 @@ import { act } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' import { NativeChatStructuredAsyncQuestion } from './NativeChatStructuredAsyncQuestion' let container: HTMLDivElement @@ -35,24 +36,87 @@ const asked: NativeChatMessage = { } describe('NativeChatStructuredAsyncQuestion', () => { - it('answers a Codex async question with an ordinary send, then hides it', () => { - const send = vi.fn(() => true) - act(() => - root.render( - + it.each([true, false])( + 'keeps an async answer through agent mail (send accepted: %s)', + (accepted) => { + const send = vi.fn(() => accepted) + const render = (messages: NativeChatMessage[]): void => + act(() => + root.render( + + ) + ) + render([asked]) + const input = container.querySelector( + 'input[data-native-chat-answer-field]' + )! + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + setter.call(input, 'Green') + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + const blue = [...container.querySelectorAll('button[aria-pressed]')].find((node) => + node.textContent?.includes('Blue') ) - ) - const blue = [...container.querySelectorAll('button[aria-pressed]')].find((node) => - node.textContent?.includes('Blue') - ) - act(() => blue?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) - act(() => vi.runAllTimers()) + act(() => blue?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + const notice = projectStructuredItemToNativeChat({ + itemId: 'orca:submission:mail', + sequence: 2, + revision: 0, + observedAt: 2, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }], + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'term_b', + dispatchId: null, + messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }] + } + } + } + })! + expect(notice.from?.kind).toBe('agent') + render([asked, notice]) + expect(container.textContent).toContain('Which color?') + expect( + [...container.querySelectorAll('button[aria-pressed]')].map((node) => node.textContent) + ).toEqual( + expect.arrayContaining([expect.stringContaining('Red'), expect.stringContaining('Blue')]) + ) + expect(container.querySelector('button[aria-pressed="true"]')?.textContent).toContain('Blue') + expect( + container.querySelector('input[data-native-chat-answer-field]')?.value + ).toBe('Green') + act(() => vi.runAllTimers()) - expect(send).toHaveBeenCalledWith('> Which color?\n\nBlue', []) - expect(container.textContent).not.toContain('Which color?') - }) + expect(send).toHaveBeenCalledWith('> Which color?\n\nBlue', []) + expect(container.textContent?.includes('Which color?')).toBe(!accepted) + render([ + asked, + notice, + { + id: 'human-answer', + role: 'user', + blocks: [{ type: 'text', text: 'Blue' }], + timestamp: 3, + source: 'transcript' + } + ]) + expect(container.textContent).not.toContain('Which color?') + } + ) }) diff --git a/src/shared/native-chat/input/native-chat-ask.test.ts b/src/shared/native-chat/input/native-chat-ask.test.ts index 516b5015cab..ff904b72dba 100644 --- a/src/shared/native-chat/input/native-chat-ask.test.ts +++ b/src/shared/native-chat/input/native-chat-ask.test.ts @@ -4,7 +4,15 @@ import { type NativeChatBlock, type NativeChatMessage } from '../../native-chat-types' -import { extractPendingAsk, resolveNativeChatAsk } from './native-chat-ask' +import { + readAgentMessageSource, + type AgentMessageSource +} from '../../agent-session/agent-session-message-source' +import { + extractPendingAsk, + extractPendingMessageReplyAsk, + resolveNativeChatAsk +} from './native-chat-ask' function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } @@ -133,7 +141,7 @@ describe('async questions', () => { type: 'tool-call', name: 'request_user_input_async', callId, - input: JSON.stringify({ questions: [{ title }] }) + input: JSON.stringify({ questions: [{ title, options: ['Yes', 'No'] }] }) }) const userRow = (id: string, flags: Partial = {}): NativeChatMessage => ({ ...message(id, [{ type: 'text', text: 'hi' }]), @@ -151,11 +159,51 @@ describe('async questions', () => { ]) }) - it('stays open until a user message reaches the agent', () => { + it.each(['queued', 'unsent', 'stoppedBeforeStart', 'awaitsRetry', 'sentWhileStopping'] as const)( + 'stays open while a human row is marked %s', + (flag) => { + const asked = message('a', [asyncCall('call-a', 'A?'), result()]) + expect(titles([asked, userRow('waiting', { [flag]: true })])).toEqual(['A?']) + expect(extractPendingAsk([asked, userRow('sent')])).toBeNull() + } + ) + + it('keeps async controls across agent notices and replay until human input or interruption', () => { const asked = message('a', [asyncCall('call-a', 'A?'), result()]) - expect(titles([asked, userRow('q', { queued: true }), userRow('u', { unsent: true })])).toEqual( - ['A?'] - ) - expect(extractPendingAsk([asked, userRow('sent')])).toBeNull() + const from: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'term_b', + dispatchId: null, + messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }] + } + } + for (const source of [ + from, + { ...from, orchestration: null }, + { ...from, senders: [], orchestration: { message: 'future-notice' } } + ]) { + const notice = userRow('notice', { + from: readAgentMessageSource(source), + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }] + }) + expect(notice.from?.kind).toBe('agent') + const history = [asked, notice] + for (const extract of [extractPendingAsk, extractPendingMessageReplyAsk]) { + const expected = extract([asked]) + expect(expected?.questions[0]?.options).toEqual([{ label: 'Yes' }, { label: 'No' }]) + expect(extract(history)).toEqual(expected) + expect(extract([...history])).toEqual(expected) + expect(extract([...history, userRow('human-answer')])).toBeNull() + expect(extract([...history, interrupted('stop')])).toBeNull() + } + } }) }) diff --git a/src/shared/native-chat/input/native-chat-ask.ts b/src/shared/native-chat/input/native-chat-ask.ts index 68cfae251d2..fb4efefa764 100644 --- a/src/shared/native-chat/input/native-chat-ask.ts +++ b/src/shared/native-chat/input/native-chat-ask.ts @@ -160,7 +160,7 @@ function scanPendingAsks(messages: readonly NativeChatMessage[]): { // Two counters track pending’s FIFO depth without retaining a queue entry per call. let outstanding = 0 let pendingDepth = -1 - // Each async question stays open past its immediate result until the agent receives a message. + // Async questions await the person's message, not an orchestration notice from another agent. let asyncQuestions: AskQuestion[] = [] const asyncCallIds = new Set() for (const message of messages) { @@ -171,7 +171,7 @@ function scanPendingAsks(messages: readonly NativeChatMessage[]): { outstanding = 0 pendingDepth = -1 pending = null - if (interrupted || reachedAgent(message)) { + if (interrupted || (reachedAgent(message) && message.from?.kind !== 'agent')) { asyncQuestions = [] asyncCallIds.clear() }