mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
Keep pending questions through agent notices and undelivered replies (#36306)
* Keep pending blocking questions through non-answer rows * Use a canonical delivered human row in blocking question tests * Remove redundant live-status question test
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { createElement } from 'react'
|
||||
import TestRenderer from 'react-test-renderer'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../../src/shared/native-chat-types'
|
||||
import { projectStructuredItemToNativeChat } from '../../../../../src/shared/structured-agent-session-projection'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
|
||||
it('retains a terminal blocking ask across a projected notice until its result', () => {
|
||||
// Mixed-source hook contract, not a captured shipping delivery: Claude-shaped call plus projected journal row.
|
||||
const asked: NativeChatMessage = {
|
||||
id: 'ask',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'AskUserQuestion',
|
||||
input: { questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] }
|
||||
}
|
||||
]
|
||||
}
|
||||
const notice = projectStructuredItemToNativeChat({
|
||||
itemId: 'notice',
|
||||
sequence: 2,
|
||||
revision: 1,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Agent update' }],
|
||||
from: { kind: 'agent', senders: [], orchestration: null }
|
||||
}
|
||||
})!
|
||||
let prompts: ReturnType<typeof useMobileNativeChatPrompts> | undefined
|
||||
function Probe({ messages }: { messages: NativeChatMessage[] }): null {
|
||||
prompts = useMobileNativeChatPrompts({
|
||||
enabled: true,
|
||||
status: null,
|
||||
messages,
|
||||
transcriptLoading: false,
|
||||
asyncSourceScope: 'host/session',
|
||||
hiddenAsyncIds: new Set()
|
||||
})
|
||||
return null
|
||||
}
|
||||
let view!: TestRenderer.ReactTestRenderer
|
||||
try {
|
||||
TestRenderer.act(() => {
|
||||
view = TestRenderer.create(createElement(Probe, { messages: [asked] }))
|
||||
})
|
||||
const pending = prompts?.ask
|
||||
expect(pending?.questions[0]?.question).toBe('Which path?')
|
||||
TestRenderer.act(() => {
|
||||
view.update(createElement(Probe, { messages: [asked, notice] }))
|
||||
})
|
||||
expect(prompts?.ask).toEqual(pending)
|
||||
TestRenderer.act(() => {
|
||||
view.update(
|
||||
createElement(Probe, {
|
||||
messages: [
|
||||
asked,
|
||||
notice,
|
||||
{
|
||||
id: 'result',
|
||||
role: 'tool',
|
||||
timestamp: 3,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'tool-result', output: 'fast' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(prompts?.ask).toBeNull()
|
||||
} finally {
|
||||
TestRenderer.act(() => view?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
|
||||
import { useNativeChatTerminalPromptCard } from './use-native-chat-interactive-prompt-card'
|
||||
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { agentStatusByPaneKey: Record<string, never> }) => unknown) =>
|
||||
selector({ agentStatusByPaneKey: {} })
|
||||
}))
|
||||
const send: NativeChatInteractiveSend = {
|
||||
sendAnswer: vi.fn(),
|
||||
sendRaw: vi.fn(),
|
||||
sendRawVerified: vi.fn(),
|
||||
cancelPending: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
cancelAsk: vi.fn()
|
||||
}
|
||||
function Card({ messages }: { messages: NativeChatMessage[] }) {
|
||||
const { presentation, actionKey } = useNativeChatTerminalPromptCard({
|
||||
paneKey: 'blocking-notice:leaf',
|
||||
targetPtyId: 'pty',
|
||||
runtimeEnvironmentId: null,
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
transcriptPath: null,
|
||||
messages,
|
||||
transcriptSettled: true,
|
||||
canSend: true
|
||||
})
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{presentation.card && (
|
||||
<NativeChatInteractiveCard
|
||||
key={actionKey}
|
||||
card={presentation.card}
|
||||
send={send}
|
||||
onDismiss={presentation.dismiss}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
afterEach(cleanup)
|
||||
|
||||
it('retains selected and typed blocking answers across a projected notice until its result', () => {
|
||||
// Mixed-source contract fixture: Claude-shaped transcript plus journal projection; no live delivery claim.
|
||||
const asked: NativeChatMessage = {
|
||||
id: 'ask',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'AskUserQuestion',
|
||||
input: {
|
||||
questions: [
|
||||
{ question: 'Tabs or spaces?', options: [{ label: 'Tabs' }, { label: 'Spaces' }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
const notice = projectStructuredItemToNativeChat({
|
||||
itemId: 'notice',
|
||||
sequence: 2,
|
||||
revision: 1,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Agent update' }],
|
||||
from: { kind: 'agent', senders: [], orchestration: null }
|
||||
}
|
||||
})!
|
||||
const view = render(<Card messages={[asked]} />)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Four spaces' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Spaces/ }))
|
||||
view.rerender(<Card messages={[asked, notice]} />)
|
||||
expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Spaces/ })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Four spaces')
|
||||
view.rerender(
|
||||
<Card
|
||||
messages={[
|
||||
asked,
|
||||
notice,
|
||||
{
|
||||
id: 'result',
|
||||
role: 'tool',
|
||||
timestamp: 3,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'tool-result', output: 'Spaces' }]
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument()
|
||||
expect(send.sendAnswer).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AgentMessageSource
|
||||
} from '../../agent-session/agent-session-message-source'
|
||||
import { nativeChatAskDismissKey } from '../../native-chat-ask-dismiss-key'
|
||||
import { projectStructuredItemToNativeChat } from '../../structured-agent-session-projection'
|
||||
import {
|
||||
extractPendingAsk,
|
||||
extractPendingMessageReplyAsk,
|
||||
@@ -57,6 +58,114 @@ const QUESTIONS_INPUT = {
|
||||
questions: [{ question: 'Deploy?', options: [{ label: 'Yes' }, { label: 'No' }] }]
|
||||
}
|
||||
|
||||
describe('blocking abandonment boundary', () => {
|
||||
// Mixed-source scanner contract: Claude-shaped calls/results plus projected journal notices, not a live capture.
|
||||
const notice = projectStructuredItemToNativeChat({
|
||||
itemId: 'notice',
|
||||
sequence: 2,
|
||||
revision: 1,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }],
|
||||
from: {
|
||||
kind: 'agent',
|
||||
senders: [],
|
||||
orchestration: {
|
||||
message: 'mail-notice',
|
||||
mailbox: 'term_b',
|
||||
dispatchId: null,
|
||||
messages: [{ messageId: 'mail-1', runId: 'run-1', from: 'term_a' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})!
|
||||
const boundaries = [
|
||||
notice,
|
||||
{
|
||||
...notice,
|
||||
from: readAgentMessageSource({ kind: 'agent', senders: null, orchestration: null })
|
||||
},
|
||||
{
|
||||
...notice,
|
||||
from: readAgentMessageSource({
|
||||
kind: 'agent',
|
||||
senders: [],
|
||||
orchestration: { message: 'future-notice' }
|
||||
})
|
||||
},
|
||||
...(
|
||||
['queued', 'unsent', 'stoppedBeforeStart', 'awaitsRetry', 'sentWhileStopping'] as const
|
||||
).map((flag) => ({ ...notice, from: undefined, [flag]: true }))
|
||||
]
|
||||
it.each(boundaries)('preserves blocking FIFO across non-answer row %#', (boundary) => {
|
||||
const rows = [
|
||||
message('ordinary', [call('Bash', { command: 'ls' })]),
|
||||
message('ask', [call('AskUserQuestion', QUESTIONS_INPUT)]),
|
||||
boundary,
|
||||
toolTurn('ordinary-result'),
|
||||
toolTurn('ask-result')
|
||||
]
|
||||
const scanner = createPendingAskScanner()
|
||||
const replay: NativeChatMessage[] = []
|
||||
for (const row of rows) {
|
||||
replay.push(row)
|
||||
scanner.append(row)
|
||||
expect(scanner.current()).toEqual(scanPendingAsks(replay))
|
||||
if (replay.length >= 2 && replay.length < rows.length) {
|
||||
expect(scanner.current().blocking?.questions[0]?.question).toBe('Deploy?')
|
||||
}
|
||||
}
|
||||
expect(scanner.current().blocking).toBeNull()
|
||||
expect(
|
||||
scanPendingAsks([rows[0]!, rows[1]!, userTurn('human', 'never mind, do this instead')])
|
||||
.blocking
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
// Normalized source-shaped inputs: OMP toolCall.arguments and OpenCode pending state.input; no decoder imports.
|
||||
// Grok question definition copied from src/main/acp/fixtures/s5-plan-approved.jsonl:6 (recorded ACP, not terminal delivery).
|
||||
it.each([
|
||||
['OMP/OpenCode canonical shape', 'ask', QUESTIONS_INPUT],
|
||||
[
|
||||
'Grok',
|
||||
'ask_user_question',
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
question: 'Which test approach do you want for the subtract function?',
|
||||
options: [
|
||||
{
|
||||
label: 'node:test (Recommended)',
|
||||
description:
|
||||
"A math.test.js file using Node's built-in test runner. Run it with node --test. No new dependencies or package.json."
|
||||
},
|
||||
{
|
||||
label: 'Plain assert script',
|
||||
description:
|
||||
'A small math.test.js that uses node:assert and exits non-zero on failure. Run it with node math.test.js. No test framework.'
|
||||
},
|
||||
{
|
||||
label: 'Add a package.json and a test script',
|
||||
description:
|
||||
'Add package.json with a test script that runs the same Node built-in tests, so npm test works.'
|
||||
}
|
||||
],
|
||||
multiSelect: null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
])('retains %s normalized blocking input across a projected notice', (_agent, name, input) => {
|
||||
const asked = message('ask', [call(String(name), input)])
|
||||
const pending = scanPendingAsks([asked]).blocking
|
||||
expect(pending).not.toBeNull()
|
||||
expect(scanPendingAsks([asked, notice]).blocking).toEqual(pending)
|
||||
expect(scanPendingAsks([asked, notice, toolTurn('answered')]).blocking).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractPendingAsk', () => {
|
||||
it('recognizes an unregistered tool whose input matches the canonical questions shape', () => {
|
||||
// Match live status’s arbitrary-tool canonical-shape fallback during transcript replay.
|
||||
@@ -335,7 +444,7 @@ describe('async questions', () => {
|
||||
expect(isDeliveredNativeChatUserMessage(notice)).toBe(false)
|
||||
scanner.append(notice)
|
||||
expect(scanner.current().contributors).toBe(pending)
|
||||
expect(scanner.current().blocking).toBeNull()
|
||||
expect(scanner.current().blocking).not.toBeNull()
|
||||
expect(scanner.append(message('replay', [asyncCall('same-call', 'duplicate')]))).toEqual([])
|
||||
scanner.append({ ...notice, id: 'legacy-human', from: undefined })
|
||||
expect(scanner.current().contributors).toEqual([])
|
||||
|
||||
@@ -141,7 +141,7 @@ export function extractPendingMessageReplyAsk(
|
||||
return aggregateAsyncQuestions(scanPendingAsks(messages).contributors)
|
||||
}
|
||||
|
||||
/** Delivered human input only; agent notices and pending sends answer no async question. */
|
||||
/** Delivered human input only; agent notices and pending sends answer no question. */
|
||||
export function isDeliveredNativeChatUserMessage(message: NativeChatMessage): boolean {
|
||||
return (
|
||||
message.role === 'user' &&
|
||||
@@ -257,15 +257,9 @@ export function createPendingAskScanner(): {
|
||||
}
|
||||
const append = (message: NativeChatMessage): readonly AsyncQuestionContributor[] => {
|
||||
const accepted: AsyncQuestionContributor[] = []
|
||||
// New user turns/interrupts abandon unresolved calls. Decoding drops tool_use_id, so orphan results cannot match by id;
|
||||
// reset the FIFO or one orphan strands later answered asks forever (#11761). Claude tool results decode as role tool.
|
||||
const interrupted = isInterruptedStatusMessage(message)
|
||||
if (message.role === 'user' || interrupted) {
|
||||
if (interrupted || isDeliveredNativeChatUserMessage(message)) {
|
||||
interrupt()
|
||||
} else {
|
||||
resetBlocking()
|
||||
}
|
||||
// Delivered human turns/interrupts abandon the uncorrelated FIFO, or orphan calls strand later asks (#11761).
|
||||
if (isDeliveredNativeChatUserMessage(message) || isInterruptedStatusMessage(message)) {
|
||||
interrupt()
|
||||
}
|
||||
for (const block of message.blocks) {
|
||||
if (block.type === 'tool-call') {
|
||||
|
||||
Reference in New Issue
Block a user