mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
265 lines
9.4 KiB
TypeScript
265 lines
9.4 KiB
TypeScript
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()
|
||
})
|
||
})
|