mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
feat(cli): run safe slash commands during streaming (#8130)
This commit is contained in:
parent
c82b6a5349
commit
29e9b352d4
10 changed files with 191 additions and 0 deletions
55
docs/design/nonblocking-slash-commands.md
Normal file
55
docs/design/nonblocking-slash-commands.md
Normal 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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,5 +32,6 @@ describe('settingsCommand', () => {
|
|||
expect(settingsCommand.description).toBe(
|
||||
'View and edit Qwen Code settings',
|
||||
);
|
||||
expect(settingsCommand.canRunDuringStreaming).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue