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
This commit is contained in:
Brennan Benson
2026-10-29 22:33:23 -07:00
committed by GitHub
parent 0c99ccb3da
commit f26fb1cfda
4 changed files with 184 additions and 30 deletions
@@ -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<typeof useMobileNativeChatPrompts> | 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()
}
)
})
@@ -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(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
session={{ messages: [asked], readPhase: 'ready' }}
send={send}
/>
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(
<NativeChatStructuredAsyncQuestion
paneKey="tab-1:structured"
session={{ messages, readPhase: 'ready' }}
send={send}
/>
)
)
render([asked])
const input = container.querySelector<HTMLInputElement>(
'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<HTMLInputElement>('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?')
}
)
})
@@ -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> = {}): 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()
}
}
})
})
@@ -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<string>()
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()
}