feat(cli): run safe slash commands during streaming (#8130)

This commit is contained in:
Dragon 2026-07-31 21:41:38 +08:00 committed by GitHub
parent c82b6a5349
commit 29e9b352d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 191 additions and 0 deletions

View file

@ -0,0 +1,55 @@
# Non-blocking Slash Commands During Streaming
## Problem
The interactive input router currently queues every slash command except
`/btw` while a model response is streaming. This makes local UI controls wait
for the active conversation turn even when their result does not depend on that
turn.
## Design
`SlashCommand` gains an opt-in `canRunDuringStreaming` capability. The default
remains false. While the main model is responding, the input router resolves the
submitted command through the existing slash-command tree. An opted-in command
is sent directly to the slash-command processor; all other slash commands keep
using the existing serialized message queue.
The direct path does not go through `submitQuery`. That function owns the model
turn lifecycle and deliberately rejects concurrent top-level turns. Keeping
local commands outside it avoids sharing abort controllers, submission flags,
or model-stream counters with the active response.
The slash-command processor and command results already update Ink through
React state. The initial commands therefore do not write directly to terminal
stdout while Ink is rendering.
## Initial Command Set
- `/status`, `/about`, and `/status paths`: read local runtime information and
append an Ink history item.
- `/settings`: opens the settings dialog; saved changes apply through the
existing settings hooks without replacing the active conversation turn.
- `/help`: opens the static help dialog.
The following categories remain serialized:
- Commands that submit or transform a model turn, such as skills, `/summary`,
`/compress`, `/model <model> <prompt>`, and `/goal`.
- Commands that replace, clear, rewind, resume, branch, or otherwise mutate
conversation state.
- Commands that schedule tools or perform long-running external work.
- Commands that read state being mutated by the active turn, such as
`/context`, `/stats`, `/copy`, `/diff`, and `/recap`.
`/btw` keeps its specialized concurrent model-request path. `/quit` keeps its
existing immediate cancellation path. Ctrl+Q continues to force any submission
to wait for idle, including an otherwise opted-in command.
## Verification
Unit coverage verifies that opted-in commands bypass both `submitQuery` and the
message queue during a response, while unmarked slash commands remain queued.
Command tests pin the initial capability declarations. Interactive E2E checks
should start a visibly streaming response, open each opted-in command, close any
dialog, and confirm that the original response continues and completes.

View file

@ -811,6 +811,56 @@ describe('AppContainer State Management', () => {
});
describe('Context Providers', () => {
const renderRespondingInput = (
slashCommands: Array<{
name: string;
description: string;
kind: 'built-in';
canRunDuringStreaming?: boolean;
}>,
) => {
const handleSlashCommand = vi.fn();
const submitQuery = vi.fn();
const addMessage = vi.fn();
mockedUseSlashCommandProcessor.mockReturnValue({
handleSlashCommand,
slashCommands,
pendingHistoryItems: [],
commandContext: {},
shellConfirmationRequest: null,
confirmationRequest: null,
});
mockedUseGeminiStream.mockReturnValue({
streamingState: 'responding',
submitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: [],
addMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextTurn: vi.fn().mockReturnValue(null),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
return { handleSlashCommand, submitQuery, addMessage };
};
it('provides AppContext with correct values', () => {
const { unmount } = render(
<AppContainer
@ -1393,6 +1443,66 @@ describe('AppContainer State Management', () => {
expect(mockQueueMessage).not.toHaveBeenCalled();
});
it('runs opted-in slash commands outside the active turn while responding', () => {
const { handleSlashCommand, submitQuery, addMessage } =
renderRespondingInput([
{
name: 'settings',
description: 'Open settings',
kind: 'built-in',
canRunDuringStreaming: true,
},
]);
capturedUIActions.handleFinalSubmit('/settings', {
submittedPrompt: '/settings',
});
expect(handleSlashCommand).toHaveBeenCalledWith('/settings');
expect(submitQuery).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
});
it('keeps opted-in slash commands queued when Ctrl+Q defers them', () => {
const { handleSlashCommand, submitQuery, addMessage } =
renderRespondingInput([
{
name: 'settings',
description: 'Open settings',
kind: 'built-in',
canRunDuringStreaming: true,
},
]);
capturedUIActions.handleFinalSubmit('/settings', {
deferUntilIdle: true,
submittedPrompt: '/settings',
});
expect(addMessage).toHaveBeenCalledWith('/settings', true, '/settings');
expect(handleSlashCommand).not.toHaveBeenCalled();
expect(submitQuery).not.toHaveBeenCalled();
});
it('keeps turn-dependent slash commands queued while responding', () => {
const { handleSlashCommand, submitQuery, addMessage } =
renderRespondingInput([
{
name: 'model',
description: 'Change model',
kind: 'built-in',
},
]);
capturedUIActions.handleFinalSubmit('/model', {
submittedPrompt: '/model',
});
expect(addMessage).toHaveBeenCalledWith('/model', false, '/model');
expect(handleSlashCommand).not.toHaveBeenCalled();
expect(submitQuery).not.toHaveBeenCalled();
});
it('submits slash commands immediately instead of queueing while idle', () => {
const mockSubmitQuery = vi.fn();
const mockQueueMessage = vi.fn();

View file

@ -173,6 +173,7 @@ import {
detectWorkflowKeyword,
buildWorkflowSteeringNotice,
} from './utils/workflow-keyword.js';
import { parseSlashCommand } from '../utils/commands.js';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { ExtensionRefreshState } from '../config/extension-refresh-state.js';
@ -2343,6 +2344,15 @@ export const AppContainer = (props: AppContainerProps) => {
addMessage(submittedValue, true, submittedPrompt);
return;
}
if (
streamingState === StreamingState.Responding &&
isSlashCommand(userPromptText) &&
parseSlashCommand(userPromptText, slashCommands).commandToExecute
?.canRunDuringStreaming
) {
void handleSlashCommand(userPromptText);
return;
}
if (
streamingState === StreamingState.Responding &&
isBtwCommand(submittedValue)
@ -2489,6 +2499,7 @@ export const AppContainer = (props: AppContainerProps) => {
isProcessing,
submitUserQuery,
handleSlashCommand,
slashCommands,
config,
geminiClient,
historyManager,

View file

@ -75,6 +75,8 @@ describe('aboutCommand', () => {
expect(aboutCommand.name).toBe('status');
expect(aboutCommand.altNames).toEqual(['about']);
expect(aboutCommand.description).toBe('show version info');
expect(aboutCommand.canRunDuringStreaming).toBe(true);
expect(aboutCommand.subCommands?.[0]?.canRunDuringStreaming).toBe(true);
});
it('should call addItem with all version info', async () => {

View file

@ -22,6 +22,7 @@ export const aboutCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
canRunDuringStreaming: true,
action: async (context) => {
const systemInfo = await getExtendedSystemInfo(context);
@ -63,6 +64,7 @@ export const aboutCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
canRunDuringStreaming: true,
action: async (context) => {
const info = await collectSessionPathInfo(context);
const content = formatSessionPathInfo(info);

View file

@ -55,5 +55,6 @@ describe('helpCommand', () => {
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
expect(helpCommand.argumentHint).toBeUndefined();
expect(helpCommand.description).toBe('for help on Qwen Code');
expect(helpCommand.canRunDuringStreaming).toBe(true);
});
});

View file

@ -13,6 +13,7 @@ export const helpCommand: SlashCommand = {
altNames: ['?'],
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
canRunDuringStreaming: true,
get description() {
return t('for help on Qwen Code');
},

View file

@ -32,5 +32,6 @@ describe('settingsCommand', () => {
expect(settingsCommand.description).toBe(
'View and edit Qwen Code settings',
);
expect(settingsCommand.canRunDuringStreaming).toBe(true);
});
});

View file

@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
canRunDuringStreaming: true,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'settings',

View file

@ -366,6 +366,13 @@ export interface SlashCommand {
*/
supportedModes?: ExecutionMode[];
/**
* Whether the interactive UI may execute this command immediately while a
* model response is streaming. Commands opt in only when they do not submit
* a model turn or mutate conversation state owned by the active turn.
*/
canRunDuringStreaming?: boolean;
// ── Phase 1: visibility ────────────────────────────────────────────────
/**
* Whether users can invoke this command via a slash command.