fix(cli): extract duplicate error formatting and add tests for /btw command

Extract repeated error formatting into formatBtwError helper, remove
no-op marginTop={0}, and add comprehensive test coverage for all three
execution modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shaojin Wen 2026-03-14 16:20:21 +08:00
parent fda065314f
commit d285c4409a
3 changed files with 386 additions and 10 deletions

View file

@ -0,0 +1,376 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { btwCommand } from './btwCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
vi.mock('../../i18n/index.js', () => ({
t: (key: string, params?: Record<string, string>) => {
if (params) {
return Object.entries(params).reduce(
(str, [k, v]) => str.replace(`{{${k}}}`, v),
key,
);
}
return key;
},
}));
describe('btwCommand', () => {
let mockContext: CommandContext;
let mockGenerateContent: ReturnType<typeof vi.fn>;
let mockGetHistory: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockGenerateContent = vi.fn();
mockGetHistory = vi.fn().mockReturnValue([]);
mockContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({
getHistory: mockGetHistory,
generateContent: mockGenerateContent,
}),
getModel: () => 'test-model',
},
},
});
});
it('should have correct metadata', () => {
expect(btwCommand.name).toBe('btw');
expect(btwCommand.kind).toBe(CommandKind.BUILT_IN);
expect(btwCommand.description).toBeTruthy();
});
it('should return error when no question is provided', async () => {
const result = await btwCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Please provide a question. Usage: /btw <your question>',
});
});
it('should return error when only whitespace is provided', async () => {
const result = await btwCommand.action!(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Please provide a question. Usage: /btw <your question>',
});
});
it('should return error when config is not loaded', async () => {
const noConfigContext = createMockCommandContext({
services: { config: null },
});
const result = await btwCommand.action!(noConfigContext, 'test question');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
describe('interactive mode', () => {
it('should set pending item and add completed item on success', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'The answer is 42.' }],
},
},
],
});
await btwCommand.action!(mockContext, 'what is the meaning of life?');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.BTW,
btw: {
question: 'what is the meaning of life?',
answer: '',
isPending: true,
},
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.BTW,
btw: {
question: 'what is the meaning of life?',
answer: 'The answer is 42.',
isPending: false,
},
},
expect.any(Number),
);
expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null);
});
it('should pass conversation history to generateContent', async () => {
const history = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi!' }] },
];
mockGetHistory.mockReturnValue(history);
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
});
await btwCommand.action!(mockContext, 'my question');
expect(mockGenerateContent).toHaveBeenCalledWith(
[
...history,
{
role: 'user',
parts: [
{
text: expect.stringContaining('my question'),
},
],
},
],
{},
expect.any(AbortSignal),
'test-model',
);
});
it('should add error item on failure', async () => {
mockGenerateContent.mockRejectedValue(new Error('API error'));
await btwCommand.action!(mockContext, 'test question');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed to answer btw question: API error',
},
expect.any(Number),
);
expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null);
});
it('should handle non-Error exceptions', async () => {
mockGenerateContent.mockRejectedValue('string error');
await btwCommand.action!(mockContext, 'test question');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed to answer btw question: string error',
},
expect.any(Number),
);
});
it('should return error when another operation is pending', async () => {
const busyContext = createMockCommandContext({
services: {
config: {
getGeminiClient: () => ({
getHistory: mockGetHistory,
generateContent: mockGenerateContent,
}),
getModel: () => 'test-model',
},
},
ui: {
pendingItem: { type: 'info' },
},
});
const result = await btwCommand.action!(busyContext, 'test question');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'Another operation is in progress. Please wait for it to complete.',
});
});
it('should not add item when abort signal is aborted', async () => {
const abortController = new AbortController();
abortController.abort();
const abortContext = createMockCommandContext({
abortSignal: abortController.signal,
services: {
config: {
getGeminiClient: () => ({
getHistory: mockGetHistory,
generateContent: mockGenerateContent,
}),
getModel: () => 'test-model',
},
},
});
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
});
await btwCommand.action!(abortContext, 'test question');
expect(abortContext.ui.addItem).not.toHaveBeenCalled();
expect(abortContext.ui.setPendingItem).toHaveBeenLastCalledWith(null);
});
it('should return fallback text when response has no parts', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [] } }],
});
await btwCommand.action!(mockContext, 'test question');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.BTW,
btw: {
question: 'test question',
answer: 'No response received.',
isPending: false,
},
},
expect.any(Number),
);
});
});
describe('non-interactive mode', () => {
let nonInteractiveContext: CommandContext;
beforeEach(() => {
nonInteractiveContext = createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getGeminiClient: () => ({
getHistory: mockGetHistory,
generateContent: mockGenerateContent,
}),
getModel: () => 'test-model',
},
},
});
});
it('should return info message on success', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'the answer' }] } }],
});
const result = await btwCommand.action!(
nonInteractiveContext,
'my question',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'btw> my question\nthe answer',
});
});
it('should return error message on failure', async () => {
mockGenerateContent.mockRejectedValue(new Error('network error'));
const result = await btwCommand.action!(
nonInteractiveContext,
'my question',
);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to answer btw question: network error',
});
});
});
describe('acp mode', () => {
let acpContext: CommandContext;
beforeEach(() => {
acpContext = createMockCommandContext({
executionMode: 'acp',
services: {
config: {
getGeminiClient: () => ({
getHistory: mockGetHistory,
generateContent: mockGenerateContent,
}),
getModel: () => 'test-model',
},
},
});
});
it('should return stream_messages generator on success', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'streamed answer' }] } }],
});
const result = (await btwCommand.action!(acpContext, 'my question')) as {
type: string;
messages: AsyncGenerator;
};
expect(result.type).toBe('stream_messages');
const messages = [];
for await (const msg of result.messages) {
messages.push(msg);
}
expect(messages).toEqual([
{ messageType: 'info', content: 'Thinking...' },
{ messageType: 'info', content: 'btw> my question\nstreamed answer' },
]);
});
it('should yield error message on failure', async () => {
mockGenerateContent.mockRejectedValue(new Error('api failure'));
const result = (await btwCommand.action!(acpContext, 'my question')) as {
type: string;
messages: AsyncGenerator;
};
const messages = [];
for await (const msg of result.messages) {
messages.push(msg);
}
expect(messages).toEqual([
{ messageType: 'info', content: 'Thinking...' },
{
messageType: 'error',
content: 'Failed to answer btw question: api failure',
},
]);
});
});
});

View file

@ -15,6 +15,12 @@ import type { HistoryItemBtw } from '../types.js';
import { t } from '../../i18n/index.js';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
function formatBtwError(error: unknown): string {
return t('Failed to answer btw question: {{error}}', {
error: error instanceof Error ? error.message : String(error),
});
}
/**
* Helper to make the ephemeral generateContent call and extract the answer.
* Uses a snapshot of the current conversation history as context.
@ -114,9 +120,7 @@ export const btwCommand: SlashCommand = {
} catch (error) {
yield {
messageType: 'error' as const,
content: t('Failed to answer btw question: {{error}}', {
error: error instanceof Error ? error.message : String(error),
}),
content: formatBtwError(error),
};
}
};
@ -137,9 +141,7 @@ export const btwCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: t('Failed to answer btw question: {{error}}', {
error: error instanceof Error ? error.message : String(error),
}),
content: formatBtwError(error),
};
}
}
@ -189,9 +191,7 @@ export const btwCommand: SlashCommand = {
ui.addItem(
{
type: MessageType.ERROR,
text: t('Failed to answer btw question: {{error}}', {
error: error instanceof Error ? error.message : String(error),
}),
text: formatBtwError(error),
},
Date.now(),
);

View file

@ -29,7 +29,7 @@ export const BtwMessage: React.FC<BtwDisplayProps> = ({ btw }) => (
{btw.question}
</Text>
</Box>
<Box flexDirection="row" marginTop={0}>
<Box flexDirection="row">
{btw.isPending ? (
<Box>
<Box marginRight={1}>