mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
Merge pull request #2371 from QwenLM/feat/btw-command
feat(cli): add /btw slash command for ephemeral side questions
This commit is contained in:
commit
d709869aae
24 changed files with 1190 additions and 19 deletions
|
|
@ -31,6 +31,24 @@ export interface FlowStep {
|
|||
capture?: string;
|
||||
/** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */
|
||||
captureFull?: string;
|
||||
/**
|
||||
* Explicit sleep before executing this step (milliseconds).
|
||||
*
|
||||
* The runner's built-in idle detection (`idle(2000, 60000)`) works well for
|
||||
* synchronous streaming, but cannot anticipate async responses that arrive
|
||||
* after output has already stabilized (e.g., a /btw side-question whose API
|
||||
* response is serialized behind a main streaming task). In such cases, the
|
||||
* idle detector triggers too early and the async response is missed.
|
||||
*
|
||||
* Use `sleep` to bridge that gap — it inserts a fixed delay before the step
|
||||
* runs, giving async operations time to complete. Optional; omitting it (or
|
||||
* setting it to 0) has no effect on existing scenarios.
|
||||
*
|
||||
* @example
|
||||
* // Wait 20s for a /btw response before capturing the result
|
||||
* { sleep: 20000, capture: 'btw-answered.png' }
|
||||
*/
|
||||
sleep?: number;
|
||||
/**
|
||||
* Streaming capture: capture multiple screenshots during execution at intervals.
|
||||
* Useful for demonstrating real-time output like progress bars.
|
||||
|
|
@ -159,6 +177,11 @@ export async function runScenario(
|
|||
const step = config.flow[i];
|
||||
const label = `[${i + 1}/${config.flow.length}]`;
|
||||
|
||||
if (step.sleep && step.sleep > 0) {
|
||||
console.log(` ${label} 💤 sleep: ${step.sleep}ms`);
|
||||
await sleep(step.sleep);
|
||||
}
|
||||
|
||||
if (step.type) {
|
||||
const display =
|
||||
step.type.length > 60 ? step.type.slice(0, 60) + '...' : step.type;
|
||||
|
|
|
|||
|
|
@ -149,6 +149,33 @@ describe('handleSlashCommand', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should execute /btw when using the default allowed list', async () => {
|
||||
const mockBtwCommand = {
|
||||
name: 'btw',
|
||||
description: 'Ask a side question',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'btw> question\nanswer',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockBtwCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/btw question',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(mockBtwCommand.action).toHaveBeenCalled();
|
||||
expect(result.type).toBe('message');
|
||||
if (result.type === 'message') {
|
||||
expect(result.content).toBe('btw> question\nanswer');
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute file commands regardless of allowed list', async () => {
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
|
|||
'init',
|
||||
'summary',
|
||||
'compress',
|
||||
'btw',
|
||||
'bug',
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
|||
import { arenaCommand } from '../ui/commands/arenaCommand.js';
|
||||
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { btwCommand } from '../ui/commands/btwCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
|
|
@ -67,6 +68,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
arenaCommand,
|
||||
approvalModeCommand,
|
||||
authCommand,
|
||||
btwCommand,
|
||||
bugCommand,
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export const createMockCommandContext = (
|
|||
setDebugMessage: vi.fn(),
|
||||
pendingItem: null,
|
||||
setPendingItem: vi.fn(),
|
||||
btwItem: null,
|
||||
setBtwItem: vi.fn(),
|
||||
cancelBtw: vi.fn(),
|
||||
btwAbortControllerRef: { current: null },
|
||||
loadHistory: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
extensionsUpdateState: new Map(),
|
||||
|
|
|
|||
|
|
@ -434,6 +434,41 @@ describe('AppContainer State Management', () => {
|
|||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('submits /btw immediately instead of queueing while responding', () => {
|
||||
const mockSubmitQuery = vi.fn();
|
||||
const mockQueueMessage = vi.fn();
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: mockSubmitQuery,
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
retryLastPrompt: vi.fn(),
|
||||
});
|
||||
mockedUseMessageQueue.mockReturnValue({
|
||||
messageQueue: [],
|
||||
addMessage: mockQueueMessage,
|
||||
clearQueue: vi.fn(),
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
capturedUIActions.handleFinalSubmit('/btw quick side question');
|
||||
|
||||
expect(mockSubmitQuery).toHaveBeenCalledWith('/btw quick side question');
|
||||
expect(mockQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings Integration', () => {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
|||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { isBtwCommand } from './utils/commandUtils.js';
|
||||
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
|
|
@ -599,6 +600,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleSlashCommand,
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
|
|
@ -747,9 +751,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
streamingState === StreamingState.Responding &&
|
||||
isBtwCommand(submittedValue)
|
||||
) {
|
||||
void submitQuery(submittedValue);
|
||||
return;
|
||||
}
|
||||
addMessage(submittedValue);
|
||||
},
|
||||
[addMessage, agentViewState],
|
||||
[addMessage, agentViewState, streamingState, submitQuery],
|
||||
);
|
||||
|
||||
const handleArenaModelsSelected = useCallback(
|
||||
|
|
@ -947,6 +958,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [escapePressedOnce, setEscapePressedOnce] = useState(false);
|
||||
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const dialogsVisibleRef = useRef(false);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
|
|
@ -1233,7 +1245,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
return;
|
||||
} else if (keyMatchers[Command.ESCAPE](key)) {
|
||||
// Escape key handling
|
||||
// Dismiss or cancel btw side-question on Escape,
|
||||
// but only when btw is actually visible (not hidden behind a dialog).
|
||||
if (btwItem && !dialogsVisibleRef.current) {
|
||||
cancelBtw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if shell is focused (to allow shell's own escape handling)
|
||||
if (embeddedShellFocused) {
|
||||
return;
|
||||
|
|
@ -1275,6 +1293,20 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Dismiss completed btw side-question on Space or Enter,
|
||||
// but only when btw is visible and the input buffer is empty.
|
||||
if (
|
||||
btwItem &&
|
||||
!btwItem.btw.isPending &&
|
||||
!dialogsVisibleRef.current &&
|
||||
buffer.text.length === 0
|
||||
) {
|
||||
if (key.name === 'return' || key.sequence === ' ') {
|
||||
setBtwItem(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
|
|
@ -1329,6 +1361,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleSlashCommand,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
settings.merged.general?.debugKeystrokeLogging,
|
||||
isAuthenticating,
|
||||
],
|
||||
|
|
@ -1402,6 +1437,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen ||
|
||||
isExtensionsManagerDialogOpen;
|
||||
dialogsVisibleRef.current = dialogsVisible;
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
|
|
@ -1492,6 +1528,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
staticExtraHeight,
|
||||
dialogsVisible,
|
||||
pendingHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
nightly,
|
||||
branchName,
|
||||
sessionStats,
|
||||
|
|
@ -1588,6 +1627,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
staticExtraHeight,
|
||||
dialogsVisible,
|
||||
pendingHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
nightly,
|
||||
branchName,
|
||||
sessionStats,
|
||||
|
|
|
|||
464
packages/cli/src/ui/commands/btwCommand.test.ts
Normal file
464
packages/cli/src/ui/commands/btwCommand.test.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* @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>;
|
||||
const createConfig = (overrides: Record<string, unknown> = {}) => ({
|
||||
getGeminiClient: () => ({
|
||||
getHistory: mockGetHistory,
|
||||
generateContent: mockGenerateContent,
|
||||
}),
|
||||
getModel: () => 'test-model',
|
||||
getSessionId: () => 'test-session-id',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGenerateContent = vi.fn();
|
||||
mockGetHistory = vi.fn().mockReturnValue([]);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when model is not configured', async () => {
|
||||
const noModelContext = createMockCommandContext({
|
||||
services: {
|
||||
config: createConfig({
|
||||
getModel: () => '',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await btwCommand.action!(noModelContext, 'test question');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No model configured.',
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive mode', () => {
|
||||
const flushPromises = () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
it('should set btwItem and update it on success', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'The answer is 42.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'what is the meaning of life?');
|
||||
|
||||
// Action returns immediately; btwItem is set synchronously
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question: 'what is the meaning of life?',
|
||||
answer: '',
|
||||
isPending: true,
|
||||
},
|
||||
});
|
||||
|
||||
// pendingItem should NOT be used
|
||||
expect(mockContext.ui.setPendingItem).not.toHaveBeenCalled();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// On success, setBtwItem is called with the completed answer
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question: 'what is the meaning of life?',
|
||||
answer: 'The answer is 42.',
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
// addItem should NOT be called (btw stays in fixed area, not in history)
|
||||
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith(
|
||||
[
|
||||
...history,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: expect.stringContaining('my question'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{},
|
||||
expect.any(AbortSignal),
|
||||
'test-model',
|
||||
expect.stringMatching(/^test-session-id########btw-/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add error item on failure and clear btwItem', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('API error'));
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
await flushPromises();
|
||||
|
||||
// btwItem should be cleared on error
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith(null);
|
||||
|
||||
// Error goes to history
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to answer btw question: API error',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
mockGenerateContent.mockRejectedValue('string error');
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to answer btw question: string error',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not block when another pendingItem exists', async () => {
|
||||
const busyContext = createMockCommandContext({
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
ui: {
|
||||
pendingItem: { type: 'info' },
|
||||
},
|
||||
});
|
||||
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
// btw should NOT be blocked by pendingItem anymore
|
||||
const result = await btwCommand.action!(busyContext, 'test question');
|
||||
expect(result).toBeUndefined();
|
||||
expect(busyContext.ui.setBtwItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update btwItem when cancelled via btwAbortControllerRef', async () => {
|
||||
mockGenerateContent.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'late answer' }] } },
|
||||
],
|
||||
}),
|
||||
50,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
// The btw command should have registered its AbortController
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
|
||||
AbortController,
|
||||
);
|
||||
|
||||
// Simulate user pressing ESC: cancel the in-flight btw
|
||||
mockContext.ui.btwAbortControllerRef.current!.abort();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// setBtwItem should only have the initial pending call (no completion)
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(1);
|
||||
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear btwAbortControllerRef after successful completion', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
// Ref is set during the call
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
|
||||
AbortController,
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// After completion, ref should be cleaned up
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear btwAbortControllerRef after error', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('API error'));
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf(
|
||||
AbortController,
|
||||
);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(mockContext.ui.btwAbortControllerRef.current).toBeNull();
|
||||
});
|
||||
|
||||
it('should cancel previous btw when starting a new one', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'first question');
|
||||
|
||||
// cancelBtw should have been called to clean up any previous btw
|
||||
expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second btw call
|
||||
await btwCommand.action!(mockContext, 'second question');
|
||||
|
||||
// cancelBtw called again for the second invocation
|
||||
expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should return fallback text when response has no parts', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'test question');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question: 'test question',
|
||||
answer: 'No response received.',
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return void immediately without blocking', async () => {
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
const result = await btwCommand.action!(mockContext, 'test question');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// Only the pending setBtwItem called so far
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(1);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Now the completed setBtwItem has been called
|
||||
expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
let nonInteractiveContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
nonInteractiveContext = createMockCommandContext({
|
||||
executionMode: 'non_interactive',
|
||||
services: {
|
||||
config: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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: createConfig(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
226
packages/cli/src/ui/commands/btwCommand.ts
Normal file
226
packages/cli/src/ui/commands/btwCommand.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { HistoryItemBtw } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
|
||||
function makeBtwPromptId(sessionId: string): string {
|
||||
return `${sessionId}########btw-${Date.now()}`;
|
||||
}
|
||||
|
||||
function formatBtwError(error: unknown): string {
|
||||
return t('Failed to answer btw question: {{error}}', {
|
||||
error:
|
||||
error instanceof Error ? error.message : String(error || 'Unknown error'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to make the ephemeral generateContent call and extract the answer.
|
||||
* Uses a snapshot of the current conversation history as context.
|
||||
*/
|
||||
async function askBtw(
|
||||
geminiClient: GeminiClient,
|
||||
model: string,
|
||||
question: string,
|
||||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
): Promise<string> {
|
||||
const history = geminiClient.getHistory();
|
||||
|
||||
const response = await geminiClient.generateContent(
|
||||
[
|
||||
...history,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `[Side question - answer briefly and concisely, this is a "by the way" question that doesn't need to be part of our main conversation]\n\n${question}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{},
|
||||
abortSignal,
|
||||
model,
|
||||
promptId,
|
||||
);
|
||||
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
return (
|
||||
parts
|
||||
?.map((part) => part.text)
|
||||
.filter((text): text is string => typeof text === 'string')
|
||||
.join('') || t('No response received.')
|
||||
);
|
||||
}
|
||||
|
||||
export const btwCommand: SlashCommand = {
|
||||
name: 'btw',
|
||||
get description() {
|
||||
return t(
|
||||
'Ask a quick side question without affecting the main conversation',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
const question = args.trim();
|
||||
const executionMode = context.executionMode ?? 'interactive';
|
||||
const abortSignal = context.abortSignal ?? new AbortController().signal;
|
||||
|
||||
if (!question) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Please provide a question. Usage: /btw <your question>'),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
const { ui } = context;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const model = config.getModel();
|
||||
const sessionId = config.getSessionId();
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('No model configured.'),
|
||||
};
|
||||
}
|
||||
|
||||
// ACP mode: return a stream_messages async generator
|
||||
if (executionMode === 'acp') {
|
||||
const btwPromptId = makeBtwPromptId(sessionId);
|
||||
const messages = async function* () {
|
||||
try {
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: t('Thinking...'),
|
||||
};
|
||||
|
||||
const answer = await askBtw(
|
||||
geminiClient,
|
||||
model,
|
||||
question,
|
||||
abortSignal,
|
||||
btwPromptId,
|
||||
);
|
||||
|
||||
yield {
|
||||
messageType: 'info' as const,
|
||||
content: `btw> ${question}\n${answer}`,
|
||||
};
|
||||
} catch (error) {
|
||||
yield {
|
||||
messageType: 'error' as const,
|
||||
content: formatBtwError(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return { type: 'stream_messages', messages: messages() };
|
||||
}
|
||||
|
||||
// Non-interactive mode: return a simple message result
|
||||
if (executionMode === 'non_interactive') {
|
||||
try {
|
||||
const btwPromptId = makeBtwPromptId(sessionId);
|
||||
const answer = await askBtw(
|
||||
geminiClient,
|
||||
model,
|
||||
question,
|
||||
abortSignal,
|
||||
btwPromptId,
|
||||
);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `btw> ${question}\n${answer}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: formatBtwError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive mode: use dedicated btwItem state for the fixed bottom area.
|
||||
// This does NOT occupy pendingItem, so the main conversation is never blocked.
|
||||
|
||||
// Cancel any previous in-flight btw before starting a new one.
|
||||
ui.cancelBtw();
|
||||
|
||||
const btwAbortController = new AbortController();
|
||||
const btwSignal = btwAbortController.signal;
|
||||
ui.btwAbortControllerRef.current = btwAbortController;
|
||||
|
||||
const pendingItem: HistoryItemBtw = {
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question,
|
||||
answer: '',
|
||||
isPending: true,
|
||||
},
|
||||
};
|
||||
ui.setBtwItem(pendingItem);
|
||||
|
||||
// Fire-and-forget: run the API call in the background so the main
|
||||
// conversation is not blocked while waiting for the btw answer.
|
||||
const btwPromptId = makeBtwPromptId(sessionId);
|
||||
void askBtw(geminiClient, model, question, btwSignal, btwPromptId)
|
||||
.then((answer) => {
|
||||
if (btwSignal.aborted) return;
|
||||
|
||||
ui.btwAbortControllerRef.current = null;
|
||||
const completedItem: HistoryItemBtw = {
|
||||
type: MessageType.BTW,
|
||||
btw: {
|
||||
question,
|
||||
answer,
|
||||
isPending: false,
|
||||
},
|
||||
};
|
||||
ui.setBtwItem(completedItem);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (btwSignal.aborted) return;
|
||||
|
||||
ui.btwAbortControllerRef.current = null;
|
||||
ui.setBtwItem(null);
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: formatBtwError(error),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -4,12 +4,13 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { MutableRefObject, ReactNode } from 'react';
|
||||
import type { Content, PartListUnion } from '@google/genai';
|
||||
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
HistoryItemBtw,
|
||||
ConfirmationRequest,
|
||||
} from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
|
@ -66,6 +67,14 @@ export interface CommandContext {
|
|||
* @param item The history item to display as pending, or `null` to clear.
|
||||
*/
|
||||
setPendingItem: (item: HistoryItemWithoutId | null) => void;
|
||||
/** The current btw side-question item rendered in the fixed bottom area. */
|
||||
btwItem: HistoryItemBtw | null;
|
||||
/** Sets the btw item independently of the main pendingItem. */
|
||||
setBtwItem: (item: HistoryItemBtw | null) => void;
|
||||
/** Cancels a pending btw (aborts the in-flight API call and clears the btw area). */
|
||||
cancelBtw: () => void;
|
||||
/** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */
|
||||
btwAbortControllerRef: MutableRefObject<AbortController | null>;
|
||||
/**
|
||||
* Loads a new set of history items, replacing the current history.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { McpStatus } from './views/McpStatus.js';
|
|||
import { ContextUsage } from './views/ContextUsage.js';
|
||||
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
|
||||
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
|
||||
import { BtwMessage } from './messages/BtwMessage.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
|
|
@ -226,6 +227,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'insight_progress' && (
|
||||
<InsightProgressMessage progress={itemForDisplay.progress} />
|
||||
)}
|
||||
{itemForDisplay.type === 'btw' && itemForDisplay.btw && (
|
||||
<BtwMessage btw={itemForDisplay.btw} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
34
packages/cli/src/ui/components/messages/BtwMessage.test.tsx
Normal file
34
packages/cli/src/ui/components/messages/BtwMessage.test.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { BtwMessage } from './BtwMessage.js';
|
||||
|
||||
describe('BtwMessage', () => {
|
||||
it('is wrapped in React.memo to avoid unnecessary layout rerenders', () => {
|
||||
expect((BtwMessage as unknown as { $$typeof?: symbol }).$$typeof).toBe(
|
||||
Symbol.for('react.memo'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the side question and answer', () => {
|
||||
const { lastFrame } = render(
|
||||
<BtwMessage
|
||||
btw={{
|
||||
question: 'side question',
|
||||
answer: 'side answer',
|
||||
isPending: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('/btw');
|
||||
expect(output).toContain('side question');
|
||||
expect(output).toContain('side answer');
|
||||
});
|
||||
});
|
||||
54
packages/cli/src/ui/components/messages/BtwMessage.tsx
Normal file
54
packages/cli/src/ui/components/messages/BtwMessage.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { BtwProps } from '../../types.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
export interface BtwDisplayProps {
|
||||
btw: BtwProps;
|
||||
}
|
||||
|
||||
const BtwMessageInternal: React.FC<BtwDisplayProps> = ({ btw }) => (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Text color={Colors.AccentYellow} bold>
|
||||
{'/btw '}
|
||||
</Text>
|
||||
<Text wrap="wrap" color={Colors.AccentYellow}>
|
||||
{btw.question}
|
||||
</Text>
|
||||
</Box>
|
||||
{btw.isPending ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={Colors.AccentYellow}>{'+ '}</Text>
|
||||
<Text color={Colors.AccentYellow}>{t('Answering...')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t('Press Escape to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text wrap="wrap">{btw.answer}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t('Press Space, Enter, or Escape to dismiss')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const BtwMessage = React.memo(BtwMessageInternal);
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemBtw,
|
||||
ThoughtSummary,
|
||||
ShellConfirmationRequest,
|
||||
ConfirmationRequest,
|
||||
|
|
@ -104,6 +105,9 @@ export interface UIState {
|
|||
staticExtraHeight: number;
|
||||
dialogsVisible: boolean;
|
||||
pendingHistoryItems: HistoryItemWithoutId[];
|
||||
btwItem: HistoryItemBtw | null;
|
||||
setBtwItem: (item: HistoryItemBtw | null) => void;
|
||||
cancelBtw: () => void;
|
||||
nightly: boolean;
|
||||
branchName: string | undefined;
|
||||
sessionStats: SessionStatsState;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { useSessionStats } from '../contexts/SessionContext.js';
|
|||
import type {
|
||||
Message,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemBtw,
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItem,
|
||||
ConfirmationRequest,
|
||||
|
|
@ -36,6 +37,7 @@ import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
|
|||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import { isBtwCommand } from '../utils/commandUtils.js';
|
||||
import { clearScreen } from '../../utils/stdioHelpers.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import {
|
||||
|
|
@ -63,6 +65,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
|||
'reset',
|
||||
'new',
|
||||
'resume',
|
||||
'btw',
|
||||
]);
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
|
|
@ -139,10 +142,20 @@ export const useSlashCommandProcessor = (
|
|||
null,
|
||||
);
|
||||
|
||||
const [btwItem, setBtwItem] = useState<HistoryItemBtw | null>(null);
|
||||
const btwAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelBtw = useCallback(() => {
|
||||
btwAbortControllerRef.current?.abort();
|
||||
btwAbortControllerRef.current = null;
|
||||
setBtwItem(null);
|
||||
}, []);
|
||||
|
||||
// AbortController for cancelling async slash commands via ESC
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelSlashCommand = useCallback(() => {
|
||||
cancelBtw();
|
||||
if (!abortControllerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -156,7 +169,7 @@ export const useSlashCommandProcessor = (
|
|||
);
|
||||
setPendingItem(null);
|
||||
setIsProcessing(false);
|
||||
}, [addItem, setIsProcessing]);
|
||||
}, [addItem, setIsProcessing, cancelBtw]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
|
@ -251,6 +264,10 @@ export const useSlashCommandProcessor = (
|
|||
setDebugMessage: actions.setDebugMessage,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
btwAbortControllerRef,
|
||||
toggleVimEnabled,
|
||||
setGeminiMdFileCount,
|
||||
reloadCommands,
|
||||
|
|
@ -279,6 +296,9 @@ export const useSlashCommandProcessor = (
|
|||
actions,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
toggleVimEnabled,
|
||||
sessionShellAllowlist,
|
||||
setGeminiMdFileCount,
|
||||
|
|
@ -366,10 +386,12 @@ export const useSlashCommandProcessor = (
|
|||
abortControllerRef.current = abortController;
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
addItemWithRecording(
|
||||
{ type: MessageType.USER, text: trimmed },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
if (!isBtwCommand(trimmed)) {
|
||||
addItemWithRecording(
|
||||
{ type: MessageType.USER, text: trimmed },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
const {
|
||||
|
|
@ -727,6 +749,9 @@ export const useSlashCommandProcessor = (
|
|||
handleSlashCommand,
|
||||
slashCommands: commands,
|
||||
pendingHistoryItems,
|
||||
btwItem,
|
||||
setBtwItem,
|
||||
cancelBtw,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
|
|
|
|||
|
|
@ -834,7 +834,7 @@ describe('useGeminiStream', () => {
|
|||
|
||||
// Wait for the first part of the response
|
||||
await waitFor(() => {
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call cancelOngoingRequest directly
|
||||
|
|
@ -983,7 +983,7 @@ describe('useGeminiStream', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Cancel the request
|
||||
|
|
@ -2709,6 +2709,109 @@ describe('useGeminiStream', () => {
|
|||
});
|
||||
|
||||
describe('Concurrent Execution Prevention', () => {
|
||||
it('should allow /btw slash commands while a main response is in progress', async () => {
|
||||
let resolveFirstCall!: () => void;
|
||||
|
||||
const firstCallPromise = new Promise<void>((resolve) => {
|
||||
resolveFirstCall = resolve;
|
||||
});
|
||||
|
||||
const firstStream = (async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'First call content',
|
||||
};
|
||||
await firstCallPromise;
|
||||
})();
|
||||
|
||||
mockSendMessageStream.mockImplementation(() => firstStream);
|
||||
mockHandleSlashCommand.mockImplementation(async (command) => {
|
||||
if (command === '/btw quick side question') {
|
||||
return { type: 'handled' };
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
let mainRequest!: Promise<void>;
|
||||
await act(async () => {
|
||||
mainRequest = result.current.submitQuery('First query');
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/btw quick side question');
|
||||
});
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/btw quick side question',
|
||||
);
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
resolveFirstCall();
|
||||
await mainRequest;
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep the main request cancellable after submitting /btw in parallel', async () => {
|
||||
let resolveFirstCall!: () => void;
|
||||
let mainAbortSignal: AbortSignal | undefined;
|
||||
|
||||
const firstCallPromise = new Promise<void>((resolve) => {
|
||||
resolveFirstCall = resolve;
|
||||
});
|
||||
|
||||
mockSendMessageStream.mockImplementation((_query, signal) => {
|
||||
mainAbortSignal = signal;
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'First call content',
|
||||
};
|
||||
await firstCallPromise;
|
||||
})();
|
||||
});
|
||||
mockHandleSlashCommand.mockImplementation(async (command) => {
|
||||
if (command === '/btw quick side question') {
|
||||
return { type: 'handled' };
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const { result } = renderTestHook();
|
||||
|
||||
let mainRequest!: Promise<void>;
|
||||
await act(async () => {
|
||||
mainRequest = result.current.submitQuery('First query');
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => {
|
||||
expect(mainAbortSignal).toBeDefined();
|
||||
expect(result.current.streamingState).toBe(StreamingState.Responding);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/btw quick side question');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.cancelOngoingRequest();
|
||||
});
|
||||
|
||||
expect(mainAbortSignal?.aborted).toBe(true);
|
||||
} finally {
|
||||
resolveFirstCall();
|
||||
await mainRequest;
|
||||
}
|
||||
});
|
||||
|
||||
it('should prevent concurrent submitQuery calls', async () => {
|
||||
let resolveFirstCall!: () => void;
|
||||
let resolveSecondCall!: () => void;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ import type {
|
|||
SlashCommandProcessorResult,
|
||||
} from '../types.js';
|
||||
import { StreamingState, MessageType, ToolCallStatus } from '../types.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import {
|
||||
isAtCommand,
|
||||
isBtwCommand,
|
||||
isSlashCommand,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
|
|
@ -1094,11 +1098,18 @@ export const useGeminiStream = (
|
|||
submitType: SendMessageType = SendMessageType.UserQuery,
|
||||
prompt_id?: string,
|
||||
) => {
|
||||
const allowConcurrentBtwDuringResponse =
|
||||
submitType === SendMessageType.UserQuery &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
typeof query === 'string' &&
|
||||
isBtwCommand(query);
|
||||
|
||||
// Prevent concurrent executions of submitQuery, but allow continuations
|
||||
// which are part of the same logical flow (tool responses)
|
||||
if (
|
||||
isSubmittingQueryRef.current &&
|
||||
submitType !== SendMessageType.ToolResult
|
||||
submitType !== SendMessageType.ToolResult &&
|
||||
!allowConcurrentBtwDuringResponse
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1106,7 +1117,8 @@ export const useGeminiStream = (
|
|||
if (
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation) &&
|
||||
submitType !== SendMessageType.ToolResult
|
||||
submitType !== SendMessageType.ToolResult &&
|
||||
!allowConcurrentBtwDuringResponse
|
||||
)
|
||||
return;
|
||||
|
||||
|
|
@ -1116,7 +1128,10 @@ export const useGeminiStream = (
|
|||
const userMessageTimestamp = Date.now();
|
||||
|
||||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (submitType !== SendMessageType.ToolResult) {
|
||||
if (
|
||||
submitType !== SendMessageType.ToolResult &&
|
||||
!allowConcurrentBtwDuringResponse
|
||||
) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
// Commit any pending retry error to history (without hint) since the
|
||||
// user is starting a new conversation turn.
|
||||
|
|
@ -1130,9 +1145,15 @@ export const useGeminiStream = (
|
|||
}
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
const abortSignal = abortControllerRef.current.signal;
|
||||
turnCancelledRef.current = false;
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = abortController.signal;
|
||||
|
||||
// Keep the main stream's cancellation state intact while /btw is handled
|
||||
// in parallel. The side-question can use its own local abort signal.
|
||||
if (!allowConcurrentBtwDuringResponse) {
|
||||
abortControllerRef.current = abortController;
|
||||
turnCancelledRef.current = false;
|
||||
}
|
||||
|
||||
if (!prompt_id) {
|
||||
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { MainContent } from '../components/MainContent.js';
|
|||
import { DialogManager } from '../components/DialogManager.js';
|
||||
import { Composer } from '../components/Composer.js';
|
||||
import { ExitWarning } from '../components/ExitWarning.js';
|
||||
import { BtwMessage } from '../components/messages/BtwMessage.js';
|
||||
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
|
||||
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
|
||||
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
|
||||
|
|
@ -66,6 +67,10 @@ export const DefaultAppLayout: React.FC = () => {
|
|||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
</Box>
|
||||
) : uiState.btwItem ? (
|
||||
<Box marginX={2} width={terminalWidth - 4}>
|
||||
<BtwMessage btw={uiState.btwItem.btw} />
|
||||
</Box>
|
||||
) : (
|
||||
<Composer />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { DialogManager } from '../components/DialogManager.js';
|
|||
import { Composer } from '../components/Composer.js';
|
||||
import { Footer } from '../components/Footer.js';
|
||||
import { ExitWarning } from '../components/ExitWarning.js';
|
||||
import { BtwMessage } from '../components/messages/BtwMessage.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
export const ScreenReaderAppLayout: React.FC = () => {
|
||||
|
|
@ -24,6 +25,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
|||
<Box flexGrow={1} overflow="hidden">
|
||||
<MainContent />
|
||||
</Box>
|
||||
|
||||
{uiState.dialogsVisible ? (
|
||||
<Box marginX={2} flexDirection="column" width={uiState.mainAreaWidth}>
|
||||
<DialogManager
|
||||
|
|
@ -31,6 +33,10 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
|||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
</Box>
|
||||
) : uiState.btwItem ? (
|
||||
<Box marginX={2} width={uiState.terminalWidth - 4}>
|
||||
<BtwMessage btw={uiState.btwItem.btw} />
|
||||
</Box>
|
||||
) : (
|
||||
<Composer />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
|||
loadHistory: (_newHistory) => {},
|
||||
pendingItem: null,
|
||||
setPendingItem: (_item) => {},
|
||||
btwItem: null,
|
||||
setBtwItem: (_item) => {},
|
||||
cancelBtw: () => {},
|
||||
btwAbortControllerRef: { current: null },
|
||||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
|
|
|
|||
|
|
@ -350,6 +350,17 @@ export type HistoryItemInsightProgress = HistoryItemBase & {
|
|||
progress: InsightProgressProps;
|
||||
};
|
||||
|
||||
export interface BtwProps {
|
||||
question: string;
|
||||
answer: string;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export type HistoryItemBtw = HistoryItemBase & {
|
||||
type: 'btw';
|
||||
btw: BtwProps;
|
||||
};
|
||||
|
||||
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
|
||||
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
|
||||
// 'tools' in historyItem.
|
||||
|
|
@ -383,7 +394,8 @@ export type HistoryItemWithoutId =
|
|||
| HistoryItemContextUsage
|
||||
| HistoryItemArenaAgentComplete
|
||||
| HistoryItemArenaSessionComplete
|
||||
| HistoryItemInsightProgress;
|
||||
| HistoryItemInsightProgress
|
||||
| HistoryItemBtw;
|
||||
|
||||
export type HistoryItem = HistoryItemWithoutId & { id: number };
|
||||
|
||||
|
|
@ -411,6 +423,7 @@ export enum MessageType {
|
|||
ARENA_AGENT_COMPLETE = 'arena_agent_complete',
|
||||
ARENA_SESSION_COMPLETE = 'arena_session_complete',
|
||||
INSIGHT_PROGRESS = 'insight_progress',
|
||||
BTW = 'btw',
|
||||
}
|
||||
|
||||
export interface InsightProgressProps {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,17 @@ export const isSlashCommand = (query: string): boolean => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const BTW_COMMAND_RE = /^[/?]btw(?:\s|$)/;
|
||||
|
||||
/**
|
||||
* Checks if a query is a /btw side-question invocation.
|
||||
* Accepts both "/btw" and "?btw" prefixes.
|
||||
*/
|
||||
export const isBtwCommand = (query: string): boolean => {
|
||||
const trimmed = query.trim();
|
||||
return trimmed.length > 0 && BTW_COMMAND_RE.test(trimmed);
|
||||
};
|
||||
|
||||
const debugLogger = createDebugLogger('COMMAND_UTILS');
|
||||
|
||||
// Copies a string snippet to the clipboard for different platforms
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
import { getCoreSystemPrompt, getCustomSystemPrompt } from './prompts.js';
|
||||
import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { promptIdContext } from '../utils/promptIdContext.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
|
|
@ -2441,6 +2442,55 @@ Other open files:
|
|||
);
|
||||
});
|
||||
|
||||
it('should prefer the current prompt id context for stateless requests', async () => {
|
||||
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await promptIdContext.run('btw-prompt-id', async () => {
|
||||
await client.generateContent(
|
||||
contents,
|
||||
{},
|
||||
abortSignal,
|
||||
DEFAULT_QWEN_FLASH_MODEL,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_QWEN_FLASH_MODEL,
|
||||
contents,
|
||||
}),
|
||||
'btw-prompt-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer an explicit prompt id override over the current context', async () => {
|
||||
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
await promptIdContext.run('context-prompt-id', async () => {
|
||||
await (
|
||||
client.generateContent as unknown as (
|
||||
...args: unknown[]
|
||||
) => Promise<GenerateContentResponse>
|
||||
)(
|
||||
contents,
|
||||
{},
|
||||
abortSignal,
|
||||
DEFAULT_QWEN_FLASH_MODEL,
|
||||
'override-prompt-id',
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockContentGenerator.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: DEFAULT_QWEN_FLASH_MODEL,
|
||||
contents,
|
||||
}),
|
||||
'override-prompt-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use config system prompt override when provided', async () => {
|
||||
const contents = [{ role: 'user', parts: [{ text: 'hello' }] }];
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import { reportError } from '../utils/errorReporting.js';
|
|||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||
import { flatMapTextParts } from '../utils/partUtils.js';
|
||||
import { promptIdContext } from '../utils/promptIdContext.js';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
|
||||
// Hook types and utilities
|
||||
|
|
@ -786,8 +787,11 @@ export class GeminiClient {
|
|||
generationConfig: GenerateContentConfig,
|
||||
abortSignal: AbortSignal,
|
||||
model: string,
|
||||
promptIdOverride?: string,
|
||||
): Promise<GenerateContentResponse> {
|
||||
let currentAttemptModel: string = model;
|
||||
const promptId =
|
||||
promptIdOverride ?? promptIdContext.getStore() ?? this.lastPromptId!;
|
||||
|
||||
try {
|
||||
const userMemory = this.config.getUserMemory();
|
||||
|
|
@ -810,7 +814,7 @@ export class GeminiClient {
|
|||
config: requestConfig,
|
||||
contents,
|
||||
},
|
||||
this.lastPromptId!,
|
||||
promptId,
|
||||
);
|
||||
};
|
||||
const result = await retryWithBackoff(apiCall, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue