mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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:
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+82
-18
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user