mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
fix(cli): dispatch queued slash commands through the slash path (#3523)
* fix(cli): dispatch queued slash commands through the slash path When the agent was responding and the user queued a message, the drain path joined all queued messages with `\n\n` and submitted them as one prompt. Any slash command in that blob (e.g. `/model`) no longer started with `/`, so it was sent to the model as plain text instead of opening the command's dialog. The mid-turn tool-result drain had the same problem: it drained the entire queue into the tool-result payload, so a slash command queued during tool execution was injected as context for the model rather than executed as a command. Queue draining now splits into segments — consecutive plain-text messages are still batched into one submission, while slash commands are submitted alone so their `/` prefix survives. The mid-turn drain only takes leading plain-text messages and leaves slash commands queued for the normal idle drain. The idle drain is gated on open dialogs so a queued `/model` does not cause the following queued prompt to be sent to the model while the picker is still open, and a re-entry lock plus a nonce close the race between state commits and the async dialog-open. * fix(cli): defer queued slash commands until idle * fix(cli): drop queued messages on cancel instead of auto-submitting Cancel's contract is now "abort and redirect" in both cancel paths: restore the most recent queued segment into the buffer for editing and drop the rest, so forgotten follow-ups cannot auto-submit once the turn settles. Previously the non-tool path left queued plain-text segments in place for the idle drain to fire, and the tool-executing path cleared only the buffer — both surprised users with belated message dispatches after they had already cancelled. * refactor(cli): batch plain prompts in idle drain Idle drain now runs in two phases: drain all plain-text prompts into one turn (drainQueue), then pop slash commands one-by-one (popNextSegment). Mirrors the mid-turn behavior so queue handling is consistent across mid-turn and idle contexts. popAllMessages now drains the entire queue joined with \n\n for Ctrl+C cancel and ESC/Up edit-restore. Drop the unused options parameter from useMessageQueue and the extractFirstSegment helper. --------- Co-authored-by: 愚远 <zhenxing.tzx@alibaba-inc.com>
This commit is contained in:
parent
2aad7c0617
commit
c87d2798bd
4 changed files with 367 additions and 269 deletions
|
|
@ -28,6 +28,7 @@ import {
|
|||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { ToolCallStatus } from './types.js';
|
||||
import { useContext } from 'react';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
|
|
@ -245,6 +246,7 @@ describe('AppContainer State Management', () => {
|
|||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
popAllMessages: vi.fn().mockReturnValue(null),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextSegment: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
mockedUseAutoAcceptIndicator.mockReturnValue(false);
|
||||
mockedUseGitBranchName.mockReturnValue('main');
|
||||
|
|
@ -459,6 +461,7 @@ describe('AppContainer State Management', () => {
|
|||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
popAllMessages: vi.fn().mockReturnValue(null),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextSegment: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
render(
|
||||
|
|
@ -476,6 +479,44 @@ describe('AppContainer State Management', () => {
|
|||
expect(mockQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits slash commands immediately instead of queueing while idle', () => {
|
||||
const mockSubmitQuery = vi.fn();
|
||||
const mockQueueMessage = vi.fn();
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
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(''),
|
||||
popAllMessages: vi.fn().mockReturnValue(null),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextSegment: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
capturedUIActions.handleFinalSubmit('/model');
|
||||
|
||||
expect(mockSubmitQuery).toHaveBeenCalledWith('/model');
|
||||
expect(mockQueueMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['exit', 'quit', ':q', ':q!', ':wq', ':wq!'])(
|
||||
'routes bare "%s" to /quit instead of sending as a message',
|
||||
(command) => {
|
||||
|
|
@ -497,6 +538,7 @@ describe('AppContainer State Management', () => {
|
|||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
popAllMessages: vi.fn().mockReturnValue(null),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextSegment: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
render(
|
||||
|
|
@ -577,6 +619,7 @@ describe('AppContainer State Management', () => {
|
|||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
popAllMessages: vi.fn().mockReturnValue(null),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextSegment: vi.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
render(
|
||||
|
|
@ -605,6 +648,7 @@ describe('AppContainer State Management', () => {
|
|||
it('moves queued follow-up messages into an empty buffer on cancel', async () => {
|
||||
const mockSetText = vi.fn();
|
||||
const mockPopAllMessages = vi.fn().mockReturnValue('queued follow-up');
|
||||
const mockClearQueue = vi.fn();
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: mockSetText,
|
||||
|
|
@ -626,10 +670,11 @@ describe('AppContainer State Management', () => {
|
|||
mockedUseMessageQueue.mockReturnValue({
|
||||
messageQueue: ['queued follow-up'],
|
||||
addMessage: vi.fn(),
|
||||
clearQueue: vi.fn(),
|
||||
clearQueue: mockClearQueue,
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
|
||||
popAllMessages: mockPopAllMessages,
|
||||
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
|
||||
popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
|
||||
});
|
||||
|
||||
render(
|
||||
|
|
@ -653,6 +698,75 @@ describe('AppContainer State Management', () => {
|
|||
expect.stringContaining('the previous prompt'),
|
||||
);
|
||||
expect(mockPopAllMessages).toHaveBeenCalled();
|
||||
// popAllForEdit drains the queue internally, so the cancel handler
|
||||
// does not need to call clearQueue separately on this path.
|
||||
expect(mockClearQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the queue when cancelling during tool execution', async () => {
|
||||
// Simulates: user asks for a shell tool (e.g. sleep 30), queues
|
||||
// `/model` and `hi` while the tool is running, then hits Ctrl+C.
|
||||
// The cancel must clear BOTH the buffer and the queue so that
|
||||
// `hi` does not auto-fire once the tool settles and the app
|
||||
// returns to idle.
|
||||
const mockSetText = vi.fn();
|
||||
const mockClearQueue = vi.fn();
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: mockSetText,
|
||||
});
|
||||
installCancelCapture({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'run_shell_command',
|
||||
description: 'sleep 30',
|
||||
status: ToolCallStatus.Executing,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
renderOutputAsMarkdown: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
retryLastPrompt: vi.fn(),
|
||||
});
|
||||
mockedUseMessageQueue.mockReturnValue({
|
||||
messageQueue: ['/model', 'hi'],
|
||||
addMessage: vi.fn(),
|
||||
clearQueue: mockClearQueue,
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue('/model\n\nhi'),
|
||||
popAllMessages: vi.fn().mockReturnValue('/model'),
|
||||
drainQueue: vi.fn().mockReturnValue([]),
|
||||
popNextSegment: vi.fn().mockReturnValue('/model'),
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
triggerCancel();
|
||||
|
||||
// Buffer cleared and queue dropped — same "abort and redirect"
|
||||
// contract as the non-tool cancel path.
|
||||
expect(mockSetText).toHaveBeenCalledWith('');
|
||||
expect(mockClearQueue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an in-progress draft when restoring queued messages on cancel', async () => {
|
||||
|
|
@ -680,6 +794,7 @@ describe('AppContainer State Management', () => {
|
|||
getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
|
||||
popAllMessages: vi.fn().mockReturnValue('queued follow-up'),
|
||||
drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
|
||||
popNextSegment: vi.fn().mockReturnValue('queued follow-up'),
|
||||
});
|
||||
|
||||
render(
|
||||
|
|
|
|||
|
|
@ -88,7 +88,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 { isBtwCommand, isSlashCommand } 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';
|
||||
|
|
@ -882,16 +882,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
disabled: agentViewState.activeView !== 'main',
|
||||
});
|
||||
|
||||
const { messageQueue, addMessage, popAllMessages, drainQueue } =
|
||||
useMessageQueue({
|
||||
isConfigInitialized,
|
||||
streamingState,
|
||||
submitQuery,
|
||||
});
|
||||
const {
|
||||
messageQueue,
|
||||
addMessage,
|
||||
clearQueue,
|
||||
popAllMessages,
|
||||
drainQueue,
|
||||
popNextSegment,
|
||||
} = useMessageQueue();
|
||||
|
||||
// Bridge message queue to mid-turn drain via ref.
|
||||
// drainQueue reads the synchronous queueRef inside the hook, so it
|
||||
// stays consistent with popAllMessages even before React re-renders.
|
||||
// stays consistent with popNextSegment even before React re-renders.
|
||||
midTurnDrainRef.current = drainQueue;
|
||||
|
||||
// Connect remote input watcher to submitQuery for bidirectional sync.
|
||||
|
|
@ -1167,6 +1169,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
speculationRef.current = IDLE_SPECULATION;
|
||||
}
|
||||
|
||||
if (
|
||||
streamingState === StreamingState.Idle &&
|
||||
isSlashCommand(submittedValue)
|
||||
) {
|
||||
void submitQuery(submittedValue);
|
||||
return;
|
||||
}
|
||||
|
||||
addMessage(submittedValue);
|
||||
},
|
||||
[
|
||||
|
|
@ -1213,28 +1223,22 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
...pendingGeminiHistoryItems,
|
||||
];
|
||||
if (isToolExecuting(pendingHistoryItems)) {
|
||||
buffer.setText(''); // Just clear the prompt
|
||||
// Tool-cancel: drop both buffer and queue so nothing auto-fires later.
|
||||
buffer.setText('');
|
||||
clearQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Move any queued follow-up messages back into the buffer so the user
|
||||
// can edit or resubmit them. Otherwise leave the buffer alone — in
|
||||
// particular, do NOT repopulate it with the previous prompt; the user
|
||||
// can still recall it via history navigation (Up/Ctrl+P).
|
||||
//
|
||||
// popAllMessages is atomic via the queue's synchronous ref, matching
|
||||
// the drain behavior used during tool completion.
|
||||
// Restore queued input joined into the buffer for editing.
|
||||
const popped = popAllMessages();
|
||||
if (popped) {
|
||||
const currentText = buffer.text;
|
||||
// Preserve any in-progress draft the user typed since submitting (this
|
||||
// is reachable via Ctrl+C cancel, which fires regardless of buffer
|
||||
// content). Mirrors the popQueueIntoInput convention in InputPrompt.
|
||||
buffer.setText(currentText ? `${popped}\n${currentText}` : popped);
|
||||
}
|
||||
}, [
|
||||
buffer,
|
||||
popAllMessages,
|
||||
clearQueue,
|
||||
pendingSlashCommandHistoryItems,
|
||||
pendingGeminiHistoryItems,
|
||||
]);
|
||||
|
|
@ -2038,6 +2042,39 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isExtensionsManagerDialogOpen;
|
||||
dialogsVisibleRef.current = dialogsVisible;
|
||||
|
||||
// Drain queued messages when idle. `queueDrainNonce` re-fires the effect
|
||||
// after each submission settles so multi-step queues drain end-to-end.
|
||||
const queueDrainingRef = useRef(false);
|
||||
const [queueDrainNonce, setQueueDrainNonce] = useState(0);
|
||||
useEffect(() => {
|
||||
if (queueDrainingRef.current) return;
|
||||
if (!isConfigInitialized) return;
|
||||
if (streamingState !== StreamingState.Idle) return;
|
||||
if (dialogsVisible) return;
|
||||
if (messageQueue.length === 0) return;
|
||||
|
||||
// Two-phase: batch plain prompts as one turn, else pop next slash command.
|
||||
const plainPrompts = drainQueue();
|
||||
const submission =
|
||||
plainPrompts.length > 0 ? plainPrompts.join('\n\n') : popNextSegment();
|
||||
if (submission === null) return;
|
||||
|
||||
queueDrainingRef.current = true;
|
||||
Promise.resolve(submitQuery(submission)).finally(() => {
|
||||
queueDrainingRef.current = false;
|
||||
setQueueDrainNonce((n) => n + 1);
|
||||
});
|
||||
}, [
|
||||
isConfigInitialized,
|
||||
streamingState,
|
||||
dialogsVisible,
|
||||
messageQueue,
|
||||
drainQueue,
|
||||
popNextSegment,
|
||||
submitQuery,
|
||||
queueDrainNonce,
|
||||
]);
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
|
|
|
|||
|
|
@ -7,13 +7,9 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useMessageQueue } from './useMessageQueue.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
describe('useMessageQueue', () => {
|
||||
let mockSubmitQuery: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSubmitQuery = vi.fn();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
|
|
@ -23,26 +19,14 @@ describe('useMessageQueue', () => {
|
|||
});
|
||||
|
||||
it('should initialize with empty queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
expect(result.current.getQueuedMessagesText()).toBe('');
|
||||
});
|
||||
|
||||
it('should add messages to queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('Test message 1');
|
||||
|
|
@ -56,13 +40,7 @@ describe('useMessageQueue', () => {
|
|||
});
|
||||
|
||||
it('should filter out empty messages', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('Valid message');
|
||||
|
|
@ -78,13 +56,7 @@ describe('useMessageQueue', () => {
|
|||
});
|
||||
|
||||
it('should clear queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('Test message');
|
||||
|
|
@ -100,13 +72,7 @@ describe('useMessageQueue', () => {
|
|||
});
|
||||
|
||||
it('should return queued messages as text with double newlines', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('Message 1');
|
||||
|
|
@ -119,181 +85,193 @@ describe('useMessageQueue', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should auto-submit queued messages when transitioning to Idle', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
{
|
||||
initialProps: { streamingState: StreamingState.Responding },
|
||||
},
|
||||
);
|
||||
describe('popAllMessages (cancel and ESC/Up restore)', () => {
|
||||
it('returns null when the queue is empty', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
// Add some messages
|
||||
act(() => {
|
||||
result.current.addMessage('Message 1');
|
||||
result.current.addMessage('Message 2');
|
||||
let popped: string | null = null;
|
||||
act(() => {
|
||||
popped = result.current.popAllMessages();
|
||||
});
|
||||
|
||||
expect(popped).toBeNull();
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
|
||||
expect(result.current.messageQueue).toEqual(['Message 1', 'Message 2']);
|
||||
it('joins all queued messages with double newlines and clears the queue', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
// Transition to Idle
|
||||
rerender({ streamingState: StreamingState.Idle });
|
||||
act(() => {
|
||||
result.current.addMessage('Message 1');
|
||||
result.current.addMessage('Message 2');
|
||||
result.current.addMessage('Message 3');
|
||||
});
|
||||
|
||||
expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1\n\nMessage 2');
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
let popped: string | null = null;
|
||||
act(() => {
|
||||
popped = result.current.popAllMessages();
|
||||
});
|
||||
|
||||
expect(popped).toBe('Message 1\n\nMessage 2\n\nMessage 3');
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a single message without separator', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('Only message');
|
||||
});
|
||||
|
||||
let popped: string | null = null;
|
||||
act(() => {
|
||||
popped = result.current.popAllMessages();
|
||||
});
|
||||
|
||||
expect(popped).toBe('Only message');
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it('joins mixed slash commands and prompts in original order', () => {
|
||||
// Edit-restore intentionally collapses segment boundaries: the user is
|
||||
// recovering input into the buffer to edit before resubmitting, so
|
||||
// typing order matters more than slash-vs-prompt routing boundaries.
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('/model');
|
||||
result.current.addMessage('hello');
|
||||
result.current.addMessage('world');
|
||||
});
|
||||
|
||||
let popped: string | null = null;
|
||||
act(() => {
|
||||
popped = result.current.popAllMessages();
|
||||
});
|
||||
|
||||
expect(popped).toBe('/model\n\nhello\n\nworld');
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not auto-submit when queue is empty', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
{
|
||||
initialProps: { streamingState: StreamingState.Responding },
|
||||
},
|
||||
);
|
||||
describe('drainQueue (mid-turn drain for tool-result injection)', () => {
|
||||
it('returns an empty array when the queue is empty', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
// Transition to Idle with empty queue
|
||||
rerender({ streamingState: StreamingState.Idle });
|
||||
let drained: string[] = [];
|
||||
act(() => {
|
||||
drained = result.current.drainQueue();
|
||||
});
|
||||
expect(drained).toEqual([]);
|
||||
});
|
||||
|
||||
expect(mockSubmitQuery).not.toHaveBeenCalled();
|
||||
it('drains all plain-text messages and leaves slash commands queued', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('one');
|
||||
result.current.addMessage('two');
|
||||
result.current.addMessage('/model');
|
||||
result.current.addMessage('three');
|
||||
});
|
||||
|
||||
let drained: string[] = [];
|
||||
act(() => {
|
||||
drained = result.current.drainQueue();
|
||||
});
|
||||
|
||||
expect(drained).toEqual(['one', 'two', 'three']);
|
||||
expect(result.current.messageQueue).toEqual(['/model']);
|
||||
});
|
||||
|
||||
it('returns an empty array when the queue contains only slash commands', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('/model');
|
||||
result.current.addMessage('/help');
|
||||
});
|
||||
|
||||
let drained: string[] = [];
|
||||
act(() => {
|
||||
drained = result.current.drainQueue();
|
||||
});
|
||||
|
||||
expect(drained).toEqual([]);
|
||||
expect(result.current.messageQueue).toEqual(['/model', '/help']);
|
||||
});
|
||||
|
||||
it('drains the whole queue when it contains no slash commands', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('a');
|
||||
result.current.addMessage('b');
|
||||
result.current.addMessage('c');
|
||||
});
|
||||
|
||||
let drained: string[] = [];
|
||||
act(() => {
|
||||
drained = result.current.drainQueue();
|
||||
});
|
||||
|
||||
expect(drained).toEqual(['a', 'b', 'c']);
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not auto-submit when not transitioning to Idle', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
{
|
||||
initialProps: { streamingState: StreamingState.Responding },
|
||||
},
|
||||
);
|
||||
describe('popNextSegment', () => {
|
||||
it('returns null when the queue is empty', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
// Add messages
|
||||
act(() => {
|
||||
result.current.addMessage('Message 1');
|
||||
let segment: string | null = null;
|
||||
act(() => {
|
||||
segment = result.current.popNextSegment();
|
||||
});
|
||||
expect(segment).toBeNull();
|
||||
});
|
||||
|
||||
// Transition to WaitingForConfirmation (not Idle)
|
||||
rerender({ streamingState: StreamingState.WaitingForConfirmation });
|
||||
it('pops the first item and leaves the rest queued', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
expect(mockSubmitQuery).not.toHaveBeenCalled();
|
||||
expect(result.current.messageQueue).toEqual(['Message 1']);
|
||||
});
|
||||
act(() => {
|
||||
result.current.addMessage('/model');
|
||||
result.current.addMessage('/help');
|
||||
});
|
||||
|
||||
it('should handle multiple state transitions correctly', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
{
|
||||
initialProps: { streamingState: StreamingState.Idle },
|
||||
},
|
||||
);
|
||||
|
||||
// Start responding
|
||||
rerender({ streamingState: StreamingState.Responding });
|
||||
|
||||
// Add messages while responding
|
||||
act(() => {
|
||||
result.current.addMessage('First batch');
|
||||
let segment: string | null = null;
|
||||
act(() => {
|
||||
segment = result.current.popNextSegment();
|
||||
});
|
||||
expect(segment).toBe('/model');
|
||||
expect(result.current.messageQueue).toEqual(['/help']);
|
||||
});
|
||||
|
||||
// Go back to idle - should submit
|
||||
rerender({ streamingState: StreamingState.Idle });
|
||||
it('drains the queue one item at a time across repeated calls', () => {
|
||||
const { result } = renderHook(() => useMessageQueue());
|
||||
|
||||
expect(mockSubmitQuery).toHaveBeenCalledWith('First batch');
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
act(() => {
|
||||
result.current.addMessage('/model');
|
||||
result.current.addMessage('/theme');
|
||||
result.current.addMessage('/help');
|
||||
});
|
||||
|
||||
// Start responding again
|
||||
rerender({ streamingState: StreamingState.Responding });
|
||||
const segments: Array<string | null> = [];
|
||||
act(() => {
|
||||
segments.push(result.current.popNextSegment());
|
||||
});
|
||||
act(() => {
|
||||
segments.push(result.current.popNextSegment());
|
||||
});
|
||||
act(() => {
|
||||
segments.push(result.current.popNextSegment());
|
||||
});
|
||||
act(() => {
|
||||
segments.push(result.current.popNextSegment());
|
||||
});
|
||||
|
||||
// Add more messages
|
||||
act(() => {
|
||||
result.current.addMessage('Second batch');
|
||||
expect(segments).toEqual(['/model', '/theme', '/help', null]);
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
|
||||
// Go back to idle - should submit again
|
||||
rerender({ streamingState: StreamingState.Idle });
|
||||
|
||||
expect(mockSubmitQuery).toHaveBeenCalledWith('Second batch');
|
||||
expect(mockSubmitQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should pop all messages from queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('Message 1');
|
||||
result.current.addMessage('Message 2');
|
||||
result.current.addMessage('Message 3');
|
||||
});
|
||||
|
||||
let popped: string | null = null;
|
||||
act(() => {
|
||||
popped = result.current.popAllMessages();
|
||||
});
|
||||
|
||||
expect(popped).toBe('Message 1\n\nMessage 2\n\nMessage 3');
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pop single message without separator', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.addMessage('Only message');
|
||||
});
|
||||
|
||||
let popped: string | null = null;
|
||||
act(() => {
|
||||
popped = result.current.popAllMessages();
|
||||
});
|
||||
|
||||
expect(popped).toBe('Only message');
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return null when popping from empty queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
);
|
||||
|
||||
let popped: string | null = null;
|
||||
act(() => {
|
||||
popped = result.current.popAllMessages();
|
||||
});
|
||||
|
||||
expect(popped).toBeNull();
|
||||
expect(result.current.messageQueue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,45 +4,27 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
export interface UseMessageQueueOptions {
|
||||
isConfigInitialized: boolean;
|
||||
streamingState: StreamingState;
|
||||
submitQuery: (query: string) => void;
|
||||
}
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
|
||||
export interface UseMessageQueueReturn {
|
||||
messageQueue: string[];
|
||||
addMessage: (message: string) => void;
|
||||
clearQueue: () => void;
|
||||
getQueuedMessagesText: () => string;
|
||||
/** Drain the entire queue joined with `\n\n`. For Ctrl+C / ESC / Up edit-restore. */
|
||||
popAllMessages: () => string | null;
|
||||
/**
|
||||
* Atomically drain all queued messages. Returns the drained messages
|
||||
* and clears both the synchronous ref and React state. Safe to call
|
||||
* from non-React contexts (e.g., tool completion callbacks).
|
||||
*/
|
||||
/** Drain plain-text prompts; leave slash commands queued. Safe from non-React callbacks. */
|
||||
drainQueue: () => string[];
|
||||
/** Pop the first item from the queue. */
|
||||
popNextSegment: () => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing message queuing during streaming responses.
|
||||
* Allows users to queue messages while the AI is responding and automatically
|
||||
* sends them when streaming completes.
|
||||
*/
|
||||
export function useMessageQueue({
|
||||
isConfigInitialized,
|
||||
streamingState,
|
||||
submitQuery,
|
||||
}: UseMessageQueueOptions): UseMessageQueueReturn {
|
||||
export function useMessageQueue(): UseMessageQueueReturn {
|
||||
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
||||
// Synchronous ref mirrors React state so non-React callbacks (e.g.,
|
||||
// mid-turn drain in handleCompletedTools) always see the latest queue.
|
||||
// Synchronous mirror so non-React callbacks see the latest queue.
|
||||
const queueRef = useRef<string[]>([]);
|
||||
|
||||
// Add a message to the queue
|
||||
const addMessage = useCallback((message: string) => {
|
||||
const trimmedMessage = message.trim();
|
||||
if (trimmedMessage.length > 0) {
|
||||
|
|
@ -51,58 +33,43 @@ export function useMessageQueue({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Clear the entire queue
|
||||
const clearQueue = useCallback(() => {
|
||||
queueRef.current = [];
|
||||
setMessageQueue([]);
|
||||
}, []);
|
||||
|
||||
// Get all queued messages as a single text string
|
||||
const getQueuedMessagesText = useCallback(() => {
|
||||
if (messageQueue.length === 0) return '';
|
||||
return messageQueue.join('\n\n');
|
||||
}, [messageQueue]);
|
||||
|
||||
// Pop all messages from the queue for editing (atomic via ref to prevent
|
||||
// duplicate pops from key auto-repeat before React re-renders)
|
||||
const popAllMessages = useCallback((): string | null => {
|
||||
const current = queueRef.current;
|
||||
if (current.length === 0) return null;
|
||||
const allText = current.join('\n\n');
|
||||
queueRef.current = [];
|
||||
setMessageQueue([]);
|
||||
return allText;
|
||||
return current.join('\n\n');
|
||||
}, []);
|
||||
|
||||
// Atomically drain all queued messages (synchronous, safe from callbacks).
|
||||
const drainQueue = useCallback((): string[] => {
|
||||
const drained = queueRef.current;
|
||||
const current = queueRef.current;
|
||||
if (current.length === 0) return [];
|
||||
const drained = current.filter((message) => !isSlashCommand(message));
|
||||
if (drained.length === 0) return [];
|
||||
queueRef.current = [];
|
||||
setMessageQueue([]);
|
||||
const rest = current.filter((message) => isSlashCommand(message));
|
||||
queueRef.current = rest;
|
||||
setMessageQueue(rest);
|
||||
return drained;
|
||||
}, []);
|
||||
|
||||
// Process queued messages when streaming becomes idle
|
||||
useEffect(() => {
|
||||
if (
|
||||
isConfigInitialized &&
|
||||
streamingState === StreamingState.Idle &&
|
||||
messageQueue.length > 0
|
||||
) {
|
||||
// Combine all messages with double newlines for clarity
|
||||
const combinedMessage = messageQueue.join('\n\n');
|
||||
// Clear the queue and submit
|
||||
clearQueue();
|
||||
submitQuery(combinedMessage);
|
||||
}
|
||||
}, [
|
||||
isConfigInitialized,
|
||||
streamingState,
|
||||
messageQueue,
|
||||
submitQuery,
|
||||
clearQueue,
|
||||
]);
|
||||
const popNextSegment = useCallback((): string | null => {
|
||||
const current = queueRef.current;
|
||||
if (current.length === 0) return null;
|
||||
const [head, ...rest] = current;
|
||||
queueRef.current = rest;
|
||||
setMessageQueue(rest);
|
||||
return head;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messageQueue,
|
||||
|
|
@ -111,5 +78,6 @@ export function useMessageQueue({
|
|||
getQueuedMessagesText,
|
||||
popAllMessages,
|
||||
drainQueue,
|
||||
popNextSegment,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue