Files
orca/src/shared/native-chat-ask.test.ts
T

265 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatBlock,
type NativeChatMessage
} from './native-chat-types'
import {
extractPendingAsk,
nativeChatAskDismissKey,
parseAskFromStatus,
resolveNativeChatAsk
} from './native-chat-ask'
function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage {
return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' }
}
function call(name: string, input: unknown): NativeChatBlock {
return { type: 'tool-call', name, input }
}
function result(): NativeChatBlock {
return { type: 'tool-result', output: 'ok' }
}
/** The row the transcript decoders emit for an interrupted turn. */
function interrupted(id: string): NativeChatMessage {
return {
id,
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp: 1,
source: 'transcript'
}
}
function userTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' }
}
/** Claude delivers tool results on their own turn, which decodes as role 'tool'. */
function toolTurn(id: string): NativeChatMessage {
return { id, role: 'tool', blocks: [result()], timestamp: 1, source: 'transcript' }
}
const QUESTIONS_INPUT = {
questions: [{ question: 'Deploy?', options: [{ label: 'Yes' }, { label: 'No' }] }]
}
describe('nativeChatAskDismissKey', () => {
it('uses the full canonical prompt and stays stable across object instances', () => {
const first = parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT))
const same = parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT))
const changed = parseAskFromStatus(
JSON.stringify({ questions: [{ question: 'Deploy?', options: [{ label: 'Later' }] }] })
)
expect(nativeChatAskDismissKey(first)).toBe(nativeChatAskDismissKey(same))
expect(nativeChatAskDismissKey(first)).not.toBe(nativeChatAskDismissKey(changed))
expect(nativeChatAskDismissKey(null)).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.
const pending = extractPendingAsk([message('m1', [call('CustomAskTool', QUESTIONS_INPUT)])])
expect(pending?.questions[0]?.question).toBe('Deploy?')
})
it('resolves calls FIFO so a sibling result cannot clear a newer pending ask', () => {
const pending = extractPendingAsk([
message('m1', [
call('Bash', { command: 'ls' }),
call('AskUserQuestion', QUESTIONS_INPUT),
// FIFO: this result answers the Bash call, not the ask.
result()
])
])
expect(pending?.questions[0]?.question).toBe('Deploy?')
})
it('keeps the newest ask when an older call receives its result', () => {
const first = { questions: [{ question: 'First?', options: [] }] }
const pending = extractPendingAsk([
message('m1', [
call('AskUserQuestion', first),
call('AskUserQuestion', QUESTIONS_INPUT),
// Resolves the FIRST ask (FIFO); the newer one stays pending.
result()
])
])
expect(pending?.questions[0]?.question).toBe('Deploy?')
})
it('does not strand an answered ask behind a tool call orphaned by an interrupt', () => {
// Claude interrupts omit tool results, leaving orphan FIFO slots (#11761).
const pending = extractPendingAsk([
message('m1', [call('Bash', { command: 'sleep 999' })]),
interrupted('m2'),
message('m3', [call('AskUserQuestion', QUESTIONS_INPUT)]),
message('m4', [result()])
])
expect(pending).toBeNull()
})
it('drops an ask abandoned by an interrupt', () => {
const pending = extractPendingAsk([
message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)]),
interrupted('m2')
])
expect(pending).toBeNull()
})
it('keeps an ask that is still awaiting its result after an earlier interrupt', () => {
const pending = extractPendingAsk([
message('m1', [call('Bash', { command: 'sleep 999' })]),
interrupted('m2'),
message('m3', [call('AskUserQuestion', QUESTIONS_INPUT)])
])
expect(pending?.questions[0]?.question).toBe('Deploy?')
})
it('drops an ask the user typed past instead of answering', () => {
// Escaping a selector and sending a prompt can leave the ask without a result.
const pending = extractPendingAsk([
message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)]),
userTurn('m2', 'never mind, do this instead'),
message('m3', [{ type: 'text', text: 'on it' }])
])
expect(pending).toBeNull()
})
it('does not strand an answered ask behind an orphan left by a plain-text interrupt', () => {
// Claude can record interrupts as bare user turns without interruptedMessageId/status rows.
const pending = extractPendingAsk([
message('m1', [call('Bash', { command: 'sleep 999' })]),
userTurn('m2', '[Request interrupted by user]'),
message('m3', [call('AskUserQuestion', QUESTIONS_INPUT)]),
toolTurn('m4')
])
expect(pending).toBeNull()
})
it('resolves an ask whose result arrives on its own tool-role turn', () => {
const pending = extractPendingAsk([
message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)]),
toolTurn('m2')
])
expect(pending).toBeNull()
})
it('ignores malformed question payloads', () => {
expect(
extractPendingAsk([
message('m1', [
call('AskUserQuestion', { questions: [] }),
call('AskUserQuestion', { questions: [{}] }),
call('AskUserQuestion', 'not-an-object')
])
])
).toBeNull()
})
})
describe('parseAskFromStatus', () => {
it('preserves OMP checkbox questions from live status and transcript history', () => {
const input = {
questions: [
{ id: 'q1', question: 'Pick', multi: true, options: [{ label: 'A' }, { label: 'B' }] }
]
}
const prompt = parseAskFromStatus(JSON.stringify(input), 'ask')
expect(prompt?.questions[0]?.multiSelect).toBe(true)
expect(extractPendingAsk([message('m1', [call('ask', input)])])).toEqual(prompt)
})
it('preserves OpenCode question envelope multiple selection on live status and replay', () => {
const input = {
id: 'question',
sessionID: 'session',
questions: [
{
header: 'Choice',
question: 'Pick both?',
multiple: true,
options: [
{ label: 'One', description: 'first' },
{ label: 'Two', description: 'second' }
]
}
]
}
const prompt = parseAskFromStatus(JSON.stringify(input), 'AskUserQuestion')
expect(prompt?.questions[0]).toMatchObject({ multiSelect: true, header: 'Choice' })
expect(extractPendingAsk([message('m1', [call('question', input)])])).toEqual(prompt)
})
it('accepts the canonical shape from any tool name and rejects broken JSON', () => {
expect(
parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT), 'SomeNewTool')?.questions
).toHaveLength(1)
expect(parseAskFromStatus('{not json', 'AskUserQuestion')).toBeNull()
expect(parseAskFromStatus(null)).toBeNull()
})
it('parses string options into labels', () => {
const prompt = parseAskFromStatus(
JSON.stringify({ questions: [{ question: 'Pick', options: ['a', 'b'] }] })
)
expect(prompt?.questions[0]?.options.map((o) => o.label)).toEqual(['a', 'b'])
})
})
describe('resolveNativeChatAsk', () => {
const transcript = [message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)])]
it('withholds transcript state until the read settles', () => {
expect(
resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: false })
).toBeNull()
expect(
resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: true })
)?.toMatchObject(QUESTIONS_INPUT)
})
it('keeps a live ask authoritative while transcript history is unsettled', () => {
const liveAsk = { questions: [{ question: 'Live?', options: [], multiSelect: false }] }
expect(resolveNativeChatAsk({ liveAsk, messages: transcript, transcriptSettled: false })).toBe(
liveAsk
)
})
})
describe('async questions', () => {
const asyncCall = (callId: string, title: string): NativeChatBlock => ({
type: 'tool-call',
name: 'request_user_input_async',
callId,
input: JSON.stringify({ questions: [{ title }] })
})
const userRow = (id: string, flags: Partial<NativeChatMessage> = {}): NativeChatMessage => ({
...message(id, [{ type: 'text', text: 'hi' }]),
role: 'user',
...flags
})
const titles = (messages: NativeChatMessage[]): string[] | undefined =>
extractPendingAsk(messages)?.questions.map((question) => question.question)
it('keeps every unanswered async question, once per call', () => {
const first = message('a', [asyncCall('call-a', 'A?'), result()])
expect(titles([first, first, message('b', [asyncCall('call-b', 'B?'), result()])])).toEqual([
'A?',
'B?'
])
})
it('stays open until a user message reaches the agent', () => {
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()
})
})