mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 20:50:34 +00:00
feat(cli): Add agent tab navigation and live tool output for in-process arena mode
Add AgentViewContext, AgentTabBar, and AgentChatView components for tab-based agent switching. Add useArenaInProcess hook bridging ArenaManager events to React state. Add agentHistoryAdapter converting AgentMessage[] to HistoryItem[]. Core support changes: - Replace stream buffers with ROUND_TEXT events (complete round text) - Add TOOL_OUTPUT_UPDATE events for live tool output streaming - Add pendingApprovals/liveOutputs/shellPids state to AgentInteractive - Fix missing ROUND_END emission for final text rounds Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
d4cfb18f79
commit
5d07c495f1
27 changed files with 2086 additions and 314 deletions
248
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal file
248
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview AgentChatView — displays a single in-process agent's conversation.
|
||||
*
|
||||
* Renders the agent's message history using HistoryItemDisplay — the same
|
||||
* component used by the main agent view. AgentMessage[] is converted to
|
||||
* HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types
|
||||
* are available without duplicating rendering logic.
|
||||
*
|
||||
* Layout:
|
||||
* - Static area: finalized messages (efficient Ink <Static>)
|
||||
* - Live area: tool groups still executing / awaiting confirmation
|
||||
* - Status line: spinner while the agent is running
|
||||
*
|
||||
* Model text output is shown only after each round completes (no live
|
||||
* streaming), which avoids per-chunk re-renders and keeps the display simple.
|
||||
*/
|
||||
|
||||
import { Box, Text, Static } from 'ink';
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
AgentStatus,
|
||||
AgentEventType,
|
||||
type AgentStatusChangeEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
useAgentViewState,
|
||||
useAgentViewActions,
|
||||
} from '../../contexts/AgentViewContext.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { HistoryItemDisplay } from '../HistoryItemDisplay.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────────
|
||||
|
||||
interface AgentChatViewProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export const AgentChatView = ({ agentId }: AgentChatViewProps) => {
|
||||
const { agents } = useAgentViewState();
|
||||
const { setAgentShellFocused } = useAgentViewActions();
|
||||
const uiState = useUIState();
|
||||
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
|
||||
uiState;
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const agent = agents.get(agentId);
|
||||
const contentWidth = terminalWidth - 4;
|
||||
|
||||
// Force re-render on message updates and status changes.
|
||||
// STREAM_TEXT is deliberately excluded — model text is shown only after
|
||||
// each round completes (via committed messages), avoiding per-chunk re-renders.
|
||||
const [, setRenderTick] = useState(0);
|
||||
const tickRef = useRef(0);
|
||||
const forceRender = useCallback(() => {
|
||||
tickRef.current += 1;
|
||||
setRenderTick(tickRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
|
||||
const emitter = agent.interactiveAgent.getEventEmitter();
|
||||
if (!emitter) return;
|
||||
|
||||
const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
|
||||
const onToolCall = () => forceRender();
|
||||
const onToolResult = () => forceRender();
|
||||
const onRoundEnd = () => forceRender();
|
||||
const onApproval = () => forceRender();
|
||||
const onOutputUpdate = () => forceRender();
|
||||
|
||||
emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange);
|
||||
emitter.on(AgentEventType.TOOL_CALL, onToolCall);
|
||||
emitter.on(AgentEventType.TOOL_RESULT, onToolResult);
|
||||
emitter.on(AgentEventType.ROUND_END, onRoundEnd);
|
||||
emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
|
||||
|
||||
return () => {
|
||||
emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange);
|
||||
emitter.off(AgentEventType.TOOL_CALL, onToolCall);
|
||||
emitter.off(AgentEventType.TOOL_RESULT, onToolResult);
|
||||
emitter.off(AgentEventType.ROUND_END, onRoundEnd);
|
||||
emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
|
||||
};
|
||||
}, [agent, forceRender]);
|
||||
|
||||
const interactiveAgent = agent?.interactiveAgent;
|
||||
const messages = interactiveAgent?.getMessages() ?? [];
|
||||
const pendingApprovals = interactiveAgent?.getPendingApprovals();
|
||||
const liveOutputs = interactiveAgent?.getLiveOutputs();
|
||||
const shellPids = interactiveAgent?.getShellPids();
|
||||
const status = interactiveAgent?.getStatus();
|
||||
const isRunning =
|
||||
status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
|
||||
|
||||
// Derive the active PTY PID: first shell PID among currently-executing tools.
|
||||
// Resets naturally to undefined when the tool finishes (shellPids cleared).
|
||||
const activePtyId =
|
||||
shellPids && shellPids.size > 0
|
||||
? shellPids.values().next().value
|
||||
: undefined;
|
||||
|
||||
// Track whether the user has toggled input focus into the embedded shell.
|
||||
// Mirrors the main agent's embeddedShellFocused in AppContainer.
|
||||
const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false);
|
||||
|
||||
// Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation
|
||||
// when an agent's embedded shell is focused.
|
||||
useEffect(() => {
|
||||
setAgentShellFocused(embeddedShellFocused);
|
||||
return () => setAgentShellFocused(false);
|
||||
}, [embeddedShellFocused, setAgentShellFocused]);
|
||||
|
||||
// Reset focus when the shell exits (activePtyId disappears).
|
||||
useEffect(() => {
|
||||
if (!activePtyId) setEmbeddedShellFocusedLocal(false);
|
||||
}, [activePtyId]);
|
||||
|
||||
// Ctrl+F: toggle shell input focus when a PTY is active.
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.ctrl && key.name === 'f') {
|
||||
if (activePtyId || embeddedShellFocused) {
|
||||
setEmbeddedShellFocusedLocal((prev) => !prev);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Convert AgentMessage[] → HistoryItem[] via adapter.
|
||||
// tickRef.current in deps ensures we rebuild when events fire even if
|
||||
// messages.length and pendingApprovals.size haven't changed (e.g. a
|
||||
// tool result updates an existing entry in place).
|
||||
const allItems = useMemo(
|
||||
() =>
|
||||
agentMessagesToHistoryItems(
|
||||
messages,
|
||||
pendingApprovals ?? new Map(),
|
||||
liveOutputs,
|
||||
shellPids,
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
messages.length,
|
||||
pendingApprovals?.size,
|
||||
liveOutputs?.size,
|
||||
shellPids?.size,
|
||||
tickRef.current,
|
||||
],
|
||||
);
|
||||
|
||||
// Split into committed (Static) and pending (live area).
|
||||
// Any tool_group with an Executing or Confirming tool — plus everything
|
||||
// after it — stays in the live area so confirmation dialogs remain
|
||||
// interactive (Ink's <Static> cannot receive input).
|
||||
const splitIndex = useMemo(() => {
|
||||
for (let idx = allItems.length - 1; idx >= 0; idx--) {
|
||||
const item = allItems[idx]!;
|
||||
if (
|
||||
item.type === 'tool_group' &&
|
||||
item.tools.some(
|
||||
(t) =>
|
||||
t.status === ToolCallStatus.Executing ||
|
||||
t.status === ToolCallStatus.Confirming,
|
||||
)
|
||||
) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return allItems.length; // all committed
|
||||
}, [allItems]);
|
||||
|
||||
const committedItems = allItems.slice(0, splitIndex);
|
||||
const pendingItems = allItems.slice(splitIndex);
|
||||
|
||||
if (!agent || !interactiveAgent) {
|
||||
return (
|
||||
<Box marginX={2}>
|
||||
<Text color={theme.status.error}>
|
||||
Agent "{agentId}" not found.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Committed message history.
|
||||
key includes historyRemountKey: when refreshStatic() clears the
|
||||
terminal it bumps the key, forcing Static to remount and re-emit
|
||||
all items on the cleared screen. */}
|
||||
<Static
|
||||
key={`agent-${agentId}-${historyRemountKey}`}
|
||||
items={committedItems.map((item) => (
|
||||
<HistoryItemDisplay
|
||||
key={item.id}
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={contentWidth}
|
||||
/>
|
||||
))}
|
||||
>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
|
||||
{/* Live area — tool groups awaiting confirmation or still executing.
|
||||
Must remain outside Static so confirmation dialogs are interactive.
|
||||
Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */}
|
||||
{pendingItems.map((item) => (
|
||||
<HistoryItemDisplay
|
||||
key={item.id}
|
||||
item={item}
|
||||
isPending={true}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={contentWidth}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
isFocused={true}
|
||||
activeShellPtyId={activePtyId ?? null}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Spinner */}
|
||||
{isRunning && (
|
||||
<Box marginX={2} marginTop={1}>
|
||||
<GeminiRespondingSpinner />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
137
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal file
137
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview AgentTabBar — horizontal tab strip for in-process agent views.
|
||||
*
|
||||
* Rendered at the top of the terminal whenever in-process agents are registered.
|
||||
* Left/Right arrow keys cycle through tabs when the input buffer is empty.
|
||||
*
|
||||
* Tab indicators: running, idle/completed, failed, cancelled
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
useAgentViewState,
|
||||
useAgentViewActions,
|
||||
type RegisteredAgent,
|
||||
} from '../../contexts/AgentViewContext.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
// ─── Status Indicators ──────────────────────────────────────
|
||||
|
||||
function statusIndicator(agent: RegisteredAgent): {
|
||||
symbol: string;
|
||||
color: string;
|
||||
} {
|
||||
const status = agent.interactiveAgent.getStatus();
|
||||
switch (status) {
|
||||
case AgentStatus.RUNNING:
|
||||
case AgentStatus.INITIALIZING:
|
||||
return { symbol: '\u25CF', color: theme.status.warning }; // ● running
|
||||
case AgentStatus.COMPLETED:
|
||||
return { symbol: '\u2713', color: theme.status.success }; // ✓ completed
|
||||
case AgentStatus.FAILED:
|
||||
return { symbol: '\u2717', color: theme.status.error }; // ✗ failed
|
||||
case AgentStatus.CANCELLED:
|
||||
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled
|
||||
default:
|
||||
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────
|
||||
|
||||
export const AgentTabBar: React.FC = () => {
|
||||
const { activeView, agents, agentShellFocused } = useAgentViewState();
|
||||
const { switchToNext, switchToPrevious } = useAgentViewActions();
|
||||
const { buffer, embeddedShellFocused } = useUIState();
|
||||
|
||||
// Left/Right arrow keys switch tabs when the input buffer is empty
|
||||
// and no embedded shell (main or agent tab) has input focus.
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (buffer.text !== '' || embeddedShellFocused || agentShellFocused)
|
||||
return;
|
||||
if (key.name === 'left') {
|
||||
switchToPrevious();
|
||||
} else if (key.name === 'right') {
|
||||
switchToNext();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Subscribe to STATUS_CHANGE events from all agents so the tab bar
|
||||
// re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED).
|
||||
// Without this, status indicators would be stale until the next unrelated render.
|
||||
const [, setTick] = useState(0);
|
||||
const forceRender = useCallback(() => setTick((t) => t + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
for (const [, agent] of agents) {
|
||||
const emitter = agent.interactiveAgent.getEventEmitter();
|
||||
if (emitter) {
|
||||
emitter.on(AgentEventType.STATUS_CHANGE, forceRender);
|
||||
cleanups.push(() =>
|
||||
emitter.off(AgentEventType.STATUS_CHANGE, forceRender),
|
||||
);
|
||||
}
|
||||
}
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, [agents, forceRender]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" paddingX={1}>
|
||||
{/* Main tab */}
|
||||
<Box marginRight={1}>
|
||||
<Text
|
||||
bold={activeView === 'main'}
|
||||
backgroundColor={
|
||||
activeView === 'main' ? theme.border.default : undefined
|
||||
}
|
||||
color={
|
||||
activeView === 'main' ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{' Main '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<Text color={theme.border.default}>{'\u2502'}</Text>
|
||||
|
||||
{/* Agent tabs */}
|
||||
{[...agents.entries()].map(([agentId, agent]) => {
|
||||
const isActive = activeView === agentId;
|
||||
const { symbol, color: indicatorColor } = statusIndicator(agent);
|
||||
|
||||
return (
|
||||
<Box key={agentId} marginLeft={1}>
|
||||
<Text
|
||||
bold={isActive}
|
||||
backgroundColor={isActive ? theme.border.default : undefined}
|
||||
color={isActive ? undefined : agent.color || theme.text.secondary}
|
||||
>
|
||||
{` ${agent.displayName} `}
|
||||
</Text>
|
||||
<Text color={indicatorColor}>{` ${symbol}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Navigation hint */}
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>←/→</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,528 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||
import type {
|
||||
AgentMessage,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
|
||||
function msg(
|
||||
role: AgentMessage['role'],
|
||||
content: string,
|
||||
extra?: Partial<AgentMessage>,
|
||||
): AgentMessage {
|
||||
return { role, content, timestamp: 0, ...extra };
|
||||
}
|
||||
|
||||
const noApprovals = new Map<string, ToolCallConfirmationDetails>();
|
||||
|
||||
function toolCallMsg(
|
||||
callId: string,
|
||||
toolName: string,
|
||||
opts?: { description?: string; renderOutputAsMarkdown?: boolean },
|
||||
): AgentMessage {
|
||||
return msg('tool_call', `Tool call: ${toolName}`, {
|
||||
metadata: {
|
||||
callId,
|
||||
toolName,
|
||||
description: opts?.description ?? '',
|
||||
renderOutputAsMarkdown: opts?.renderOutputAsMarkdown,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toolResultMsg(
|
||||
callId: string,
|
||||
toolName: string,
|
||||
opts?: {
|
||||
success?: boolean;
|
||||
resultDisplay?: string;
|
||||
outputFile?: string;
|
||||
},
|
||||
): AgentMessage {
|
||||
return msg('tool_result', `Tool ${toolName}`, {
|
||||
metadata: {
|
||||
callId,
|
||||
toolName,
|
||||
success: opts?.success ?? true,
|
||||
resultDisplay: opts?.resultDisplay,
|
||||
outputFile: opts?.outputFile,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Role mapping ────────────────────────────────────────────
|
||||
|
||||
describe('agentMessagesToHistoryItems — role mapping', () => {
|
||||
it('maps user message', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[msg('user', 'hello')],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ type: 'user', text: 'hello' });
|
||||
});
|
||||
|
||||
it('maps plain assistant message', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[msg('assistant', 'response')],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' });
|
||||
});
|
||||
|
||||
it('maps thought assistant message', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[msg('assistant', 'thinking...', { thought: true })],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items[0]).toMatchObject({
|
||||
type: 'gemini_thought',
|
||||
text: 'thinking...',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps assistant message with error metadata', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[msg('assistant', 'oops', { metadata: { error: true } })],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items[0]).toMatchObject({ type: 'error', text: 'oops' });
|
||||
});
|
||||
|
||||
it('maps info message with no level → type info', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[msg('info', 'note')],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items[0]).toMatchObject({ type: 'info', text: 'note' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['warning', 'warning'],
|
||||
['success', 'success'],
|
||||
['error', 'error'],
|
||||
] as const)('maps info message with level=%s', (level, expectedType) => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[msg('info', 'text', { metadata: { level } })],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items[0]).toMatchObject({ type: expectedType });
|
||||
});
|
||||
|
||||
it('maps unknown info level → type info', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[msg('info', 'x', { metadata: { level: 'verbose' } })],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items[0]).toMatchObject({ type: 'info' });
|
||||
});
|
||||
|
||||
it('skips unknown roles without crashing', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
msg('user', 'before'),
|
||||
// force an unknown role
|
||||
{ role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 },
|
||||
msg('user', 'after'),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ type: 'user', text: 'before' });
|
||||
expect(items[1]).toMatchObject({ type: 'user', text: 'after' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool grouping ───────────────────────────────────────────
|
||||
|
||||
describe('agentMessagesToHistoryItems — tool grouping', () => {
|
||||
it('merges a tool_call + tool_result pair into one tool_group', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]!.type).toBe('tool_group');
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools).toHaveLength(1);
|
||||
expect(group.tools[0]!.name).toBe('read_file');
|
||||
});
|
||||
|
||||
it('merges multiple parallel tool calls into one tool_group', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'read_file'),
|
||||
toolCallMsg('c2', 'write_file'),
|
||||
toolResultMsg('c1', 'read_file'),
|
||||
toolResultMsg('c2', 'write_file'),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools).toHaveLength(2);
|
||||
expect(group.tools[0]!.name).toBe('read_file');
|
||||
expect(group.tools[1]!.name).toBe('write_file');
|
||||
});
|
||||
|
||||
it('preserves tool call order by first appearance', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c2', 'second'),
|
||||
toolCallMsg('c1', 'first'),
|
||||
toolResultMsg('c1', 'first'),
|
||||
toolResultMsg('c2', 'second'),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.name).toBe('second');
|
||||
expect(group.tools[1]!.name).toBe('first');
|
||||
});
|
||||
|
||||
it('breaks tool groups at non-tool messages', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'tool_a'),
|
||||
toolResultMsg('c1', 'tool_a'),
|
||||
msg('assistant', 'between'),
|
||||
toolCallMsg('c2', 'tool_b'),
|
||||
toolResultMsg('c2', 'tool_b'),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]!.type).toBe('tool_group');
|
||||
expect(items[1]!.type).toBe('gemini');
|
||||
expect(items[2]!.type).toBe('tool_group');
|
||||
});
|
||||
|
||||
it('handles tool_result arriving without a prior tool_call gracefully', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolResultMsg('c1', 'orphan', {
|
||||
success: true,
|
||||
resultDisplay: 'output',
|
||||
}),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.callId).toBe('c1');
|
||||
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool status ─────────────────────────────────────────────
|
||||
|
||||
describe('agentMessagesToHistoryItems — tool status', () => {
|
||||
it('Executing: tool_call with no result yet', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'shell')],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing);
|
||||
});
|
||||
|
||||
it('Success: tool_result with success=true', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'read'),
|
||||
toolResultMsg('c1', 'read', { success: true }),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
|
||||
});
|
||||
|
||||
it('Error: tool_result with success=false', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'write'),
|
||||
toolResultMsg('c1', 'write', { success: false }),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.status).toBe(ToolCallStatus.Error);
|
||||
});
|
||||
|
||||
it('Confirming: tool_call present in pendingApprovals', () => {
|
||||
const fakeApproval = {} as ToolCallConfirmationDetails;
|
||||
const approvals = new Map([['c1', fakeApproval]]);
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'shell')],
|
||||
approvals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
|
||||
expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval);
|
||||
});
|
||||
|
||||
it('Confirming takes priority over Executing', () => {
|
||||
// pending approval AND no result yet → Confirming, not Executing
|
||||
const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]);
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'shell')],
|
||||
approvals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool metadata ───────────────────────────────────────────
|
||||
|
||||
describe('agentMessagesToHistoryItems — tool metadata', () => {
|
||||
it('forwards resultDisplay from tool_result', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'read'),
|
||||
toolResultMsg('c1', 'read', {
|
||||
success: true,
|
||||
resultDisplay: 'file contents',
|
||||
}),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.resultDisplay).toBe('file contents');
|
||||
});
|
||||
|
||||
it('forwards outputFile from tool_result', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'shell'),
|
||||
toolResultMsg('c1', 'shell', {
|
||||
success: true,
|
||||
outputFile: '/tmp/output.txt',
|
||||
}),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.outputFile).toBe('/tmp/output.txt');
|
||||
});
|
||||
|
||||
it('forwards renderOutputAsMarkdown from tool_call', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }),
|
||||
toolResultMsg('c1', 'web_fetch', { success: true }),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards description from tool_call', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.description).toBe('reading src/index.ts');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── liveOutputs overlay ─────────────────────────────────────
|
||||
|
||||
describe('agentMessagesToHistoryItems — liveOutputs', () => {
|
||||
it('uses liveOutput as resultDisplay for Executing tools', () => {
|
||||
const liveOutputs = new Map([['c1', 'live stdout so far']]);
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'shell')],
|
||||
noApprovals,
|
||||
liveOutputs,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.resultDisplay).toBe('live stdout so far');
|
||||
});
|
||||
|
||||
it('ignores liveOutput for completed tools', () => {
|
||||
const liveOutputs = new Map([['c1', 'stale live output']]);
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'shell'),
|
||||
toolResultMsg('c1', 'shell', {
|
||||
success: true,
|
||||
resultDisplay: 'final output',
|
||||
}),
|
||||
],
|
||||
noApprovals,
|
||||
liveOutputs,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.resultDisplay).toBe('final output');
|
||||
});
|
||||
|
||||
it('falls back to entry resultDisplay when no liveOutput for callId', () => {
|
||||
const liveOutputs = new Map([['other-id', 'unrelated']]);
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'shell')],
|
||||
noApprovals,
|
||||
liveOutputs,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.resultDisplay).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shellPids overlay ───────────────────────────────────────
|
||||
|
||||
describe('agentMessagesToHistoryItems — shellPids', () => {
|
||||
it('sets ptyId for Executing tools with a known PID', () => {
|
||||
const shellPids = new Map([['c1', 12345]]);
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'shell')],
|
||||
noApprovals,
|
||||
undefined,
|
||||
shellPids,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.ptyId).toBe(12345);
|
||||
});
|
||||
|
||||
it('does not set ptyId for completed tools', () => {
|
||||
const shellPids = new Map([['c1', 12345]]);
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
toolCallMsg('c1', 'shell'),
|
||||
toolResultMsg('c1', 'shell', { success: true }),
|
||||
],
|
||||
noApprovals,
|
||||
undefined,
|
||||
shellPids,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.ptyId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set ptyId when shellPids is not provided', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[toolCallMsg('c1', 'shell')],
|
||||
noApprovals,
|
||||
);
|
||||
const group = items[0] as Extract<
|
||||
(typeof items)[0],
|
||||
{ type: 'tool_group' }
|
||||
>;
|
||||
expect(group.tools[0]!.ptyId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ID stability ────────────────────────────────────────────
|
||||
|
||||
describe('agentMessagesToHistoryItems — ID stability', () => {
|
||||
it('assigns monotonically increasing IDs', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
msg('user', 'u1'),
|
||||
msg('assistant', 'a1'),
|
||||
msg('info', 'i1'),
|
||||
toolCallMsg('c1', 'tool'),
|
||||
toolResultMsg('c1', 'tool'),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
const ids = items.map((i) => i.id);
|
||||
expect(ids).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('tool_group consumes one ID regardless of how many calls it contains', () => {
|
||||
const items = agentMessagesToHistoryItems(
|
||||
[
|
||||
msg('user', 'go'),
|
||||
toolCallMsg('c1', 'tool_a'),
|
||||
toolCallMsg('c2', 'tool_b'),
|
||||
toolResultMsg('c1', 'tool_a'),
|
||||
toolResultMsg('c2', 'tool_b'),
|
||||
msg('assistant', 'done'),
|
||||
],
|
||||
noApprovals,
|
||||
);
|
||||
// user=0, tool_group=1, assistant=2
|
||||
expect(items.map((i) => i.id)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('IDs from a prefix of messages are stable when more messages are appended', () => {
|
||||
const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')];
|
||||
|
||||
const before = agentMessagesToHistoryItems(base, noApprovals);
|
||||
const after = agentMessagesToHistoryItems(
|
||||
[...base, msg('info', 'i')],
|
||||
noApprovals,
|
||||
);
|
||||
|
||||
expect(after[0]!.id).toBe(before[0]!.id);
|
||||
expect(after[1]!.id).toBe(before[1]!.id);
|
||||
expect(after[2]!.id).toBe(2);
|
||||
});
|
||||
});
|
||||
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal file
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview agentHistoryAdapter — converts AgentMessage[] to HistoryItem[].
|
||||
*
|
||||
* This adapter bridges the sub-agent data model (AgentMessage[] from
|
||||
* AgentInteractive) to the shared rendering model (HistoryItem[] consumed by
|
||||
* HistoryItemDisplay). It lives in the CLI package so that packages/core types
|
||||
* are never coupled to CLI rendering types.
|
||||
*
|
||||
* ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[]
|
||||
* only ever grows. Index-based IDs are therefore stable — Ink's <Static>
|
||||
* requires items never shift or be removed, which this guarantees.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentMessage,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Convert AgentMessage[] + pendingApprovals into HistoryItem[].
|
||||
*
|
||||
* Consecutive tool_call / tool_result messages are merged into a single
|
||||
* tool_group HistoryItem. pendingApprovals overlays confirmation state so
|
||||
* ToolGroupMessage can render confirmation dialogs.
|
||||
*
|
||||
* liveOutputs (optional) provides real-time display data for executing tools.
|
||||
* shellPids (optional) provides PTY PIDs for interactive shell tools so
|
||||
* HistoryItemDisplay can render ShellInputPrompt on the active shell.
|
||||
*/
|
||||
export function agentMessagesToHistoryItems(
|
||||
messages: readonly AgentMessage[],
|
||||
pendingApprovals: ReadonlyMap<string, ToolCallConfirmationDetails>,
|
||||
liveOutputs?: ReadonlyMap<string, ToolResultDisplay>,
|
||||
shellPids?: ReadonlyMap<string, number>,
|
||||
): HistoryItem[] {
|
||||
const items: HistoryItem[] = [];
|
||||
let nextId = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < messages.length) {
|
||||
const msg = messages[i]!;
|
||||
|
||||
// ── user ──────────────────────────────────────────────────
|
||||
if (msg.role === 'user') {
|
||||
items.push({ type: 'user', text: msg.content, id: nextId++ });
|
||||
i++;
|
||||
|
||||
// ── assistant ─────────────────────────────────────────────
|
||||
} else if (msg.role === 'assistant') {
|
||||
if (msg.metadata?.['error']) {
|
||||
items.push({ type: 'error', text: msg.content, id: nextId++ });
|
||||
} else if (msg.thought) {
|
||||
items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ });
|
||||
} else {
|
||||
items.push({ type: 'gemini', text: msg.content, id: nextId++ });
|
||||
}
|
||||
i++;
|
||||
|
||||
// ── info / warning / success / error ──────────────────────
|
||||
} else if (msg.role === 'info') {
|
||||
const level = msg.metadata?.['level'] as string | undefined;
|
||||
const type =
|
||||
level === 'warning' || level === 'success' || level === 'error'
|
||||
? level
|
||||
: 'info';
|
||||
items.push({ type, text: msg.content, id: nextId++ });
|
||||
i++;
|
||||
|
||||
// ── tool_call / tool_result → tool_group ──────────────────
|
||||
} else if (msg.role === 'tool_call' || msg.role === 'tool_result') {
|
||||
const groupId = nextId++;
|
||||
|
||||
const callMap = new Map<
|
||||
string,
|
||||
{
|
||||
callId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
resultDisplay: ToolResultDisplay | string | undefined;
|
||||
outputFile: string | undefined;
|
||||
renderOutputAsMarkdown: boolean | undefined;
|
||||
success: boolean | undefined;
|
||||
}
|
||||
>();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
while (
|
||||
i < messages.length &&
|
||||
(messages[i]!.role === 'tool_call' ||
|
||||
messages[i]!.role === 'tool_result')
|
||||
) {
|
||||
const m = messages[i]!;
|
||||
const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`;
|
||||
|
||||
if (m.role === 'tool_call') {
|
||||
if (!callMap.has(callId)) callOrder.push(callId);
|
||||
callMap.set(callId, {
|
||||
callId,
|
||||
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
|
||||
description: (m.metadata?.['description'] as string) ?? '',
|
||||
resultDisplay: undefined,
|
||||
outputFile: undefined,
|
||||
renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as
|
||||
| boolean
|
||||
| undefined,
|
||||
success: undefined,
|
||||
});
|
||||
} else {
|
||||
// tool_result — attach to existing call entry
|
||||
const entry = callMap.get(callId);
|
||||
const resultDisplay = m.metadata?.['resultDisplay'] as
|
||||
| ToolResultDisplay
|
||||
| string
|
||||
| undefined;
|
||||
const outputFile = m.metadata?.['outputFile'] as string | undefined;
|
||||
const success = m.metadata?.['success'] as boolean;
|
||||
|
||||
if (entry) {
|
||||
entry.success = success;
|
||||
entry.resultDisplay = resultDisplay;
|
||||
entry.outputFile = outputFile;
|
||||
} else {
|
||||
// Result arrived without a prior tool_call message (shouldn't
|
||||
// normally happen, but handle gracefully)
|
||||
callOrder.push(callId);
|
||||
callMap.set(callId, {
|
||||
callId,
|
||||
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
|
||||
description: '',
|
||||
resultDisplay,
|
||||
outputFile,
|
||||
renderOutputAsMarkdown: undefined,
|
||||
success,
|
||||
});
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => {
|
||||
const entry = callMap.get(callId)!;
|
||||
const approval = pendingApprovals.get(callId);
|
||||
|
||||
let status: ToolCallStatus;
|
||||
if (approval) {
|
||||
status = ToolCallStatus.Confirming;
|
||||
} else if (entry.success === undefined) {
|
||||
status = ToolCallStatus.Executing;
|
||||
} else if (entry.success) {
|
||||
status = ToolCallStatus.Success;
|
||||
} else {
|
||||
status = ToolCallStatus.Error;
|
||||
}
|
||||
|
||||
// For executing tools, use live output if available (Gap 4)
|
||||
const resultDisplay =
|
||||
status === ToolCallStatus.Executing && liveOutputs?.has(callId)
|
||||
? liveOutputs.get(callId)
|
||||
: entry.resultDisplay;
|
||||
|
||||
return {
|
||||
callId: entry.callId,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
resultDisplay,
|
||||
outputFile: entry.outputFile,
|
||||
renderOutputAsMarkdown: entry.renderOutputAsMarkdown,
|
||||
status,
|
||||
confirmationDetails: approval,
|
||||
ptyId:
|
||||
status === ToolCallStatus.Executing
|
||||
? shellPids?.get(callId)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
items.push({ type: 'tool_group', tools, id: groupId });
|
||||
} else {
|
||||
// Skip unknown roles
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
9
packages/cli/src/ui/components/agent-view/index.ts
Normal file
9
packages/cli/src/ui/components/agent-view/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { AgentTabBar } from './AgentTabBar.js';
|
||||
export { AgentChatView } from './AgentChatView.js';
|
||||
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||
Loading…
Add table
Add a link
Reference in a new issue