Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
* renders AgentComposer with `key={activeView}`, so switching tabs unmounts
* the composer; any queue held only in local component state is discarded
* and the message is never delivered.
*
* Also covers #10148 -- delivery must not depend on the keyed composer
* being mounted: queued follow-ups are flushed when the agent settles to
* idle even while the user is on another teammate tab, and the queue is
* dropped only at COMPLETED/CANCELLED (delivery is genuinely impossible
* there; a FAILED agent still processes queued follow-ups — core's
* enqueueMessage restarts the run loop — so those must be delivered).
*/

import { render } from 'ink-testing-library';
Expand Down Expand Up @@ -88,7 +95,7 @@ function makeFakeAgent(): AgentInteractive {
} as unknown as AgentInteractive;
}

describe('AgentComposer queued follow-ups (#10069)', () => {
describe('Agent View queued follow-ups (#10069, #10148)', () => {
let agentA: AgentInteractive;
let agentB: AgentInteractive;
const streamingByAgent = new Map<AgentInteractive, typeof IDLE>();
Expand Down Expand Up @@ -191,19 +198,74 @@ describe('AgentComposer queued follow-ups (#10069)', () => {
expect(agentA.enqueueMessage).toHaveBeenCalledTimes(1);
});

it('flushes when returning to a tab after the agent becomes idle', async () => {
it('delivers queued follow-ups while the user is on another tab (#10148)', async () => {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
streamingByAgent.set(agentA, BUSY);
const app = await renderWithView('agent-a');

submitCapture.current!('follow-up while away');
await switchTo(app, 'agent-a');
expect(app.lastFrame()).toContain('follow-up while away');

// The user keeps working on teammate B while A finishes its round.
await switchTo(app, 'agent-b');
streamingByAgent.set(agentA, IDLE);
await switchTo(app, 'agent-a');
await switchTo(app, 'agent-b');

// Delivery must not wait for the user to revisit A's tab: A's composer
// is unmounted here, only the provider persists (#10148).
expect(agentA.enqueueMessage).toHaveBeenCalledTimes(1);
expect(agentA.enqueueMessage).toHaveBeenCalledWith('follow-up while away');

// Revisiting A later must not re-deliver.
await switchTo(app, 'agent-a');
expect(agentA.enqueueMessage).toHaveBeenCalledTimes(1);
expect(app.lastFrame()).not.toContain('follow-up while away');
});

// Only COMPLETED/CANCELLED are terminal for delivery: a FAILED agent's
// enqueueMessage restarts the run loop (core agent-interactive.ts), so
// its queued follow-ups must be delivered, not dropped.
it.each([
{ status: AgentStatus.COMPLETED, delivered: false },
{ status: AgentStatus.CANCELLED, delivered: false },
{ status: AgentStatus.FAILED, delivered: true },
])(
'handles the queue when the agent reaches $status (#10148)',
async ({ status, delivered }) => {
streamingByAgent.set(agentA, BUSY);
const app = await renderWithView('agent-a');

submitCapture.current!('queued at terminal');
await switchTo(app, 'agent-a');
expect(app.lastFrame()).toContain('queued at terminal');

// The agent reaches the terminal status while the user is on B.
await switchTo(app, 'agent-b');
streamingByAgent.set(agentA, {
status,
streamingState: StreamingState.Idle,
isInputActive: false,
elapsedTime: 0,
lastPromptTokenCount: 0,
});
await switchTo(app, 'agent-b');

if (delivered) {
expect(agentA.enqueueMessage).toHaveBeenCalledTimes(1);
expect(agentA.enqueueMessage).toHaveBeenCalledWith(
'queued at terminal',
);
} else {
expect(agentA.enqueueMessage).not.toHaveBeenCalled();
}

// Either way the queue is cleared — no permanent "queued" display.
await switchTo(app, 'agent-a');
expect(app.lastFrame()).not.toContain('queued at terminal');
expect(agentA.enqueueMessage).toHaveBeenCalledTimes(delivered ? 1 : 0);
},
);

it('joins multiple queued follow-ups into one prompt after a tab switch', async () => {
streamingByAgent.set(agentA, BUSY);
const app = await renderWithView('agent-a');
Expand Down
39 changes: 7 additions & 32 deletions packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@
*/

import { Box, Text, useStdin } from 'ink';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import {
AgentStatus,
isTerminalStatus,
ApprovalMode,
APPROVAL_MODES,
} from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -71,7 +70,6 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
setAgentMessageQueue,
appendToAgentMessageQueue,
} = useAgentViewActions();
const agent = agents.get(agentId);
Expand Down Expand Up @@ -198,39 +196,16 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
[buffer, agentTabBarFocused, setAgentTabBarFocused],
);

// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
// ── Message queue display ──
//
// The queue lives in AgentViewContext (keyed by agentId), not in local
// state: the layout keys this component by the active view, so switching
// teammate tabs unmounts it and a local queue would be silently dropped
// before the flush below ever runs (#10069).
// Queued follow-ups live in AgentViewContext (keyed by agentId) and are
// delivered by the provider's always-mounted per-agent flusher, not here:
// the layout keys this component by the active view, so a flush effect in
// this component would only run while the agent's tab is focused (#10069,
// #10148).

const messageQueue = agentMessageQueues.get(agentId) ?? EMPTY_MESSAGE_QUEUE;

// When agent becomes idle (and not terminal), flush queued messages.
const flushedQueueRef = useRef<readonly string[] | null>(null);
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
messageQueue.length > 0 &&
status !== undefined &&
!isTerminalStatus(status)
) {
if (flushedQueueRef.current === messageQueue) return;
flushedQueueRef.current = messageQueue;
const combined = messageQueue.join('\n');
setAgentMessageQueue(agentId, []);
interactiveAgent?.enqueueMessage(combined);
}
}, [
streamingState,
messageQueue,
interactiveAgent,
status,
agentId,
setAgentMessageQueue,
]);

const handleSubmit = useCallback(
(text: string) => {
const trimmed = text.trim();
Expand Down
Loading
Loading