qwen-code/packages/cli/src/ui/hooks/useMessageQueue.test.ts
Shaojin Wen 61ad9db9c1
feat(cli): queue input editing — pop queued messages for editing via ↑/ESC (#2871)
* feat(cli): add queue input editing via Up arrow key

Allow users to edit queued messages by pressing the Up arrow key when
the cursor is at the top of the input. All queued messages are popped
into the input field for revision before resubmission, reducing wasted
turns from incorrect queued instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing mocks for InputPrompt tests and attachment mode guard

- Add popAllQueuedMessages mock and messageQueue to UIState/UIActions
  mocks in InputPrompt.test.tsx to fix 25 test failures
- Add !isAttachmentMode guard to prevent queue pop from conflicting
  with attachment navigation
- Add single-message popAllMessages test case

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Copilot review - restrict to Up arrow, add tests, update docs

- Only trigger queue pop on NAVIGATION_UP (arrow key), not HISTORY_UP
  (Ctrl+P), preserving existing Ctrl+P history navigation behavior
- Update AsyncMessageQueue class docs to describe popLast() LIFO semantics
- Add InputPrompt tests: Up arrow pops queue, Up arrow falls back to
  history when queue empty, Ctrl+P not intercepted by queue pop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update fileoverview docs and make popAllMessages atomic via ref

- Update @fileoverview to describe FIFO+LIFO capability instead of
  "Simple FIFO queue"
- Use queueRef to make popAllMessages atomic, preventing duplicate
  pops from key auto-repeat before React re-renders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sync queueRef in addMessage/clearQueue and fall through on null pop

- Update queueRef inside addMessage setter and clearQueue to keep ref
  in sync between renders, preventing stale reads after clearQueue
- When popAllQueuedMessages returns null (queue already cleared), fall
  through to normal history navigation instead of consuming the key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove dead popLast() and align popAllMessages separator to \n\n

- Remove unused AsyncMessageQueue.popLast() (no production callers)
- Change popAllMessages join separator from \n to \n\n for consistency
  with getQueuedMessagesText and auto-submit behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use hook's drainQueue for mid-turn drain to prevent double-consumption race

The midTurnDrainRef previously used a separate messageQueueRef (synced
from React state), while popAllMessages uses the hook's internal
queueRef. If a tool completed between popAllMessages clearing queueRef
and React re-rendering, midTurnDrainRef would read stale data and
consume the same messages a second time.

Switching to the hook's drainQueue makes both paths read from the same
synchronous ref, eliminating the window for double consumption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing popAllMessages mock and prepend branch test

Add popAllMessages to useMessageQueue mock in AppContainer tests.
Add test for prepending queued messages before existing input text.

* feat: add ESC trigger, cursor preservation, and progressive hint

- ESC pops queued messages before double-ESC clear logic
- Cursor stays at user's editing position after pop via moveToOffset
- Extract popQueueIntoInput helper to share logic between Up and ESC
- QueuedMessageDisplay hint hides after 3 empty→non-empty transitions

* test: add null-pop fallthrough test for queue race condition

Verify that when React state shows non-empty queue but the ref is
already drained (popAllQueuedMessages returns null), Up arrow falls
through to normal history navigation instead of getting stuck.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:38:32 +08:00

299 lines
7.9 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('should initialize with empty queue', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
}),
);
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,
}),
);
act(() => {
result.current.addMessage('Test message 1');
result.current.addMessage('Test message 2');
});
expect(result.current.messageQueue).toEqual([
'Test message 1',
'Test message 2',
]);
});
it('should filter out empty messages', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
act(() => {
result.current.addMessage('Valid message');
result.current.addMessage(' '); // Only whitespace
result.current.addMessage(''); // Empty
result.current.addMessage('Another valid message');
});
expect(result.current.messageQueue).toEqual([
'Valid message',
'Another valid message',
]);
});
it('should clear queue', () => {
const { result } = renderHook(() =>
useMessageQueue({
isConfigInitialized: true,
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
}),
);
act(() => {
result.current.addMessage('Test message');
});
expect(result.current.messageQueue).toEqual(['Test message']);
act(() => {
result.current.clearQueue();
});
expect(result.current.messageQueue).toEqual([]);
});
it('should return queued messages as text with double newlines', () => {
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');
});
expect(result.current.getQueuedMessagesText()).toBe(
'Message 1\n\nMessage 2\n\nMessage 3',
);
});
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 },
},
);
// Add some messages
act(() => {
result.current.addMessage('Message 1');
result.current.addMessage('Message 2');
});
expect(result.current.messageQueue).toEqual(['Message 1', 'Message 2']);
// Transition to Idle
rerender({ streamingState: StreamingState.Idle });
expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1\n\nMessage 2');
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 },
},
);
// Transition to Idle with empty queue
rerender({ streamingState: StreamingState.Idle });
expect(mockSubmitQuery).not.toHaveBeenCalled();
});
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 },
},
);
// Add messages
act(() => {
result.current.addMessage('Message 1');
});
// Transition to WaitingForConfirmation (not Idle)
rerender({ streamingState: StreamingState.WaitingForConfirmation });
expect(mockSubmitQuery).not.toHaveBeenCalled();
expect(result.current.messageQueue).toEqual(['Message 1']);
});
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');
});
// Go back to idle - should submit
rerender({ streamingState: StreamingState.Idle });
expect(mockSubmitQuery).toHaveBeenCalledWith('First batch');
expect(result.current.messageQueue).toEqual([]);
// Start responding again
rerender({ streamingState: StreamingState.Responding });
// Add more messages
act(() => {
result.current.addMessage('Second batch');
});
// 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([]);
});
});