Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/cli/src/backends/gemini/runGemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,9 @@ import {
import { maybeUpdateGeminiSessionIdMetadata } from '@/backends/gemini/utils/geminiSessionIdMetadata';
import { updateMetadataBestEffort } from '@/api/session/sessionWritesBestEffort';
import {
parseOptionsFromText,
hasIncompleteOptions,
segmentTrailingOptions,
formatOptionsXml,
} from '@/backends/gemini/utils/optionsParser';
} from '@/utils/optionsParser';
import { ConversationHistory } from '@/backends/gemini/utils/conversationHistory';
import { createGeminiBackendMessageHandler } from '@/backends/gemini/runtime/createGeminiBackendMessageHandler';
import { reportGeminiConnectedServiceRuntimeAuthFailureBestEffort } from '@/backends/gemini/connectedServices/surfaceGeminiConnectedServiceRuntimeAuthFailure';
Expand Down Expand Up @@ -983,7 +982,11 @@ export async function runGemini(opts: {
// Send accumulated response to mobile app ONLY when turn is complete
// This prevents message fragmentation from Gemini's chunked responses
if (hasAssistantOutput) {
const { text: messageText, options } = parseOptionsFromText(turnMessageState.accumulatedResponse);
const { before, options, hasIncompleteTrailingOptions } = segmentTrailingOptions(turnMessageState.accumulatedResponse);
// Trim the app-facing message (Gemini display concern) to stay
// byte-identical to the previous parseOptionsFromText behavior; the
// terminal formatter path preserves surrounding whitespace verbatim.
const messageText = before.trim();

// Record assistant response in conversation history for context preservation
conversationHistory.addAssistantMessage(messageText);
Expand All @@ -994,7 +997,7 @@ export async function runGemini(opts: {
const optionsXml = formatOptionsXml(options);
finalMessageText = messageText + optionsXml;
logger.debug(`[gemini] Found ${options.length} options in response`);
} else if (hasIncompleteOptions(turnMessageState.accumulatedResponse)) {
} else if (hasIncompleteTrailingOptions) {
logger.debug(`[gemini] Warning: Incomplete options block detected`);
}

Expand Down
70 changes: 0 additions & 70 deletions apps/cli/src/backends/gemini/utils/optionsParser.ts

This file was deleted.

77 changes: 77 additions & 0 deletions apps/cli/src/ui/messageFormatterInk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { SDKAssistantMessage, SDKMessage, SDKResultMessage } from '@/backends/claude/sdk'
import { MessageBuffer } from './ink/messageBuffer'
import { formatClaudeMessageForInk } from './messageFormatterInk'

function buildAssistantMessage(text: string): SDKMessage {
return {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text }],
},
} satisfies SDKAssistantMessage
}

function buildAssistantMessageFromTextBlocks(...texts: string[]): SDKMessage {
return {
type: 'assistant',
message: {
role: 'assistant',
content: texts.map((text) => ({ type: 'text', text })),
},
} satisfies SDKAssistantMessage
}

describe('formatClaudeMessageForInk', () => {
it('renders assistant <options> blocks as a numbered list instead of raw XML', () => {
const messageBuffer = new MessageBuffer()
const text = 'Should I proceed?\n\n<options>\n <option>Yes, go ahead</option>\n <option>No, stop here</option>\n</options>'

formatClaudeMessageForInk(buildAssistantMessage(text), messageBuffer)

const contents = messageBuffer.getMessages().map((m) => m.content)
expect(contents).toContain('Should I proceed?\n\nOptions:\n 1. Yes, go ahead\n 2. No, stop here')
expect(contents.join('\n')).not.toContain('<options>')
})

it('leaves assistant text without options untouched', () => {
const messageBuffer = new MessageBuffer()
const text = 'All done. Nothing to choose here.'

formatClaudeMessageForInk(buildAssistantMessage(text), messageBuffer)

const contents = messageBuffer.getMessages().map((m) => m.content)
expect(contents).toContain(text)
})

it('assembles an options block split across two adjacent text blocks', () => {
const messageBuffer = new MessageBuffer()
const message = buildAssistantMessageFromTextBlocks(
'Should I proceed?\n\n<options>\n<option>Yes, go ahead</option>\n',
'<option>No, stop here</option>\n</options>',
)

formatClaudeMessageForInk(message, messageBuffer)

const contents = messageBuffer.getMessages().map((m) => m.content)
expect(contents).toContain('Should I proceed?\n\nOptions:\n 1. Yes, go ahead\n 2. No, stop here')
expect(contents.join('\n')).not.toContain('<options>')
expect(contents.join('\n')).not.toContain('<option>')
})

it('renders <options> blocks in the result summary as a numbered list', () => {
const messageBuffer = new MessageBuffer()
const resultMessage = {
type: 'result',
subtype: 'success',
result: 'Pick a next step.\n\n<options>\n<option>Continue</option>\n<option>Abort</option>\n</options>',
} as unknown as SDKResultMessage

formatClaudeMessageForInk(resultMessage, messageBuffer)

const contents = messageBuffer.getMessages().map((m) => m.content)
expect(contents).toContain('Pick a next step.\n\nOptions:\n 1. Continue\n 2. Abort')
expect(contents.join('\n')).not.toContain('<options>')
})
})
30 changes: 27 additions & 3 deletions apps/cli/src/ui/messageFormatterInk.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SDKMessage, SDKAssistantMessage, SDKResultMessage, SDKSystemMessage, SDKUserMessage } from '@/backends/claude/sdk'
import { formatTextWithOptionsForTerminal } from '@/utils/optionsParser'
import type { MessageBuffer } from './ink/messageBuffer'
import { logger } from './logger'

Expand Down Expand Up @@ -69,11 +70,31 @@ export function formatClaudeMessageForInk(
const assistantMsg = message as SDKAssistantMessage
if (assistantMsg.message && assistantMsg.message.content) {
messageBuffer.addMessage('🤖 Assistant:', 'assistant')


// Options can be split across adjacent SDK text blocks (the model
// streams one logical message as several contiguous text
// fragments). Assemble each contiguous run of text blocks into one
// string and format it ONCE so a trailing <options> block that
// spans the block boundary is still recognised. Flush the run on
// any non-text block or at the end of the content array.
let textRun = ''
let hasTextRun = false
const flushTextRun = (): void => {
if (hasTextRun) {
messageBuffer.addMessage(formatTextWithOptionsForTerminal(textRun), 'assistant')
textRun = ''
hasTextRun = false
}
}

for (const block of assistantMsg.message.content) {
if (block.type === 'text') {
messageBuffer.addMessage(block.text || '', 'assistant')
// Join with '' since SDK text blocks are already contiguous
// fragments of the raw stream.
textRun += block.text || ''
hasTextRun = true
} else if (block.type === 'tool_use') {
flushTextRun()
messageBuffer.addMessage(`🔧 Tool: ${block.name}`, 'tool')
if (block.input) {
const inputStr = JSON.stringify(block.input, null, 2)
Expand All @@ -84,8 +105,11 @@ export function formatClaudeMessageForInk(
messageBuffer.addMessage(`Input: ${inputStr}`, 'tool')
}
}
} else {
flushTextRun()
}
}
flushTextRun()
}
break
}
Expand All @@ -95,7 +119,7 @@ export function formatClaudeMessageForInk(
if (resultMsg.subtype === 'success') {
if ('result' in resultMsg && resultMsg.result) {
messageBuffer.addMessage('✨ Summary:', 'result')
messageBuffer.addMessage(resultMsg.result || '', 'result')
messageBuffer.addMessage(formatTextWithOptionsForTerminal(resultMsg.result || ''), 'result')
}

if (resultMsg.usage) {
Expand Down
130 changes: 130 additions & 0 deletions apps/cli/src/utils/optionsParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import {
formatOptionsXml,
formatTextWithOptionsForTerminal,
segmentTrailingOptions,
} from './optionsParser';

describe('segmentTrailingOptions', () => {
it('splits a trailing options block from the preceding prose', () => {
const input = 'Which approach do you prefer?\n\n<options>\n <option>Option A</option>\n <option>Option B</option>\n</options>';
const result = segmentTrailingOptions(input);
expect(result.before).toBe('Which approach do you prefer?\n\n');
expect(result.options).toEqual(['Option A', 'Option B']);
expect(result.hasIncompleteTrailingOptions).toBe(false);
});

it('preserves surrounding whitespace in before exactly (no trim)', () => {
const input = ' Leading and trailing spaces. \n<options>\n<option>Yes</option>\n</options>';
const result = segmentTrailingOptions(input);
expect(result.before).toBe(' Leading and trailing spaces. \n');
});

it('returns the full text as before when there is no options block', () => {
const input = ' Just a plain answer. ';
const result = segmentTrailingOptions(input);
expect(result.before).toBe(input);
expect(result.options).toEqual([]);
expect(result.hasIncompleteTrailingOptions).toBe(false);
});

it('matches tags case-insensitively', () => {
const input = 'Pick one:\n<OPTIONS>\n<OPTION>Yes</OPTION>\n<OPTION>No</OPTION>\n</OPTIONS>';
const result = segmentTrailingOptions(input);
expect(result.before).toBe('Pick one:\n');
expect(result.options).toEqual(['Yes', 'No']);
});

it('ignores empty option tags', () => {
const input = '<options><option></option><option>Keep me</option></options>';
const result = segmentTrailingOptions(input);
expect(result.options).toEqual(['Keep me']);
});

it('does NOT match an <options> block that is not at the end of the text', () => {
const input = 'See <options><option>A</option></options> then more prose follows.';
const result = segmentTrailingOptions(input);
expect(result.before).toBe(input);
expect(result.options).toEqual([]);
expect(result.hasIncompleteTrailingOptions).toBe(false);
});

it('flags an unclosed trailing options block as incomplete', () => {
const result = segmentTrailingOptions('Question?\n<options>\n<option>Yes</option>');
expect(result.hasIncompleteTrailingOptions).toBe(true);
expect(result.options).toEqual([]);
expect(result.before).toBe('Question?\n<options>\n<option>Yes</option>');
});

it('only segments the LAST/trailing block when several appear', () => {
const input = 'Literal <options><option>X</option></options> in prose.\n<options>\n<option>Real</option>\n</options>';
const result = segmentTrailingOptions(input);
expect(result.before).toBe('Literal <options><option>X</option></options> in prose.\n');
expect(result.options).toEqual(['Real']);
});
});

describe('formatOptionsXml', () => {
it('round-trips options through XML', () => {
const xml = formatOptionsXml(['One', 'Two']);
expect(segmentTrailingOptions(xml).options).toEqual(['One', 'Two']);
});

it('returns an empty string for no options', () => {
expect(formatOptionsXml([])).toBe('');
});
});

describe('formatTextWithOptionsForTerminal', () => {
it('replaces a trailing options block with a numbered list', () => {
const input = 'Which approach do you prefer?\n\n<options>\n <option>Option A</option>\n <option>Option B</option>\n</options>';
const result = formatTextWithOptionsForTerminal(input);
expect(result).toBe('Which approach do you prefer?\n\nOptions:\n 1. Option A\n 2. Option B');
expect(result).not.toContain('<options>');
});

it('renders a numbered list when the message is options-only', () => {
const input = '<options>\n<option>Yes</option>\n<option>No</option>\n</options>';
expect(formatTextWithOptionsForTerminal(input)).toBe('Options:\n 1. Yes\n 2. No');
});

it('returns text unchanged when there is no options block (exact equality)', () => {
const input = 'Plain answer with spacing\npreserved.';
expect(formatTextWithOptionsForTerminal(input)).toBe(input);
});

it('preserves leading and trailing whitespace of the prose exactly', () => {
const input = '\n\n Choose: \n<options>\n<option>A</option>\n<option>B</option>\n</options>';
expect(formatTextWithOptionsForTerminal(input)).toBe('\n\n Choose: \nOptions:\n 1. A\n 2. B');
});

it('leaves a fenced/literal <options> block in the MIDDLE of text untouched', () => {
const input = 'Here is how the markup looks:\n```xml\n<options>\n<option>A</option>\n<option>B</option>\n</options>\n```\nThat is the format.';
expect(formatTextWithOptionsForTerminal(input)).toBe(input);
});

it('leaves a literal <options> block untouched when prose follows it', () => {
const input = 'The tag <options><option>A</option></options> is used for menus, note the syntax.';
expect(formatTextWithOptionsForTerminal(input)).toBe(input);
});

it('renders only the LAST block, leaving earlier literal ones raw', () => {
const input = 'Example markup: <options><option>X</option></options>\nNow pick one:\n<options>\n<option>Real A</option>\n<option>Real B</option>\n</options>';
const result = formatTextWithOptionsForTerminal(input);
expect(result).toBe('Example markup: <options><option>X</option></options>\nNow pick one:\nOptions:\n 1. Real A\n 2. Real B');
// The earlier literal block is preserved verbatim.
expect(result).toContain('<options><option>X</option></options>');
// Only one block was rendered as a list.
expect(result.match(/Options:/g)?.length).toBe(1);
});

it('drops an empty trailing options block, preserving before exactly', () => {
const input = 'Before the empty block\n<options>\n</options>';
expect(formatTextWithOptionsForTerminal(input)).toBe('Before the empty block\n');
});

it('returns text unchanged for an incomplete (unclosed) trailing block', () => {
const input = 'Question?\n<options>\n<option>Yes</option>';
expect(formatTextWithOptionsForTerminal(input)).toBe(input);
});
});
Loading