mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(native-chat): model Grok question card keys
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
formatAskAnswer,
|
||||
parseAskFromStatus
|
||||
} from '../../../src/shared/native-chat-ask'
|
||||
import { buildGrokAskAnswerKeys } from '../../../src/shared/native-chat-grok-ask-answer'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage {
|
||||
@@ -236,3 +237,60 @@ describe('buildAskAnswerKeys', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildGrokAskAnswerKeys', () => {
|
||||
it('selects and submits a single-question single-select answer', () => {
|
||||
const prompt = {
|
||||
questions: [
|
||||
{ question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] }
|
||||
]
|
||||
}
|
||||
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([
|
||||
{ raw: '2' },
|
||||
{ raw: '\r' }
|
||||
])
|
||||
})
|
||||
|
||||
it('moves between questions with Grok navigation before submitting', () => {
|
||||
const prompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
|
||||
]
|
||||
}
|
||||
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [1] }, { indices: [0] }])).toEqual([
|
||||
{ raw: '2' },
|
||||
{ raw: 'l' },
|
||||
{ raw: '1' },
|
||||
{ raw: '\r' }
|
||||
])
|
||||
})
|
||||
|
||||
it('picks every Grok multi-select option before submitting', () => {
|
||||
const prompt = {
|
||||
questions: [
|
||||
{
|
||||
question: 'q',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([
|
||||
{ raw: '1' },
|
||||
{ raw: '3' },
|
||||
{ raw: '\r' }
|
||||
])
|
||||
})
|
||||
|
||||
it('opens Grok free text with z before typing and submitting', () => {
|
||||
const prompt = {
|
||||
questions: [{ question: 'q', multiSelect: false, options: [{ label: 'A' }] }]
|
||||
}
|
||||
expect(buildGrokAskAnswerKeys(prompt, [{ indices: [], other: 'custom' }])).toEqual([
|
||||
{ raw: 'z' },
|
||||
{ text: 'custom' },
|
||||
{ raw: '\r' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,13 +256,21 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('answers Grok selector prompts with the option number', async () => {
|
||||
it('answers Grok selector prompts with its pick and submit keys', 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)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
let result: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
})
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
await expect(result).resolves.toBe(true)
|
||||
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
|
||||
expect.objectContaining({ text: '2', enter: false }),
|
||||
expect.objectContaining({ text: '\r', enter: false })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the marker for a Grok selector answer', async () => {
|
||||
@@ -270,9 +278,17 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'grok')
|
||||
markMobileNativeChatInputStale('terminal')
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
|
||||
let result: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
})
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
await expect(result).resolves.toBe(true)
|
||||
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
|
||||
expect.objectContaining({ text: '2', enter: false }),
|
||||
expect.objectContaining({ text: '\r', enter: false })
|
||||
])
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -731,7 +747,7 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp')
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
@@ -739,6 +755,7 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Tabs', enter: true })
|
||||
await act(async () => {
|
||||
second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
@@ -797,13 +814,14 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'omp')
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
|
||||
await act(async () => {
|
||||
answerSend?.cancelPending()
|
||||
settle[0]!(acceptedResponse())
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AskAnswerSelection,
|
||||
type AskPrompt
|
||||
} from '../../../src/shared/native-chat-ask'
|
||||
import { buildGrokAskAnswerKeys } from '../../../src/shared/native-chat-grok-ask-answer'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
|
||||
import {
|
||||
@@ -258,10 +259,13 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
|
||||
return sent && writeTurnsRef.current.get(handle) === turn
|
||||
}
|
||||
const transcriptAgent = resolveNativeChatTranscriptAgent(agentRef.current)
|
||||
const groups =
|
||||
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
|
||||
transcriptAgent === 'codex'
|
||||
? buildCodexAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections)
|
||||
: transcriptAgent === 'grok'
|
||||
? buildGrokAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections)
|
||||
for (let index = 0; index < groups.length; index += 1) {
|
||||
if (generationRef.current !== generation) {
|
||||
return false
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
type AskQuestion,
|
||||
type InteractiveQuestionParser
|
||||
} from '../../../../shared/native-chat-ask'
|
||||
import { buildGrokAskAnswerKeys } from '../../../../shared/native-chat-grok-ask-answer'
|
||||
|
||||
export {
|
||||
buildAskAnswerKeys,
|
||||
buildCodexAskAnswerKeys,
|
||||
buildGrokAskAnswerKeys,
|
||||
formatAskAnswer,
|
||||
hasAskAnswer,
|
||||
parseAskFromStatus,
|
||||
|
||||
@@ -66,12 +66,12 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
|
||||
it('routes a non-selector answer through the pasted-text send path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok')
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'omp')
|
||||
)
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
// Grok commits a pasted answer: label text 'B', not option-number keystrokes.
|
||||
// OMP commits a pasted answer: label text 'B', not option-number keystrokes.
|
||||
expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
@@ -80,6 +80,22 @@ describe('useNativeChatInteractiveSend', () => {
|
||||
expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a Grok answer through its question-card keystrokes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'grok')
|
||||
)
|
||||
|
||||
act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }]))
|
||||
|
||||
expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
[{ raw: '2' }, { raw: '\r' }],
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a Codex answer through the option-number keystroke path', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'codex')
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
buildAskAnswerKeys,
|
||||
buildCodexAskAnswerKeys,
|
||||
buildGrokAskAnswerKeys,
|
||||
formatAskAnswer,
|
||||
hasAskAnswer,
|
||||
type AskAnswerSelection,
|
||||
@@ -96,7 +97,7 @@ export function useNativeChatInteractiveSend(
|
||||
// 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'
|
||||
const transcriptAgent = resolveNativeChatTranscriptAgent(agent)
|
||||
// Why: pin the answered question's baseline BEFORE delivery. A late settle
|
||||
// callback (paced writes + remote acceptance can span seconds on SSH) must
|
||||
// not read the live status and mint a fresh baseline for a replacement
|
||||
@@ -132,9 +133,11 @@ export function useNativeChatInteractiveSend(
|
||||
? sendNativeChatAskAnswer(
|
||||
settings,
|
||||
targetPtyId,
|
||||
buildsCodexAnswer
|
||||
transcriptAgent === 'codex'
|
||||
? buildCodexAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections),
|
||||
: transcriptAgent === 'grok'
|
||||
? buildGrokAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections),
|
||||
onSettled
|
||||
)
|
||||
: sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { isQuestionAnsweredSubmitInput } from '../../../../shared/agent-question-answered-intent'
|
||||
import { createAgentQuestionAnsweredInference } from './agent-question-answered-inference'
|
||||
|
||||
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
@@ -99,6 +100,19 @@ describe('agent question-answered inference', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps waiting when a Grok multi_select digit only picks a partial answer', () => {
|
||||
const interactivePrompt = JSON.stringify({
|
||||
questions: [
|
||||
{
|
||||
question: 'pick several',
|
||||
multi_select: true,
|
||||
options: [{ label: 'A' }, { label: 'B' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(isQuestionAnsweredSubmitInput('1', interactivePrompt)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps waiting when the synthetic free-text row is selected', () => {
|
||||
const { inference, inferQuestionAnswered } = makeInference(makeWaitingQuestionEntry())
|
||||
|
||||
|
||||
@@ -43,8 +43,17 @@ function readSingleSelectOptionCount(interactivePrompt: string | undefined): num
|
||||
if (!Array.isArray(parsed.questions) || parsed.questions.length !== 1) {
|
||||
return -1
|
||||
}
|
||||
const [question] = parsed.questions as { multiSelect?: unknown; options?: unknown }[]
|
||||
if (!question || question.multiSelect === true || !Array.isArray(question.options)) {
|
||||
const [question] = parsed.questions as {
|
||||
multiSelect?: unknown
|
||||
multi_select?: unknown
|
||||
options?: unknown
|
||||
}[]
|
||||
if (
|
||||
!question ||
|
||||
question.multiSelect === true ||
|
||||
question.multi_select === true ||
|
||||
!Array.isArray(question.options)
|
||||
) {
|
||||
return -1
|
||||
}
|
||||
return question.options.length
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AskAnswerKeyGroup, AskAnswerSelection, AskPrompt } from './native-chat-ask'
|
||||
|
||||
const GROK_ENTER = '\r'
|
||||
const GROK_NEXT_QUESTION = 'l'
|
||||
const GROK_FREE_TEXT = 'z'
|
||||
|
||||
function optionKey(index: number): string | null {
|
||||
if (index >= 0 && index < 9) {
|
||||
return String(index + 1)
|
||||
}
|
||||
if (index >= 9 && index < 15) {
|
||||
return String.fromCharCode('a'.charCodeAt(0) + index - 9)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Grok picks options by shortcut, moves questions with `l`, and opens free text with `z`. */
|
||||
export function buildGrokAskAnswerKeys(
|
||||
prompt: AskPrompt,
|
||||
selections: AskAnswerSelection[]
|
||||
): AskAnswerKeyGroup[] {
|
||||
const groups: AskAnswerKeyGroup[] = []
|
||||
|
||||
prompt.questions.forEach((question, questionIndex) => {
|
||||
const selection = selections[questionIndex]
|
||||
const other = (selection?.other ?? '').trim()
|
||||
const selectedIndices = question.multiSelect
|
||||
? (selection?.indices ?? [])
|
||||
: (selection?.indices.slice(0, 1) ?? [])
|
||||
|
||||
if (!other || question.multiSelect) {
|
||||
for (const index of selectedIndices) {
|
||||
const key = optionKey(index)
|
||||
if (key) {
|
||||
groups.push({ raw: key })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (other) {
|
||||
const freeText = question.multiSelect
|
||||
? other
|
||||
: [question.options[selectedIndices[0] ?? -1]?.label, other].filter(Boolean).join(', ')
|
||||
groups.push({ raw: GROK_FREE_TEXT }, { text: freeText }, { raw: GROK_ENTER })
|
||||
} else if (questionIndex < prompt.questions.length - 1) {
|
||||
groups.push({ raw: GROK_NEXT_QUESTION })
|
||||
} else if (selectedIndices.length > 0) {
|
||||
groups.push({ raw: GROK_ENTER })
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
Reference in New Issue
Block a user