mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
fix(native-chat): step Grok question answers
This commit is contained in:
@@ -104,23 +104,82 @@ describe('parseAskFromStatus', () => {
|
||||
expect(parseAskFromStatus('{"foo":1}')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses Grok question notifications whose payload is the questions array', () => {
|
||||
const grokQuestions = [
|
||||
{
|
||||
question: 'Ship to which region?',
|
||||
options: [
|
||||
{ label: 'us-east', description: 'US East' },
|
||||
{ label: 'eu-west', description: 'EU West' }
|
||||
],
|
||||
multi_select: false
|
||||
}
|
||||
]
|
||||
const ask = parseAskFromStatus(JSON.stringify(grokQuestions), 'ask_user_question')
|
||||
expect(ask?.questions[0]).toMatchObject({
|
||||
question: 'Ship to which region?',
|
||||
options: [{ label: 'us-east' }, { label: 'eu-west' }],
|
||||
multiSelect: false
|
||||
})
|
||||
it('parses the captured Grok ask_user_question envelope and options', () => {
|
||||
// Captured from ~/.grok/sessions/.../chat_history.jsonl. Keep the
|
||||
// {questions:[...]} envelope: Grok does not send a bare array here.
|
||||
const grokInput = {
|
||||
questions: [
|
||||
{
|
||||
question: 'How should we proceed with the 0.0.41 mobile release?',
|
||||
options: [
|
||||
{
|
||||
label: 'Both platforms (Recommended)',
|
||||
description:
|
||||
'iOS: App Store Connect with existing TestFlight 0.0.41; Android: trigger release workflow'
|
||||
},
|
||||
{
|
||||
label: 'iOS only',
|
||||
description: 'Prepare App Store Connect copy; no Android CI.'
|
||||
},
|
||||
{
|
||||
label: 'Android only',
|
||||
description: 'Tag/dispatch Mobile Android Release with release_version 0.0.41'
|
||||
},
|
||||
{
|
||||
label: 'Status only for now',
|
||||
description: 'Stop here; I’ll handle ASC/Android myself with the drafts above'
|
||||
}
|
||||
],
|
||||
multi_select: false
|
||||
},
|
||||
{
|
||||
question: 'How should we trigger the Android release (if shipping Android)?',
|
||||
options: [
|
||||
{
|
||||
label: 'Tag push mobile-android-v0.0.41 (Recommended)',
|
||||
description: 'git tag on origin/main and push'
|
||||
},
|
||||
{
|
||||
label: 'workflow_dispatch',
|
||||
description: 'Dispatch Mobile Android Release with release_version 0.0.41'
|
||||
},
|
||||
{ label: 'N/A — not shipping Android', description: 'Skip Android trigger' }
|
||||
],
|
||||
multi_select: false
|
||||
}
|
||||
]
|
||||
}
|
||||
const ask = parseAskFromStatus(JSON.stringify(grokInput), 'ask_user_question')
|
||||
expect(ask?.questions).toHaveLength(2)
|
||||
expect(
|
||||
ask?.questions.flatMap((question) => question.options.map((option) => option.label))
|
||||
).toEqual([
|
||||
'Both platforms (Recommended)',
|
||||
'iOS only',
|
||||
'Android only',
|
||||
'Status only for now',
|
||||
'Tag push mobile-android-v0.0.41 (Recommended)',
|
||||
'workflow_dispatch',
|
||||
'N/A — not shipping Android'
|
||||
])
|
||||
expect(ask?.questions.every((question) => question.multiSelect)).toBe(false)
|
||||
})
|
||||
|
||||
it('maps Grok multi_select to the selector flag', () => {
|
||||
const capturedShapeWithMultiSelect = {
|
||||
questions: [
|
||||
{
|
||||
question: 'Pick release targets',
|
||||
options: [{ label: 'iOS' }, { label: 'Android' }],
|
||||
multi_select: true
|
||||
}
|
||||
]
|
||||
}
|
||||
const ask = parseAskFromStatus(
|
||||
JSON.stringify(capturedShapeWithMultiSelect),
|
||||
'ask_user_question'
|
||||
)
|
||||
expect(ask?.questions[0]?.multiSelect).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -256,29 +256,24 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('submits a non-selector answer as pasted label text with a single Enter', async () => {
|
||||
it('answers Grok selector prompts with the option number', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
// Grok's question tool commits the pasted answer: label text + one Enter.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
})
|
||||
|
||||
it('clears an orphaned image paste before an answer that commits with Enter (#10228)', async () => {
|
||||
it('keeps the marker for a Grok selector answer', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
|
||||
// An earlier image send left its path on this terminal's composer line.
|
||||
markMobileNativeChatInputStale('terminal')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
// Without the leading clear, the pasted label + Enter would submit
|
||||
// "<image path>Spaces" as one prompt.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(false)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the marker for a selector answer, which cannot submit the composer', async () => {
|
||||
@@ -303,7 +298,7 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
result: { send: { accepted: false } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp')
|
||||
markMobileNativeChatInputStale('terminal')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false)
|
||||
|
||||
@@ -223,8 +223,8 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Grok commits pasted labels; Claude and Codex need their selector-specific
|
||||
// keystrokes paced so each step renders before the next lands.
|
||||
// Claude, Codex, and Grok need selector-specific keystrokes paced so each
|
||||
// step renders before the next lands; other agents commit pasted labels.
|
||||
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
|
||||
// This shape pastes the label into the composer and commits it, so an
|
||||
// orphaned image paste would be submitted along with the answer (#10228).
|
||||
|
||||
@@ -93,8 +93,8 @@ export function useNativeChatInteractiveSend(
|
||||
// Cancel any prior in-flight answer before starting a new one.
|
||||
cancelInFlight()
|
||||
const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId)
|
||||
// Claude and Codex ignore pasted labels but have different selector state
|
||||
// machines; Grok commits pasted text. OpenClaude follows Claude's path.
|
||||
// Claude, Codex, and Grok ignore pasted labels but have different selector
|
||||
// state machines. OpenClaude follows Claude's path.
|
||||
const stepsAnswer = shouldStepNativeChatAskAnswer(agent)
|
||||
const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex'
|
||||
// Why: pin the answered question's baseline BEFORE delivery. A late settle
|
||||
|
||||
@@ -49,16 +49,17 @@ describe('nativeChatRequiresLocalTranscript', () => {
|
||||
})
|
||||
|
||||
describe('shouldStepNativeChatAskAnswer', () => {
|
||||
it('steps the digit-commit selector agents (Claude, OpenClaude, Codex)', () => {
|
||||
it('steps the digit-commit selector agents (Claude, OpenClaude, Codex, Grok)', () => {
|
||||
expect(shouldStepNativeChatAskAnswer('claude')).toBe(true)
|
||||
expect(shouldStepNativeChatAskAnswer('openclaude')).toBe(true)
|
||||
// Codex 0.145's request_user_input card ignores typed labels and commits on
|
||||
// the highlighted row, so pasted answers misdeliver like STA-1860.
|
||||
expect(shouldStepNativeChatAskAnswer('codex')).toBe(true)
|
||||
// Grok's ask_user_question card also commits option digits directly.
|
||||
expect(shouldStepNativeChatAskAnswer('grok')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not step other or unknown agents', () => {
|
||||
expect(shouldStepNativeChatAskAnswer('grok')).toBe(false)
|
||||
expect(shouldStepNativeChatAskAnswer('omp')).toBe(false)
|
||||
expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false)
|
||||
expect(shouldStepNativeChatAskAnswer(null)).toBe(false)
|
||||
|
||||
@@ -31,12 +31,13 @@ export function nativeChatRequiresLocalTranscript(agent: string | null | undefin
|
||||
|
||||
/** True when the agent renders a digit-commit question selector that ignores
|
||||
* typed label text (pasting "Blue" + Enter commits the highlighted FIRST
|
||||
* option — STA-1860): Claude's AskUserQuestion and Codex 0.145's
|
||||
* request_user_input card both behave this way, so answers must be delivered
|
||||
* as per-option keystrokes. Other agents commit a pasted answer. */
|
||||
* option — STA-1860): Claude's AskUserQuestion, Codex 0.145's
|
||||
* request_user_input, and Grok's ask_user_question cards behave this way,
|
||||
* so answers must be delivered as per-option keystrokes. Other agents commit
|
||||
* a pasted answer. */
|
||||
export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean {
|
||||
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
|
||||
return transcriptAgent === 'claude' || transcriptAgent === 'codex'
|
||||
return transcriptAgent === 'claude' || transcriptAgent === 'codex' || transcriptAgent === 'grok'
|
||||
}
|
||||
|
||||
export function resolveNativeChatTranscriptAgent(
|
||||
|
||||
@@ -20,11 +20,8 @@ export function registerQuestionTool(toolName: string, parser: InteractiveQuesti
|
||||
}
|
||||
|
||||
function parseQuestionsShape(input: unknown): AskPrompt | null {
|
||||
const rawQuestions = Array.isArray(input)
|
||||
? input
|
||||
: input && typeof input === 'object'
|
||||
? (input as { questions?: unknown }).questions
|
||||
: undefined
|
||||
const rawQuestions =
|
||||
input && typeof input === 'object' ? (input as { questions?: unknown }).questions : undefined
|
||||
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user