mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
feat(cli): background-agent UI — pill, combined dialog, detail view (#3488)
* feat(cli): background-task UI — pill, combined dialog, detail view Adds the user-facing surface for background tasks on top of the model-facing agent control primitives merged in #3471. A dedicated pill in the footer summarises running tasks, ↓ focuses it, and Enter opens a combined dialog listing every task with a detail view that shows the original prompt, live stats, and a rolling progress feed of recent tool invocations. Also renames BackgroundAgent* to BackgroundTask* for consistency with the user-facing terminology and the task_* tool family. * chore: trigger CI
This commit is contained in:
parent
d09c19c0c5
commit
03c88b7308
28 changed files with 2322 additions and 428 deletions
|
|
@ -44,6 +44,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
|||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { AgentViewProvider } from './ui/contexts/AgentViewContext.js';
|
||||
import { BackgroundTaskViewProvider } from './ui/contexts/BackgroundTaskViewContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js';
|
||||
import {
|
||||
|
|
@ -251,13 +252,15 @@ export async function startInteractiveUI(
|
|||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AgentViewProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
<BackgroundTaskViewProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</BackgroundTaskViewProvider>
|
||||
</AgentViewProvider>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ describe('runNonInteractive', () => {
|
|||
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
|
||||
setNotificationCallback: vi.fn(),
|
||||
setRegisterCallback: vi.fn(),
|
||||
getRunning: vi.fn().mockReturnValue([]),
|
||||
getAll: vi.fn().mockReturnValue([]),
|
||||
hasUnfinalizedTasks: vi.fn().mockReturnValue(false),
|
||||
abortAll: vi.fn(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
BackgroundAgentStatus,
|
||||
BackgroundTaskStatus,
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -302,7 +302,7 @@ export async function runNonInteractive(
|
|||
sdkNotification?: {
|
||||
task_id: string;
|
||||
tool_use_id?: string;
|
||||
status: BackgroundAgentStatus;
|
||||
status: BackgroundTaskStatus;
|
||||
usage?: {
|
||||
total_tokens: number;
|
||||
tool_uses: number;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ import {
|
|||
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useAgentViewState } from './contexts/AgentViewContext.js';
|
||||
import {
|
||||
useBackgroundTaskViewState,
|
||||
useBackgroundTaskViewActions,
|
||||
} from './contexts/BackgroundTaskViewContext.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||
|
|
@ -900,6 +904,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false);
|
||||
|
||||
const agentViewState = useAgentViewState();
|
||||
const { dialogOpen: bgTasksDialogOpen } = useBackgroundTaskViewState();
|
||||
const { closeDialog: closeBgTasksDialog } = useBackgroundTaskViewActions();
|
||||
|
||||
// Prompt suggestion state
|
||||
const [promptSuggestion, setPromptSuggestion] = useState<string | null>(null);
|
||||
|
|
@ -1593,7 +1599,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isResumeDialogOpen ||
|
||||
isDeleteDialogOpen ||
|
||||
isExtensionsManagerDialogOpen ||
|
||||
isRewindSelectorOpen;
|
||||
isRewindSelectorOpen ||
|
||||
bgTasksDialogOpen;
|
||||
dialogsVisibleRef.current = dialogsVisible;
|
||||
const shouldShowStickyTodos =
|
||||
stickyTodos !== null &&
|
||||
|
|
@ -1918,6 +1925,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isFolderTrustDialogOpen,
|
||||
showWelcomeBackDialog,
|
||||
handleWelcomeBackClose,
|
||||
isBackgroundTasksDialogOpen: bgTasksDialogOpen,
|
||||
closeBackgroundTasksDialog: closeBgTasksDialog,
|
||||
});
|
||||
|
||||
const handleExit = useCallback(
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import { HooksManagementDialog } from './hooks/HooksManagementDialog.js';
|
|||
import { SessionPicker } from './SessionPicker.js';
|
||||
import { RewindSelector } from './RewindSelector.js';
|
||||
import { MemoryDialog } from './MemoryDialog.js';
|
||||
import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js';
|
||||
import { useBackgroundTaskViewState } from '../contexts/BackgroundTaskViewContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
|
|
@ -64,6 +66,7 @@ export const DialogManager = ({
|
|||
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { dialogOpen: bgTasksDialogOpen } = useBackgroundTaskViewState();
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||
uiState;
|
||||
|
||||
|
|
@ -436,5 +439,19 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Background tasks dialog — lowest priority so other dialogs
|
||||
// (permissions, trust prompts, auth, etc.) always take precedence. The
|
||||
// dialog is part of the shared dialogsVisible machinery (see
|
||||
// AppContainer) so its visibility mutes the composer and the global
|
||||
// Ctrl+C / Esc handlers route through `closeAnyOpenDialog`.
|
||||
if (bgTasksDialogOpen) {
|
||||
return (
|
||||
<BackgroundTasksDialog
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
terminalWidth={mainAreaWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
|||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
|
|
@ -97,11 +98,13 @@ const renderWithWidth = (width: number, uiState: UIState) => {
|
|||
return render(
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider value={createMockConfig() as never}>
|
||||
<VimModeProvider settings={mockSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<VimModeProvider settings={mockSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</KeypressProvider>
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
|||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { BackgroundTasksPill } from './background-view/BackgroundTasksPill.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
import { useStatusLine } from '../hooks/useStatusLine.js';
|
||||
|
|
@ -173,7 +174,10 @@ export const Footer: React.FC = () => {
|
|||
{line}
|
||||
</Text>
|
||||
))}
|
||||
<Text wrap="truncate">{leftBottomContent}</Text>
|
||||
<Box flexDirection="row" flexShrink={1}>
|
||||
<Text wrap="truncate">{leftBottomContent}</Text>
|
||||
<BackgroundTasksPill />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right Section — never compressed, aligns to top so multi-line
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ import {
|
|||
useAgentViewState,
|
||||
useAgentViewActions,
|
||||
} from '../contexts/AgentViewContext.js';
|
||||
import {
|
||||
useBackgroundTaskViewState,
|
||||
useBackgroundTaskViewActions,
|
||||
} from '../contexts/BackgroundTaskViewContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
import { BaseTextInput } from './BaseTextInput.js';
|
||||
import type { RenderLineOptions } from './BaseTextInput.js';
|
||||
|
|
@ -124,7 +128,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const { pasteWorkaround } = useKeypressContext();
|
||||
const { agents, agentTabBarFocused } = useAgentViewState();
|
||||
const { setAgentTabBarFocused } = useAgentViewActions();
|
||||
const {
|
||||
entries: bgEntries,
|
||||
dialogOpen: bgDialogOpen,
|
||||
pillFocused: bgPillFocused,
|
||||
} = useBackgroundTaskViewState();
|
||||
const { setPillFocused: setBgPillFocused } = useBackgroundTaskViewActions();
|
||||
const hasAgents = agents.size > 0;
|
||||
// Includes terminal entries — the pill stays open so users can reopen
|
||||
// the dialog to inspect final state after the last agent finishes.
|
||||
const hasBgAgents = bgEntries.length > 0;
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const [escPressCount, setEscPressCount] = useState(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
|
|
@ -445,12 +458,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): boolean => {
|
||||
// When the tab bar has focus, block all non-printable keys so arrow
|
||||
// keys and shortcuts don't interfere. Printable characters fall
|
||||
// through to BaseTextInput's default handler so the first keystroke
|
||||
// appears in the input immediately (the tab bar handler releases
|
||||
// focus on the same event).
|
||||
if (agentTabBarFocused) {
|
||||
// When the Arena tab bar or background pill has focus, block
|
||||
// non-printable keys so arrow keys and shortcuts don't interfere.
|
||||
// Printable characters fall through to BaseTextInput's default
|
||||
// handler so the first keystroke appears in the input immediately
|
||||
// (each surface's own handler releases focus on the same event).
|
||||
if (agentTabBarFocused || bgPillFocused) {
|
||||
if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
|
|
@ -462,6 +475,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
return true; // consume non-printable keys
|
||||
}
|
||||
|
||||
// When the Background tasks dialog is open, swallow every key so
|
||||
// nothing reaches the composer buffer — the dialog's own keypress
|
||||
// handler owns selection, open/close, and stop actions. Unlike
|
||||
// the tab bar we do NOT let printable chars type through, because
|
||||
// the dialog doesn't auto-close on printable input and users
|
||||
// would leak text into the hidden composer.
|
||||
if (bgDialogOpen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO(jacobr): this special case is likely not needed anymore.
|
||||
// We should probably stop supporting paste if the InputPrompt is not
|
||||
// focused.
|
||||
|
|
@ -929,10 +952,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (inputHistory.navigateDown()) {
|
||||
return true;
|
||||
}
|
||||
// Focus order on Down from an empty composer:
|
||||
// team tab bar (if any Arena agents) → Background tasks pill
|
||||
// (if any bg agents) → otherwise stay put. The pill itself
|
||||
// opens the dialog on Enter; the tab bar re-routes Down into
|
||||
// the pill once it has focus, so both surfaces remain reachable
|
||||
// in sequence.
|
||||
if (hasAgents) {
|
||||
setAgentTabBarFocused(true);
|
||||
return true;
|
||||
}
|
||||
if (hasBgAgents) {
|
||||
setBgPillFocused(true);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1096,8 +1129,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
parsePlaceholder,
|
||||
freePlaceholderId,
|
||||
agentTabBarFocused,
|
||||
bgDialogOpen,
|
||||
bgPillFocused,
|
||||
hasAgents,
|
||||
hasBgAgents,
|
||||
setAgentTabBarFocused,
|
||||
setBgPillFocused,
|
||||
followup,
|
||||
onPromptSuggestionDismiss,
|
||||
],
|
||||
|
|
|
|||
274
packages/cli/src/ui/components/agent-view/AgentChatContent.tsx
Normal file
274
packages/cli/src/ui/components/agent-view/AgentChatContent.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Presentational transcript renderer for a single AgentCore. Subscribes
|
||||
* to the core's event emitter internally and force-renders on updates,
|
||||
* so consumers only pass state props and don't wire their own listeners.
|
||||
*/
|
||||
|
||||
import { Box, Text, Static } from 'ink';
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
AgentStatus,
|
||||
AgentEventType,
|
||||
getGitBranch,
|
||||
type AgentCore,
|
||||
type AgentInteractive,
|
||||
type AgentStatusChangeEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useAgentViewActions } from '../../contexts/AgentViewContext.js';
|
||||
import { HistoryItemDisplay } from '../HistoryItemDisplay.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
|
||||
import { AgentHeader } from './AgentHeader.js';
|
||||
|
||||
export interface AgentChatContentProps {
|
||||
/** The agent's AgentCore — the source of truth for transcript state. */
|
||||
core: AgentCore;
|
||||
/**
|
||||
* The InteractiveAgent wrapper, if any. Present for live arena tabs;
|
||||
* omit for read-only transcript surfaces. When provided, drives the
|
||||
* spinner and the embedded-shell affordance — all reads happen inside
|
||||
* this component, which re-renders on the relevant events, so state
|
||||
* stays fresh without plumbing props from an ancestor that doesn't
|
||||
* subscribe.
|
||||
*/
|
||||
interactiveAgent?: AgentInteractive | null;
|
||||
/** Stable identifier used for memo keys and the Static remount key. */
|
||||
instanceKey: string;
|
||||
/** Optional display name shown in the header. */
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
export const AgentChatContent = ({
|
||||
core,
|
||||
interactiveAgent,
|
||||
instanceKey,
|
||||
modelName,
|
||||
}: AgentChatContentProps) => {
|
||||
const readonly = !interactiveAgent;
|
||||
const uiState = useUIState();
|
||||
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
|
||||
uiState;
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
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(() => {
|
||||
const emitter = core.getEventEmitter();
|
||||
|
||||
const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
|
||||
const onToolCall = () => forceRender();
|
||||
const onToolResult = () => forceRender();
|
||||
const onRoundEnd = () => forceRender();
|
||||
const onApproval = () => forceRender();
|
||||
const onOutputUpdate = () => forceRender();
|
||||
const onFinish = () => 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);
|
||||
emitter.on(AgentEventType.FINISH, onFinish);
|
||||
|
||||
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);
|
||||
emitter.off(AgentEventType.FINISH, onFinish);
|
||||
};
|
||||
}, [core, forceRender]);
|
||||
|
||||
const messages = core.getMessages();
|
||||
const pendingApprovals = core.getPendingApprovals();
|
||||
const liveOutputs = core.getLiveOutputs();
|
||||
const shellPids = core.getShellPids();
|
||||
|
||||
// Read status/PTY/timing state fresh on every render — this component
|
||||
// re-renders on STATUS_CHANGE/TOOL_CALL/TOOL_OUTPUT_UPDATE so the reads
|
||||
// stay current without prop plumbing from a non-subscribed ancestor.
|
||||
const status = interactiveAgent?.getStatus() ?? AgentStatus.COMPLETED;
|
||||
const executionStartTimes = interactiveAgent?.getExecutionStartTimes();
|
||||
const activePtyId =
|
||||
shellPids.size > 0
|
||||
? ((shellPids.values().next().value as number | undefined) ?? null)
|
||||
: null;
|
||||
const isRunning =
|
||||
status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
|
||||
|
||||
// Embedded-shell focus (Ctrl+F toggle). Lives here so the auto-reset
|
||||
// effect sees a fresh activePtyId — AgentChatView above us doesn't
|
||||
// subscribe to agent events, so driving this from there would leave
|
||||
// focus stuck on a terminated PTY.
|
||||
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
|
||||
const { setAgentShellFocused } = useAgentViewActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (readonly) return;
|
||||
setAgentShellFocused(embeddedShellFocused);
|
||||
return () => setAgentShellFocused(false);
|
||||
}, [embeddedShellFocused, readonly, setAgentShellFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePtyId) setEmbeddedShellFocused(false);
|
||||
}, [activePtyId]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (readonly) return;
|
||||
if (key.ctrl && key.name === 'f') {
|
||||
if (activePtyId || embeddedShellFocused) {
|
||||
setEmbeddedShellFocused((prev) => !prev);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: !readonly },
|
||||
);
|
||||
|
||||
// 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,
|
||||
liveOutputs,
|
||||
shellPids,
|
||||
executionStartTimes,
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
instanceKey,
|
||||
messages.length,
|
||||
pendingApprovals.size,
|
||||
liveOutputs.size,
|
||||
shellPids.size,
|
||||
executionStartTimes?.size,
|
||||
tickRef.current,
|
||||
],
|
||||
);
|
||||
|
||||
// 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;
|
||||
}, [allItems]);
|
||||
|
||||
const committedItems = allItems.slice(0, splitIndex);
|
||||
const pendingItems = allItems.slice(splitIndex);
|
||||
|
||||
const agentWorkingDir = core.runtimeContext.getTargetDir() ?? '';
|
||||
// Cache the branch — it won't change during the agent's lifetime and
|
||||
// getGitBranch uses synchronous execSync which blocks the render loop.
|
||||
const agentGitBranch = useMemo(
|
||||
() => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[instanceKey],
|
||||
);
|
||||
|
||||
const agentModelId = core.modelConfig.model ?? '';
|
||||
|
||||
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-${instanceKey}-${historyRemountKey}`}
|
||||
items={[
|
||||
<AgentHeader
|
||||
key="agent-header"
|
||||
modelId={agentModelId}
|
||||
modelName={modelName}
|
||||
workingDirectory={agentWorkingDir}
|
||||
gitBranch={agentGitBranch}
|
||||
/>,
|
||||
...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. */}
|
||||
{pendingItems.map((item) => (
|
||||
<HistoryItemDisplay
|
||||
key={item.id}
|
||||
item={item}
|
||||
isPending={true}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={contentWidth}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
isFocused={!readonly}
|
||||
activeShellPtyId={activePtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Spinner */}
|
||||
{isRunning && (
|
||||
<Box marginX={2} marginTop={1}>
|
||||
<GeminiRespondingSpinner />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Re-exported helper for consumers that render an error panel when the
|
||||
// backing agent/core isn't available (e.g. a race where the registry
|
||||
// entry exists but `core` hasn't been attached yet).
|
||||
export const AgentChatMissing = ({ label }: { label: string }) => (
|
||||
<Box marginX={2}>
|
||||
<Text color={theme.status.error}>{label}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -5,45 +5,13 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* Arena wrapper around AgentChatContent. Resolves the selected agent
|
||||
* from AgentViewContext; the content component owns live-state reads
|
||||
* and the Ctrl+F embedded-shell toggle.
|
||||
*/
|
||||
|
||||
import { Box, Text, Static } from 'ink';
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
AgentStatus,
|
||||
AgentEventType,
|
||||
getGitBranch,
|
||||
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';
|
||||
import { AgentHeader } from './AgentHeader.js';
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────────
|
||||
import { useAgentViewState } from '../../contexts/AgentViewContext.js';
|
||||
import { AgentChatContent, AgentChatMissing } from './AgentChatContent.js';
|
||||
|
||||
interface AgentChatViewProps {
|
||||
agentId: string;
|
||||
|
|
@ -51,225 +19,21 @@ interface AgentChatViewProps {
|
|||
|
||||
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 executionStartTimes = interactiveAgent?.getExecutionStartTimes();
|
||||
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,
|
||||
executionStartTimes,
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
agentId,
|
||||
messages.length,
|
||||
pendingApprovals?.size,
|
||||
liveOutputs?.size,
|
||||
shellPids?.size,
|
||||
executionStartTimes?.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);
|
||||
|
||||
const core = interactiveAgent?.getCore();
|
||||
const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? '';
|
||||
// Cache the branch — it won't change during the agent's lifetime and
|
||||
// getGitBranch uses synchronous execSync which blocks the render loop.
|
||||
const agentGitBranch = useMemo(
|
||||
() => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agentId],
|
||||
);
|
||||
|
||||
if (!agent || !interactiveAgent || !core) {
|
||||
return (
|
||||
<Box marginX={2}>
|
||||
<Text color={theme.status.error}>
|
||||
Agent "{agentId}" not found.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
return <AgentChatMissing label={`Agent "${agentId}" not found.`} />;
|
||||
}
|
||||
|
||||
const agentModelId = core.modelConfig.model ?? '';
|
||||
|
||||
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={[
|
||||
<AgentHeader
|
||||
key="agent-header"
|
||||
modelId={agentModelId}
|
||||
modelName={agent.modelName}
|
||||
workingDirectory={agentWorkingDir}
|
||||
gitBranch={agentGitBranch}
|
||||
/>,
|
||||
...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>
|
||||
<AgentChatContent
|
||||
core={core}
|
||||
interactiveAgent={interactiveAgent}
|
||||
instanceKey={agentId}
|
||||
modelName={agent.modelName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ import {
|
|||
useAgentViewActions,
|
||||
type RegisteredAgent,
|
||||
} from '../../contexts/AgentViewContext.js';
|
||||
import {
|
||||
useBackgroundTaskViewState,
|
||||
useBackgroundTaskViewActions,
|
||||
} from '../../contexts/BackgroundTaskViewContext.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
|
@ -59,9 +63,16 @@ function statusIndicator(agent: RegisteredAgent): {
|
|||
export const AgentTabBar: React.FC = () => {
|
||||
const { activeView, agents, agentShellFocused, agentTabBarFocused } =
|
||||
useAgentViewState();
|
||||
const { switchToNext, switchToPrevious, setAgentTabBarFocused } =
|
||||
useAgentViewActions();
|
||||
const {
|
||||
switchToNext,
|
||||
switchToPrevious,
|
||||
switchToMain,
|
||||
setAgentTabBarFocused,
|
||||
} = useAgentViewActions();
|
||||
const { entries: bgEntries } = useBackgroundTaskViewState();
|
||||
const { setPillFocused: setBgPillFocused } = useBackgroundTaskViewActions();
|
||||
const { embeddedShellFocused } = useUIState();
|
||||
const hasBgAgents = bgEntries.length > 0;
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
|
@ -74,6 +85,15 @@ export const AgentTabBar: React.FC = () => {
|
|||
switchToNext();
|
||||
} else if (key.name === 'up') {
|
||||
setAgentTabBarFocused(false);
|
||||
} else if (key.name === 'down') {
|
||||
// Switch to main first — the footer pill only renders under the
|
||||
// main view, so focusing it from an agent tab would strand focus
|
||||
// on an offscreen surface.
|
||||
if (hasBgAgents) {
|
||||
setAgentTabBarFocused(false);
|
||||
switchToMain();
|
||||
setBgPillFocused(true);
|
||||
}
|
||||
} else if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import type { BackgroundTaskEntry, Config } from '@qwen-code/qwen-code-core';
|
||||
import { BackgroundTasksDialog } from './BackgroundTasksDialog.js';
|
||||
import {
|
||||
BackgroundTaskViewProvider,
|
||||
useBackgroundTaskViewActions,
|
||||
useBackgroundTaskViewState,
|
||||
} from '../../contexts/BackgroundTaskViewContext.js';
|
||||
import { ConfigContext } from '../../contexts/ConfigContext.js';
|
||||
import { useBackgroundTaskView } from '../../hooks/useBackgroundTaskView.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
|
||||
vi.mock('../../hooks/useBackgroundTaskView.js', () => ({
|
||||
useBackgroundTaskView: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseBackgroundTaskView = vi.mocked(useBackgroundTaskView);
|
||||
const mockedUseKeypress = vi.mocked(useKeypress);
|
||||
|
||||
function entry(overrides: Partial<BackgroundTaskEntry>): BackgroundTaskEntry {
|
||||
return {
|
||||
agentId: 'a',
|
||||
description: 'desc',
|
||||
status: 'running',
|
||||
startTime: 0,
|
||||
abortController: new AbortController(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface ProbeHandle {
|
||||
actions: ReturnType<typeof useBackgroundTaskViewActions>;
|
||||
state: ReturnType<typeof useBackgroundTaskViewState>;
|
||||
setEntries: (next: readonly BackgroundTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
setEntries: (next: readonly BackgroundTaskEntry[]) => void;
|
||||
pressKey: (key: { name?: string; sequence?: string }) => void;
|
||||
call: (fn: () => void) => void;
|
||||
lastFrame: () => string | undefined;
|
||||
probe: { current: ProbeHandle | null };
|
||||
}
|
||||
|
||||
function setup(initial: readonly BackgroundTaskEntry[]): Harness {
|
||||
const handlers: Array<(key: { name?: string; sequence?: string }) => void> =
|
||||
[];
|
||||
mockedUseKeypress.mockImplementation((cb, opts) => {
|
||||
if (opts?.isActive !== false) handlers.push(cb as never);
|
||||
});
|
||||
|
||||
const cancel = vi.fn();
|
||||
const config = {
|
||||
getBackgroundTaskRegistry: () => ({
|
||||
cancel,
|
||||
setActivityChangeCallback: vi.fn(),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const handle: { current: ProbeHandle | null } = { current: null };
|
||||
|
||||
// Wrapper holds the entries in React state so updates propagate normally.
|
||||
// The hook mock is bound to this wrapper via the closure below.
|
||||
function Harness() {
|
||||
const [entries, setEntries] = useState(initial);
|
||||
mockedUseBackgroundTaskView.mockImplementation(() => ({ entries }));
|
||||
return (
|
||||
<ConfigContext.Provider value={config}>
|
||||
<BackgroundTaskViewProvider config={config}>
|
||||
<Probe entriesSetter={setEntries} />
|
||||
<BackgroundTasksDialog
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</BackgroundTaskViewProvider>
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Probe({
|
||||
entriesSetter,
|
||||
}: {
|
||||
entriesSetter: (e: readonly BackgroundTaskEntry[]) => void;
|
||||
}) {
|
||||
handle.current = {
|
||||
actions: useBackgroundTaskViewActions(),
|
||||
state: useBackgroundTaskViewState(),
|
||||
setEntries: entriesSetter,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<Harness />);
|
||||
|
||||
return {
|
||||
cancel,
|
||||
setEntries(next) {
|
||||
handlers.length = 0;
|
||||
act(() => handle.current!.setEntries(next));
|
||||
},
|
||||
pressKey(key) {
|
||||
act(() => {
|
||||
for (const h of handlers) h(key);
|
||||
});
|
||||
},
|
||||
call(fn) {
|
||||
act(() => fn());
|
||||
},
|
||||
lastFrame,
|
||||
probe: handle,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BackgroundTasksDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exits to list mode when the running entry being viewed flips to a terminal status', () => {
|
||||
const running = entry({ agentId: 'a', status: 'running' });
|
||||
const h = setup([running]);
|
||||
|
||||
h.call(() => h.probe.current!.actions.openDialog());
|
||||
h.call(() => h.probe.current!.actions.enterDetail());
|
||||
expect(h.probe.current!.state.dialogMode).toBe('detail');
|
||||
|
||||
h.setEntries([{ ...running, status: 'completed' }]);
|
||||
|
||||
expect(h.probe.current!.state.dialogMode).toBe('list');
|
||||
});
|
||||
|
||||
it('exits to list mode after cancelling the running entry being viewed in detail', () => {
|
||||
const running = entry({ agentId: 'a', status: 'running' });
|
||||
const h = setup([running]);
|
||||
|
||||
h.call(() => h.probe.current!.actions.openDialog());
|
||||
h.call(() => h.probe.current!.actions.enterDetail());
|
||||
expect(h.probe.current!.state.dialogMode).toBe('detail');
|
||||
|
||||
h.pressKey({ sequence: 'x' });
|
||||
expect(h.cancel).toHaveBeenCalledWith('a');
|
||||
|
||||
// Registry would push the cancelled status; simulate that update.
|
||||
h.setEntries([{ ...running, status: 'cancelled' }]);
|
||||
|
||||
expect(h.probe.current!.state.dialogMode).toBe('list');
|
||||
});
|
||||
|
||||
it('keeps detail mode when an already-terminal entry is opened (no spurious fallback)', () => {
|
||||
const done = entry({ agentId: 'a', status: 'completed' });
|
||||
const h = setup([done]);
|
||||
|
||||
h.call(() => h.probe.current!.actions.openDialog());
|
||||
h.call(() => h.probe.current!.actions.enterDetail());
|
||||
expect(h.probe.current!.state.dialogMode).toBe('detail');
|
||||
|
||||
// The auto-fallback ref must only trigger on a running → terminal
|
||||
// transition. Re-rendering with a fresh terminal entry must not evict
|
||||
// the user from detail.
|
||||
h.setEntries([{ ...done }]);
|
||||
expect(h.probe.current!.state.dialogMode).toBe('detail');
|
||||
});
|
||||
|
||||
it('clamps selectedIndex when entries shrink', () => {
|
||||
const a = entry({ agentId: 'a' });
|
||||
const b = entry({ agentId: 'b' });
|
||||
const c = entry({ agentId: 'c' });
|
||||
const h = setup([a, b, c]);
|
||||
|
||||
h.call(() => h.probe.current!.actions.openDialog());
|
||||
h.call(() => h.probe.current!.actions.moveSelectionDown());
|
||||
h.call(() => h.probe.current!.actions.moveSelectionDown());
|
||||
expect(h.probe.current!.state.selectedIndex).toBe(2);
|
||||
|
||||
h.setEntries([a]);
|
||||
expect(h.probe.current!.state.selectedIndex).toBe(0);
|
||||
|
||||
h.setEntries([]);
|
||||
expect(h.probe.current!.state.selectedIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,572 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* BackgroundTasksDialog — overlay with two modes (`list`, `detail`).
|
||||
* Key handling is scoped to this component; the composer is muted via
|
||||
* the `bgDialogOpen` branch in InputPrompt while the dialog is open.
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import stringWidth from 'string-width';
|
||||
import {
|
||||
useBackgroundTaskViewState,
|
||||
useBackgroundTaskViewActions,
|
||||
} from '../../contexts/BackgroundTaskViewContext.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import {
|
||||
buildBackgroundEntryLabel,
|
||||
ToolDisplayNames,
|
||||
ToolNames,
|
||||
type BackgroundTaskEntry,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Tool-name → display-name lookup (`run_shell_command` → `Shell`).
|
||||
const TOOL_DISPLAY_BY_NAME: Record<string, string> = Object.fromEntries(
|
||||
(Object.keys(ToolNames) as Array<keyof typeof ToolNames>).map((key) => [
|
||||
ToolNames[key],
|
||||
ToolDisplayNames[key],
|
||||
]),
|
||||
);
|
||||
|
||||
function formatActivityLabel(name: string, description: string | undefined) {
|
||||
const display = TOOL_DISPLAY_BY_NAME[name] ?? name;
|
||||
const singleLineDesc = description
|
||||
? description.replace(/\s*\n\s*/g, ' ').trim()
|
||||
: '';
|
||||
return singleLineDesc ? `${display}(${singleLineDesc})` : display;
|
||||
}
|
||||
import { formatDuration, formatTokenCount } from '../../utils/formatters.js';
|
||||
|
||||
const STATUS_VERBS: Record<BackgroundTaskEntry['status'], string> = {
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Stopped',
|
||||
};
|
||||
|
||||
interface StatusPresentation {
|
||||
icon: string;
|
||||
color: string;
|
||||
labelColor: string;
|
||||
}
|
||||
|
||||
function terminalStatusPresentation(
|
||||
status: BackgroundTaskEntry['status'],
|
||||
): StatusPresentation | null {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return {
|
||||
icon: '\u2714',
|
||||
color: theme.status.success,
|
||||
labelColor: theme.text.secondary,
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
icon: '\u2716',
|
||||
color: theme.status.error,
|
||||
labelColor: theme.status.errorDim,
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
icon: '\u2716',
|
||||
color: theme.status.warning,
|
||||
labelColor: theme.status.warningDim,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rowLabel(entry: BackgroundTaskEntry): string {
|
||||
return buildBackgroundEntryLabel(entry, { includePrefix: false });
|
||||
}
|
||||
|
||||
function elapsedFor(entry: BackgroundTaskEntry): string {
|
||||
const elapsedMs = Math.max(
|
||||
0,
|
||||
(entry.endTime ?? Date.now()) - entry.startTime,
|
||||
);
|
||||
// Round down to whole seconds — the detail subtitle is a glanceable
|
||||
// indicator, not a stopwatch, and sub-second precision flickers distract
|
||||
// from the actual status change.
|
||||
const wholeSeconds = Math.floor(elapsedMs / 1000);
|
||||
return formatDuration(wholeSeconds * 1000, { hideTrailingZeros: true });
|
||||
}
|
||||
|
||||
// Manually truncate to an exact cell width so each row lines up with the
|
||||
// others regardless of content length. Relying on Ink's `wrap="truncate-end"`
|
||||
// inside MaxSizedBox produced inconsistent row widths when some rows fit and
|
||||
// others needed ellipsis, breaking the left-column alignment of the prefix.
|
||||
function truncateToWidth(text: string, maxWidth: number): string {
|
||||
if (maxWidth <= 0) return '';
|
||||
if (stringWidth(text) <= maxWidth) return text;
|
||||
const ellipsis = '…';
|
||||
const ellipsisWidth = stringWidth(ellipsis);
|
||||
const target = Math.max(0, maxWidth - ellipsisWidth);
|
||||
let width = 0;
|
||||
let result = '';
|
||||
for (const char of text) {
|
||||
const charWidth = stringWidth(char);
|
||||
if (width + charWidth > target) break;
|
||||
width += charWidth;
|
||||
result += char;
|
||||
}
|
||||
return result + ellipsis;
|
||||
}
|
||||
|
||||
// ─── List mode ─────────────────────────────────────────────
|
||||
|
||||
const ListBody: React.FC<{
|
||||
entries: readonly BackgroundTaskEntry[];
|
||||
selectedIndex: number;
|
||||
maxRows: number;
|
||||
}> = ({ entries, selectedIndex, maxRows }) => {
|
||||
// Keep the "Local agents (N)" section header rendered even when the list
|
||||
// is empty, so the overlay doesn't collapse into a single line of
|
||||
// empty-state text when the last agent finishes while the dialog is open.
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
<Text bold>Local agents</Text>
|
||||
<Text color={theme.text.secondary}> (0)</Text>
|
||||
</Box>
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>No tasks currently running</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Window entries around selectedIndex. When the list fits, show
|
||||
// everything; otherwise centre the selection and clamp to the ends.
|
||||
// "+N more above/below" lines consume one row each on the respective
|
||||
// side, so subtract them from the available row budget.
|
||||
const fits = entries.length <= maxRows;
|
||||
const effectiveRows = Math.max(1, fits ? maxRows : maxRows - 2);
|
||||
const windowStart = fits
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
selectedIndex - Math.floor(effectiveRows / 2),
|
||||
entries.length - effectiveRows,
|
||||
),
|
||||
);
|
||||
const windowEnd = fits
|
||||
? entries.length
|
||||
: Math.min(entries.length, windowStart + effectiveRows);
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = entries.length - windowEnd;
|
||||
const visible = entries.slice(windowStart, windowEnd);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
<Text bold>Local agents</Text>
|
||||
<Text color={theme.text.secondary}> ({entries.length})</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
{hiddenAbove > 0 && (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` ^ ${hiddenAbove} more above`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{visible.map((entry, visibleIdx) => {
|
||||
const idx = windowStart + visibleIdx;
|
||||
const isSelected = idx === selectedIndex;
|
||||
const terminal = terminalStatusPresentation(entry.status);
|
||||
const labelColor = isSelected
|
||||
? theme.text.accent
|
||||
: terminal
|
||||
? terminal.labelColor
|
||||
: theme.text.primary;
|
||||
return (
|
||||
<Box key={entry.agentId} flexDirection="row" paddingX={1}>
|
||||
<Text color={isSelected ? theme.text.accent : undefined}>
|
||||
{isSelected ? '> ' : ' '}
|
||||
</Text>
|
||||
<Text color={labelColor}>{rowLabel(entry)}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{hiddenBelow > 0 && (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{` v ${hiddenBelow} more below`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Detail mode ───────────────────────────────────────────
|
||||
|
||||
const DetailBody: React.FC<{
|
||||
entry: BackgroundTaskEntry;
|
||||
maxHeight: number;
|
||||
maxWidth: number;
|
||||
}> = ({ entry, maxHeight, maxWidth }) => {
|
||||
const title = `${entry.subagentType ?? 'Agent'} \u203A ${rowLabel(entry)}`;
|
||||
|
||||
const terminal = terminalStatusPresentation(entry.status);
|
||||
const dimSubtitleParts: string[] = [elapsedFor(entry)];
|
||||
if (entry.stats?.totalTokens) {
|
||||
dimSubtitleParts.push(
|
||||
`${formatTokenCount(entry.stats.totalTokens)} tokens`,
|
||||
);
|
||||
}
|
||||
if (entry.stats?.toolUses !== undefined) {
|
||||
dimSubtitleParts.push(
|
||||
`${entry.stats.toolUses} tool${entry.stats.toolUses === 1 ? '' : 's'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Registry stores activities newest-last; keep that order so the live
|
||||
// row sits at the bottom of the Progress block. Cap at 5 in case the
|
||||
// registry ever raises its buffer.
|
||||
const activities = (entry.recentActivities ?? []).slice(-5);
|
||||
const hasError = entry.status === 'failed' && Boolean(entry.error);
|
||||
|
||||
// Prompt: show at most 5 newline-delimited segments, each row truncated
|
||||
// to one visual line. Append an ellipsis if the source had more.
|
||||
const promptLines = entry.prompt ? entry.prompt.split('\n') : [];
|
||||
const visiblePromptLines = promptLines.slice(0, 5);
|
||||
const promptTruncated = promptLines.length > visiblePromptLines.length;
|
||||
if (promptTruncated && visiblePromptLines.length > 0) {
|
||||
const lastIdx = visiblePromptLines.length - 1;
|
||||
visiblePromptLines[lastIdx] =
|
||||
`${visiblePromptLines[lastIdx].trimEnd()}\u2026`;
|
||||
}
|
||||
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={maxHeight}
|
||||
maxWidth={maxWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Box>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
{terminal && (
|
||||
<Text color={terminal.color}>
|
||||
{`${terminal.icon} ${STATUS_VERBS[entry.status]} \u00B7 `}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.secondary}>
|
||||
{dimSubtitleParts.join(' \u00B7 ')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{activities.length > 0 && (
|
||||
<Fragment>
|
||||
<Box />
|
||||
<Box>
|
||||
<Text bold dimColor>
|
||||
Progress
|
||||
</Text>
|
||||
</Box>
|
||||
{activities.map((a, i) => {
|
||||
const isLast = i === activities.length - 1;
|
||||
// ASCII `>` is unambiguously one cell wide in every terminal
|
||||
// font, so `> ` (2 cells) aligns with a two-space indent on the
|
||||
// other rows. Unicode chevrons rendered with inconsistent width
|
||||
// broke alignment in some fonts.
|
||||
const prefix = isLast ? '> ' : ' ';
|
||||
const label = truncateToWidth(
|
||||
formatActivityLabel(a.name, a.description),
|
||||
Math.max(0, maxWidth - stringWidth(prefix)),
|
||||
);
|
||||
return (
|
||||
<Box key={`${a.at}-${i}`}>
|
||||
<Text
|
||||
color={isLast ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
{prefix}
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{visiblePromptLines.length > 0 && (
|
||||
<Fragment>
|
||||
<Box />
|
||||
<Box>
|
||||
<Text bold dimColor>
|
||||
Prompt
|
||||
</Text>
|
||||
</Box>
|
||||
{visiblePromptLines.map((line, i) => (
|
||||
<Box key={`prompt-${i}`}>
|
||||
<Text wrap="truncate-end">{line || ' '}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<Fragment>
|
||||
<Box />
|
||||
<Box>
|
||||
<Text bold color={theme.status.error}>
|
||||
Error
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.status.error} wrap="wrap">
|
||||
{entry.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</Fragment>
|
||||
)}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Dialog shell ──────────────────────────────────────────
|
||||
|
||||
interface BackgroundTasksDialogProps {
|
||||
availableTerminalHeight: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { entries, selectedIndex, dialogOpen, dialogMode } =
|
||||
useBackgroundTaskViewState();
|
||||
const {
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
closeDialog,
|
||||
enterDetail,
|
||||
exitDetail,
|
||||
cancelSelected,
|
||||
} = useBackgroundTaskViewActions();
|
||||
const config = useConfig();
|
||||
|
||||
// Progress and Prompt are each self-capped at 5 rows inside DetailBody,
|
||||
// so the body never grows unbounded. Use all available height (minus the
|
||||
// dialog chrome) as the MaxSizedBox budget so nothing gets clipped just
|
||||
// because the terminal is short. Chrome = border(2) + title(1) + two
|
||||
// marginTops(2) + hint(1) = 6 rows.
|
||||
const detailContentHeight = Math.max(10, availableTerminalHeight - 6);
|
||||
// Rounded border + paddingX=1 on the outer Box ≈ 4 horizontal cells.
|
||||
const detailContentWidth = Math.max(10, terminalWidth - 4);
|
||||
|
||||
// List mode row budget: terminal height minus chrome (border 2 + title 1
|
||||
// + two marginTops 2 + hint 1) and list header ("N active agents" 1 +
|
||||
// marginTop 1 + "Local agents (N)" 1) = 10.
|
||||
const listMaxRows = Math.max(3, availableTerminalHeight - 10);
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => entries[selectedIndex] ?? null,
|
||||
[entries, selectedIndex],
|
||||
);
|
||||
|
||||
// Tick up a local counter on each activity callback to force the
|
||||
// detail body to re-render while it's open. The main status
|
||||
// subscription in useBackgroundTaskView intentionally ignores
|
||||
// activity updates so the Footer pill and AppContainer don't re-run
|
||||
// on every tool call a background agent makes.
|
||||
const [, bumpActivity] = useState(0);
|
||||
const selectedAgentId = selectedEntry?.agentId;
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || dialogMode !== 'detail' || !selectedAgentId) return;
|
||||
const registry = config.getBackgroundTaskRegistry();
|
||||
const onActivity = (entry: BackgroundTaskEntry) => {
|
||||
if (entry.agentId !== selectedAgentId) return;
|
||||
bumpActivity((n) => n + 1);
|
||||
};
|
||||
registry.setActivityChangeCallback(onActivity);
|
||||
return () => registry.setActivityChangeCallback(undefined);
|
||||
}, [dialogOpen, dialogMode, config, selectedAgentId]);
|
||||
|
||||
// Wall-clock tick for the running agent's duration. Activity callbacks
|
||||
// fire when tools run, but duration needs to advance even when the agent
|
||||
// is quietly thinking — otherwise the "33s" line freezes between tool uses.
|
||||
const selectedStatus = selectedEntry?.status;
|
||||
useEffect(() => {
|
||||
if (
|
||||
!dialogOpen ||
|
||||
dialogMode !== 'detail' ||
|
||||
!selectedAgentId ||
|
||||
selectedStatus !== 'running'
|
||||
)
|
||||
return;
|
||||
const id = setInterval(() => bumpActivity((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [dialogOpen, dialogMode, selectedAgentId, selectedStatus]);
|
||||
|
||||
// Auto-fallback to the list view when the selected agent reaches a
|
||||
// terminal state while the user is watching it live. We only exit on
|
||||
// the running → terminal *transition* — if the user deliberately
|
||||
// opened an already-completed entry, they stay on it. The detail
|
||||
// view itself renders terminal state fine, so this is a UX choice
|
||||
// (return focus to the running roster) rather than a correctness fix.
|
||||
const initialDetailStatusRef = useRef<{
|
||||
agentId: string;
|
||||
status: BackgroundTaskEntry['status'];
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || dialogMode !== 'detail') {
|
||||
initialDetailStatusRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Defensive fallback: if the viewed entry has somehow gone missing,
|
||||
// drop back to the list so we don't sit on a "No entry to show" screen.
|
||||
// Hitting this path now is unlikely — terminal entries stay in the
|
||||
// registry — but the entry could disappear if the registry is reset.
|
||||
if (!selectedAgentId) {
|
||||
initialDetailStatusRef.current = null;
|
||||
exitDetail();
|
||||
return;
|
||||
}
|
||||
const seen = initialDetailStatusRef.current;
|
||||
if (!seen || seen.agentId !== selectedAgentId) {
|
||||
// First render in detail mode for this entry — remember the status we
|
||||
// opened with so we can detect a transition away from 'running' later.
|
||||
if (selectedStatus) {
|
||||
initialDetailStatusRef.current = {
|
||||
agentId: selectedAgentId,
|
||||
status: selectedStatus,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
seen.status === 'running' &&
|
||||
selectedStatus &&
|
||||
selectedStatus !== 'running'
|
||||
) {
|
||||
exitDetail();
|
||||
}
|
||||
}, [dialogOpen, dialogMode, selectedAgentId, selectedStatus, exitDetail]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!dialogOpen) return;
|
||||
|
||||
if (dialogMode === 'list') {
|
||||
if (key.name === 'up') {
|
||||
moveSelectionUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
moveSelectionDown();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
if (selectedEntry) enterDetail();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'escape' || key.name === 'left') {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
if (key.sequence === 'x' && !key.ctrl && !key.meta) {
|
||||
cancelSelected();
|
||||
return;
|
||||
}
|
||||
// Note: the "stop all agents" chord (ctrl+x ctrl+k in claw-code)
|
||||
// is intentionally deferred. `useKeypress` fires per keystroke,
|
||||
// so collapsing the chord to plain ctrl+k makes a destructive
|
||||
// action too easy to trigger by mistake. Stop-all will land in
|
||||
// a follow-up PR once proper chord handling is in place.
|
||||
return;
|
||||
}
|
||||
|
||||
// detail mode
|
||||
if (key.name === 'left') {
|
||||
exitDetail();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key.name === 'escape' ||
|
||||
key.name === 'return' ||
|
||||
key.name === 'space'
|
||||
) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
if (key.sequence === 'x' && !key.ctrl && !key.meta) {
|
||||
cancelSelected();
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: dialogOpen },
|
||||
);
|
||||
|
||||
if (!dialogOpen) return null;
|
||||
|
||||
// Hint footer — context-sensitive.
|
||||
const hints: string[] = [];
|
||||
if (dialogMode === 'list') {
|
||||
hints.push('\u2191/\u2193 select', 'Enter view');
|
||||
if (selectedEntry?.status === 'running') hints.push('x stop');
|
||||
hints.push('\u2190/Esc close');
|
||||
} else {
|
||||
hints.push('\u2190 go back', 'Esc/Enter/Space close');
|
||||
if (selectedEntry?.status === 'running') hints.push('x stop');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
marginTop={1}
|
||||
paddingX={1}
|
||||
>
|
||||
{dialogMode === 'list' && (
|
||||
<Box paddingX={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Background tasks
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={dialogMode === 'list' ? 1 : 0}>
|
||||
{dialogMode === 'list' ? (
|
||||
<ListBody
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
maxRows={listMaxRows}
|
||||
/>
|
||||
) : selectedEntry ? (
|
||||
<DetailBody
|
||||
entry={selectedEntry}
|
||||
maxHeight={detailContentHeight}
|
||||
maxWidth={detailContentWidth}
|
||||
/>
|
||||
) : (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>No entry to show.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1} paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{hints.join(' \u00B7 ')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { BackgroundTaskEntry } from '@qwen-code/qwen-code-core';
|
||||
import { getPillLabel } from './BackgroundTasksPill.js';
|
||||
|
||||
function entry(overrides: Partial<BackgroundTaskEntry>): BackgroundTaskEntry {
|
||||
return {
|
||||
agentId: 'a',
|
||||
description: 'desc',
|
||||
status: 'running',
|
||||
startTime: 0,
|
||||
abortController: new AbortController(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getPillLabel', () => {
|
||||
it('uses singular form for one running agent', () => {
|
||||
expect(getPillLabel([entry({ agentId: 'a' })])).toBe('1 local agent');
|
||||
});
|
||||
|
||||
it('uses plural form for multiple running agents', () => {
|
||||
expect(
|
||||
getPillLabel([
|
||||
entry({ agentId: 'a' }),
|
||||
entry({ agentId: 'b' }),
|
||||
entry({ agentId: 'c' }),
|
||||
]),
|
||||
).toBe('3 local agents');
|
||||
});
|
||||
|
||||
it('counts only running entries when running and terminal mix', () => {
|
||||
expect(
|
||||
getPillLabel([
|
||||
entry({ agentId: 'a', status: 'running' }),
|
||||
entry({ agentId: 'b', status: 'completed' }),
|
||||
entry({ agentId: 'c', status: 'cancelled' }),
|
||||
]),
|
||||
).toBe('1 local agent');
|
||||
});
|
||||
|
||||
it('uses singular done form for one terminal-only entry', () => {
|
||||
expect(getPillLabel([entry({ agentId: 'a', status: 'completed' })])).toBe(
|
||||
'1 local agent done',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses plural done form when all entries are terminal', () => {
|
||||
expect(
|
||||
getPillLabel([
|
||||
entry({ agentId: 'a', status: 'completed' }),
|
||||
entry({ agentId: 'b', status: 'failed' }),
|
||||
]),
|
||||
).toBe('2 local agents done');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import {
|
||||
useBackgroundTaskViewState,
|
||||
useBackgroundTaskViewActions,
|
||||
} from '../../contexts/BackgroundTaskViewContext.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { BackgroundTaskEntry } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Pill label: counts running entries while any are running; once everything
|
||||
* has terminated, switches to a "done" form so the pill still invites
|
||||
* reopening the dialog to inspect final state.
|
||||
*/
|
||||
export function getPillLabel(entries: readonly BackgroundTaskEntry[]): string {
|
||||
const running = entries.filter((e) => e.status === 'running').length;
|
||||
if (running > 0) {
|
||||
return running === 1 ? '1 local agent' : `${running} local agents`;
|
||||
}
|
||||
return entries.length === 1
|
||||
? '1 local agent done'
|
||||
: `${entries.length} local agents done`;
|
||||
}
|
||||
|
||||
export const BackgroundTasksPill: React.FC = () => {
|
||||
const { entries, pillFocused } = useBackgroundTaskViewState();
|
||||
const { openDialog, setPillFocused } = useBackgroundTaskViewActions();
|
||||
|
||||
const onKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
if (key.name === 'return') {
|
||||
openDialog();
|
||||
} else if (key.name === 'up' || key.name === 'escape') {
|
||||
setPillFocused(false);
|
||||
} else if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.meta
|
||||
) {
|
||||
setPillFocused(false);
|
||||
}
|
||||
},
|
||||
[openDialog, setPillFocused],
|
||||
);
|
||||
|
||||
useKeypress(onKeypress, { isActive: pillFocused });
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const label = getPillLabel(entries);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
<Text inverse={pillFocused}>{label}</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -73,6 +73,10 @@ const getStatusText = (status: AgentResultDisplay['status']) => {
|
|||
}
|
||||
};
|
||||
|
||||
const BackgroundManageHint: React.FC = () => (
|
||||
<Text color={theme.text.secondary}> (↓ to manage)</Text>
|
||||
);
|
||||
|
||||
const MAX_TOOL_CALLS = 5;
|
||||
const MAX_TASK_PROMPT_LINES = 5;
|
||||
|
||||
|
|
@ -150,6 +154,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
|||
</Text>
|
||||
<StatusDot status={data.status} />
|
||||
<StatusIndicator status={data.status} />
|
||||
{data.status === 'background' && <BackgroundManageHint />}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
|
@ -231,6 +236,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
|||
</Text>
|
||||
<StatusDot status={data.status} />
|
||||
<StatusIndicator status={data.status} />
|
||||
{data.status === 'background' && <BackgroundManageHint />}
|
||||
</Box>
|
||||
|
||||
{/* Task description */}
|
||||
|
|
|
|||
214
packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx
Normal file
214
packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* BackgroundTaskViewContext — React state for the Background tasks
|
||||
* dialog. Subscription plumbing (registry callbacks → entries) lives in
|
||||
* `useBackgroundTaskView`, invoked once here so it owns the single-slot
|
||||
* `setStatusChangeCallback` for the TUI's lifetime.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
type BackgroundTaskEntry,
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useBackgroundTaskView } from '../hooks/useBackgroundTaskView.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export type BackgroundDialogMode = 'closed' | 'list' | 'detail';
|
||||
|
||||
export interface BackgroundTaskViewState {
|
||||
/** Live snapshot of every background agent entry, ordered by startTime. */
|
||||
entries: readonly BackgroundTaskEntry[];
|
||||
/** Index into `entries` for the currently focused row (0-based). */
|
||||
selectedIndex: number;
|
||||
/** `'closed'` when the overlay isn't mounted; otherwise the active mode. */
|
||||
dialogMode: BackgroundDialogMode;
|
||||
/** Convenience boolean: `dialogMode !== 'closed'`. */
|
||||
dialogOpen: boolean;
|
||||
/**
|
||||
* True when the footer pill owns keyboard focus (highlighted, awaiting
|
||||
* Enter to open the dialog). Mirrors the Arena tab-bar focus pattern.
|
||||
*/
|
||||
pillFocused: boolean;
|
||||
}
|
||||
|
||||
export interface BackgroundTaskViewActions {
|
||||
moveSelectionUp(): boolean;
|
||||
moveSelectionDown(): boolean;
|
||||
openDialog(): void;
|
||||
closeDialog(): void;
|
||||
enterDetail(): void;
|
||||
exitDetail(): void;
|
||||
/** Cancel the currently selected entry (no-op if not running). */
|
||||
cancelSelected(): void;
|
||||
setPillFocused(focused: boolean): void;
|
||||
}
|
||||
|
||||
// ─── Context ────────────────────────────────────────────────
|
||||
|
||||
export const BackgroundTaskViewStateContext =
|
||||
createContext<BackgroundTaskViewState | null>(null);
|
||||
export const BackgroundTaskViewActionsContext =
|
||||
createContext<BackgroundTaskViewActions | null>(null);
|
||||
|
||||
// ─── Defaults (used when no provider is mounted) ────────────
|
||||
|
||||
const DEFAULT_STATE: BackgroundTaskViewState = {
|
||||
entries: [],
|
||||
selectedIndex: 0,
|
||||
dialogMode: 'closed',
|
||||
dialogOpen: false,
|
||||
pillFocused: false,
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
const noopBool = () => false;
|
||||
|
||||
const DEFAULT_ACTIONS: BackgroundTaskViewActions = {
|
||||
moveSelectionUp: noopBool,
|
||||
moveSelectionDown: noopBool,
|
||||
openDialog: noop,
|
||||
closeDialog: noop,
|
||||
enterDetail: noop,
|
||||
exitDetail: noop,
|
||||
cancelSelected: noop,
|
||||
setPillFocused: noop,
|
||||
};
|
||||
|
||||
// ─── Hooks ──────────────────────────────────────────────────
|
||||
|
||||
export function useBackgroundTaskViewState(): BackgroundTaskViewState {
|
||||
return useContext(BackgroundTaskViewStateContext) ?? DEFAULT_STATE;
|
||||
}
|
||||
|
||||
export function useBackgroundTaskViewActions(): BackgroundTaskViewActions {
|
||||
return useContext(BackgroundTaskViewActionsContext) ?? DEFAULT_ACTIONS;
|
||||
}
|
||||
|
||||
// ─── Provider ───────────────────────────────────────────────
|
||||
|
||||
interface BackgroundTaskViewProviderProps {
|
||||
config?: Config;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BackgroundTaskViewProvider({
|
||||
config,
|
||||
children,
|
||||
}: BackgroundTaskViewProviderProps) {
|
||||
const { entries } = useBackgroundTaskView(config ?? null);
|
||||
|
||||
const [rawSelectedIndex, setRawSelectedIndex] = useState(0);
|
||||
const [dialogMode, setDialogMode] = useState<BackgroundDialogMode>('closed');
|
||||
const [pillFocused, setPillFocused] = useState(false);
|
||||
const dialogOpen = dialogMode !== 'closed';
|
||||
const hasEntries = entries.length > 0;
|
||||
|
||||
// Drop stale pill focus once the pill itself unmounts — i.e., when the
|
||||
// registry is empty. The pill stays rendered while terminal entries
|
||||
// exist (so the user can reopen the dialog post-termination), so we
|
||||
// intentionally do *not* drop focus on the running → terminal flip.
|
||||
useEffect(() => {
|
||||
if (pillFocused && !hasEntries) setPillFocused(false);
|
||||
}, [pillFocused, hasEntries]);
|
||||
|
||||
// rawSelectedIndex can fall out of range when entries shrink; clamp on read.
|
||||
const selectedIndex =
|
||||
entries.length === 0
|
||||
? 0
|
||||
: Math.min(Math.max(0, rawSelectedIndex), entries.length - 1);
|
||||
|
||||
const moveSelectionUp = useCallback((): boolean => {
|
||||
if (selectedIndex <= 0) return false;
|
||||
setRawSelectedIndex(selectedIndex - 1);
|
||||
return true;
|
||||
}, [selectedIndex]);
|
||||
|
||||
const moveSelectionDown = useCallback((): boolean => {
|
||||
if (entries.length === 0) return false;
|
||||
if (selectedIndex >= entries.length - 1) return false;
|
||||
setRawSelectedIndex(selectedIndex + 1);
|
||||
return true;
|
||||
}, [entries.length, selectedIndex]);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
setDialogMode('list');
|
||||
setPillFocused(false);
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogMode('closed');
|
||||
}, []);
|
||||
|
||||
const enterDetail = useCallback(() => {
|
||||
if (entries.length === 0) return;
|
||||
setDialogMode('detail');
|
||||
}, [entries.length]);
|
||||
|
||||
const exitDetail = useCallback(() => {
|
||||
setDialogMode('list');
|
||||
}, []);
|
||||
|
||||
const cancelSelected = useCallback(() => {
|
||||
if (!config) return;
|
||||
const target = entries[selectedIndex];
|
||||
if (!target) return;
|
||||
// cancel() is a no-op for non-running entries, so no pre-check here.
|
||||
config.getBackgroundTaskRegistry().cancel(target.agentId);
|
||||
}, [config, entries, selectedIndex]);
|
||||
|
||||
const state: BackgroundTaskViewState = useMemo(
|
||||
() => ({
|
||||
entries,
|
||||
selectedIndex,
|
||||
dialogMode,
|
||||
dialogOpen,
|
||||
pillFocused,
|
||||
}),
|
||||
[entries, selectedIndex, dialogMode, dialogOpen, pillFocused],
|
||||
);
|
||||
|
||||
const actions: BackgroundTaskViewActions = useMemo(
|
||||
() => ({
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
enterDetail,
|
||||
exitDetail,
|
||||
cancelSelected,
|
||||
setPillFocused,
|
||||
}),
|
||||
[
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
enterDetail,
|
||||
exitDetail,
|
||||
cancelSelected,
|
||||
setPillFocused,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<BackgroundTaskViewStateContext.Provider value={state}>
|
||||
<BackgroundTaskViewActionsContext.Provider value={actions}>
|
||||
{children}
|
||||
</BackgroundTaskViewActionsContext.Provider>
|
||||
</BackgroundTaskViewStateContext.Provider>
|
||||
);
|
||||
}
|
||||
55
packages/cli/src/ui/hooks/useBackgroundTaskView.ts
Normal file
55
packages/cli/src/ui/hooks/useBackgroundTaskView.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* useBackgroundTaskView — subscribes to the background task registry's
|
||||
* status-change callback and maintains a reactive snapshot of every
|
||||
* `BackgroundTaskEntry`, including terminal ones. Surfaces that only
|
||||
* care about live work (the footer pill, the composer's Down-arrow
|
||||
* route) filter for `running` themselves.
|
||||
*
|
||||
* Intentionally ignores activity updates (appendActivity). Tool-call
|
||||
* traffic from a running background agent would otherwise churn the
|
||||
* Footer pill and the AppContainer every few hundred ms. The detail
|
||||
* dialog subscribes to the activity callback directly when it needs
|
||||
* live Progress updates.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
type BackgroundTaskEntry,
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface UseBackgroundTaskViewResult {
|
||||
entries: readonly BackgroundTaskEntry[];
|
||||
}
|
||||
|
||||
export function useBackgroundTaskView(
|
||||
config: Config | null,
|
||||
): UseBackgroundTaskViewResult {
|
||||
const [entries, setEntries] = useState<BackgroundTaskEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
const registry = config.getBackgroundTaskRegistry();
|
||||
|
||||
// getAll() returns a fresh array in registration (= startTime) order.
|
||||
setEntries(registry.getAll());
|
||||
|
||||
const onStatusChange = () => {
|
||||
setEntries(registry.getAll());
|
||||
};
|
||||
|
||||
registry.setStatusChangeCallback(onStatusChange);
|
||||
|
||||
return () => {
|
||||
registry.setStatusChangeCallback(undefined);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
return { entries };
|
||||
}
|
||||
|
|
@ -57,6 +57,10 @@ export interface DialogCloseOptions {
|
|||
// Welcome back dialog
|
||||
showWelcomeBackDialog: boolean;
|
||||
handleWelcomeBackClose: () => void;
|
||||
|
||||
// Background tasks dialog
|
||||
isBackgroundTasksDialogOpen: boolean;
|
||||
closeBackgroundTasksDialog: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -114,6 +118,14 @@ export function useDialogClose(options: DialogCloseOptions) {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (options.isBackgroundTasksDialogOpen) {
|
||||
// Background tasks dialog — routed through closeAnyOpenDialog so
|
||||
// Ctrl+C and the global escape path dismiss it without escalating
|
||||
// to exit prompts.
|
||||
options.closeBackgroundTasksDialog();
|
||||
return true;
|
||||
}
|
||||
|
||||
// No dialog was open
|
||||
return false;
|
||||
}, [options]);
|
||||
|
|
|
|||
|
|
@ -21,34 +21,64 @@ vi.mock('../../core/contentGenerator.js', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// Mock AgentCore and AgentInteractive to avoid real model calls
|
||||
// Mock AgentCore and AgentInteractive to avoid real model calls.
|
||||
// The mock must also expose the observable-state accessors that
|
||||
// AgentInteractive now delegates to (getMessages, pendingApprovals,
|
||||
// liveOutputs, shellPids, pushMessage, etc.) — otherwise agent lifecycle
|
||||
// methods like abort() / addMessage() fail on missing prototype methods.
|
||||
vi.mock('../runtime/agent-core.js', () => ({
|
||||
AgentCore: vi.fn().mockImplementation(() => ({
|
||||
subagentId: 'mock-id',
|
||||
name: 'mock-agent',
|
||||
eventEmitter: {
|
||||
AgentCore: vi.fn().mockImplementation(() => {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const pendingApprovals = new Map<string, unknown>();
|
||||
const liveOutputs = new Map<string, unknown>();
|
||||
const shellPids = new Map<string, number>();
|
||||
const emitter = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
stats: {
|
||||
start: vi.fn(),
|
||||
getSummary: vi.fn().mockReturnValue({}),
|
||||
},
|
||||
createChat: vi.fn().mockResolvedValue({}),
|
||||
prepareTools: vi.fn().mockReturnValue([]),
|
||||
runReasoningLoop: vi.fn().mockResolvedValue({
|
||||
text: 'Done',
|
||||
terminateMode: null,
|
||||
turnsUsed: 1,
|
||||
}),
|
||||
getEventEmitter: vi.fn().mockReturnValue({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
}),
|
||||
getExecutionSummary: vi.fn().mockReturnValue({}),
|
||||
})),
|
||||
};
|
||||
return {
|
||||
subagentId: 'mock-id',
|
||||
name: 'mock-agent',
|
||||
eventEmitter: emitter,
|
||||
stats: {
|
||||
start: vi.fn(),
|
||||
getSummary: vi.fn().mockReturnValue({}),
|
||||
},
|
||||
createChat: vi.fn().mockResolvedValue({}),
|
||||
prepareTools: vi.fn().mockReturnValue([]),
|
||||
runReasoningLoop: vi.fn().mockResolvedValue({
|
||||
text: 'Done',
|
||||
terminateMode: null,
|
||||
turnsUsed: 1,
|
||||
}),
|
||||
getEventEmitter: vi.fn().mockReturnValue(emitter),
|
||||
getExecutionSummary: vi.fn().mockReturnValue({}),
|
||||
getMessages: () => messages,
|
||||
getPendingApprovals: () => pendingApprovals,
|
||||
getLiveOutputs: () => liveOutputs,
|
||||
getShellPids: () => shellPids,
|
||||
pushMessage: (
|
||||
role: string,
|
||||
content: string,
|
||||
options?: { thought?: boolean; metadata?: Record<string, unknown> },
|
||||
) => {
|
||||
const message: Record<string, unknown> = {
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
if (options?.thought) message['thought'] = true;
|
||||
if (options?.metadata) message['metadata'] = options.metadata;
|
||||
messages.push(message);
|
||||
},
|
||||
setPendingApproval: (callId: string, details: unknown) =>
|
||||
pendingApprovals.set(callId, details),
|
||||
deletePendingApproval: (callId: string) =>
|
||||
pendingApprovals.delete(callId),
|
||||
clearPendingApprovals: () => pendingApprovals.clear(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
function createMockToolRegistry() {
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ describe('BackgroundTaskRegistry', () => {
|
|||
|
||||
registry.complete('a', 'done');
|
||||
|
||||
const running = registry.getRunning();
|
||||
const running = registry.getAll().filter((e) => e.status === 'running');
|
||||
expect(running).toHaveLength(1);
|
||||
expect(running[0].agentId).toBe('b');
|
||||
});
|
||||
|
|
@ -457,6 +457,190 @@ describe('BackgroundTaskRegistry', () => {
|
|||
expect(meta.toolUseId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getAll returns every entry regardless of status', () => {
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
registry.register({
|
||||
agentId: 'b',
|
||||
description: 'agent b',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
registry.register({
|
||||
agentId: 'c',
|
||||
description: 'agent c',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
registry.complete('a', 'done');
|
||||
registry.fail('b', 'boom');
|
||||
|
||||
const all = registry.getAll();
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all.map((e) => e.status).sort()).toEqual([
|
||||
'completed',
|
||||
'failed',
|
||||
'running',
|
||||
]);
|
||||
// Callers that need only running entries filter getAll() themselves.
|
||||
expect(
|
||||
registry
|
||||
.getAll()
|
||||
.filter((e) => e.status === 'running')
|
||||
.map((e) => e.agentId),
|
||||
).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('statusChange callback fires on register and every state transition', () => {
|
||||
const seen: Array<{ id: string; status: string }> = [];
|
||||
registry.setStatusChangeCallback((entry) => {
|
||||
seen.push({ id: entry.agentId, status: entry.status });
|
||||
});
|
||||
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
registry.register({
|
||||
agentId: 'b',
|
||||
description: 'agent b',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
registry.complete('a', 'ok');
|
||||
registry.fail('b', 'err');
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ id: 'a', status: 'running' },
|
||||
{ id: 'b', status: 'running' },
|
||||
{ id: 'a', status: 'completed' },
|
||||
{ id: 'b', status: 'failed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('statusChange callback errors do not break registry operations', () => {
|
||||
registry.setStatusChangeCallback(() => {
|
||||
throw new Error('listener broke');
|
||||
});
|
||||
|
||||
// Should not throw even though the callback does.
|
||||
expect(() =>
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(registry.get('a')?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('statusChange callback can be cleared with undefined', () => {
|
||||
const cb = vi.fn();
|
||||
registry.setStatusChangeCallback(cb);
|
||||
registry.setStatusChangeCallback(undefined);
|
||||
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('appendActivity builds a rolling buffer capped at 5', () => {
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
registry.appendActivity('a', {
|
||||
name: `Tool${i}`,
|
||||
description: `call ${i}`,
|
||||
at: i,
|
||||
});
|
||||
}
|
||||
|
||||
const activities = registry.get('a')!.recentActivities ?? [];
|
||||
expect(activities.map((a) => a.name)).toEqual([
|
||||
'Tool2',
|
||||
'Tool3',
|
||||
'Tool4',
|
||||
'Tool5',
|
||||
'Tool6',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appendActivity no-ops after the agent terminates', () => {
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
registry.complete('a', 'done');
|
||||
registry.appendActivity('a', { name: 'Late', description: 'x', at: 99 });
|
||||
|
||||
expect(registry.get('a')!.recentActivities ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('appendActivity fires activityChange, not statusChange', () => {
|
||||
const statusCb = vi.fn();
|
||||
const activityCb = vi.fn();
|
||||
registry.setStatusChangeCallback(statusCb);
|
||||
registry.setActivityChangeCallback(activityCb);
|
||||
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
statusCb.mockClear();
|
||||
activityCb.mockClear();
|
||||
|
||||
registry.appendActivity('a', { name: 'T', description: 'd', at: 0 });
|
||||
|
||||
expect(statusCb).not.toHaveBeenCalled();
|
||||
expect(activityCb).toHaveBeenCalledOnce();
|
||||
expect(activityCb.mock.calls[0][0].agentId).toBe('a');
|
||||
});
|
||||
|
||||
it('stores prompt verbatim on the entry', () => {
|
||||
registry.register({
|
||||
agentId: 'a',
|
||||
description: 'agent a',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
abortController: new AbortController(),
|
||||
prompt: 'Run sleep 30 and report done.',
|
||||
});
|
||||
expect(registry.get('a')!.prompt).toBe('Run sleep 30 and report done.');
|
||||
});
|
||||
|
||||
it('escapes XML metacharacters in interpolated fields', () => {
|
||||
const callback = vi.fn();
|
||||
registry.setNotificationCallback(callback);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { createDebugLogger } from '../utils/debugLogger.js';
|
|||
const debugLogger = createDebugLogger('BACKGROUND_TASKS');
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 40;
|
||||
const MAX_RECENT_ACTIVITIES = 5;
|
||||
|
||||
// Grace period after cancel() before emitting a fallback cancelled
|
||||
// notification. The natural handler (bgBody) almost always settles and
|
||||
|
|
@ -27,6 +28,36 @@ const MAX_DESCRIPTION_LENGTH = 40;
|
|||
// doesn't feel hung.
|
||||
const CANCEL_GRACE_MS = 5000;
|
||||
|
||||
/**
|
||||
* Single source of truth for the human-facing label of a background
|
||||
* entry. Shared by the notification payload (model-facing) and the TUI
|
||||
* dialog (user-facing) so the two surfaces never drift.
|
||||
*
|
||||
* When `includePrefix` is true (default), returns `subagentType: desc`;
|
||||
* when false, returns the bare truncated description — used where the
|
||||
* subagent type is already rendered separately (e.g. the dialog header).
|
||||
*/
|
||||
export function buildBackgroundEntryLabel(
|
||||
entry: { description: string; subagentType?: string },
|
||||
options: { includePrefix?: boolean } = {},
|
||||
): string {
|
||||
const { includePrefix = true } = options;
|
||||
let raw = entry.description;
|
||||
if (
|
||||
entry.subagentType &&
|
||||
raw.toLowerCase().startsWith(entry.subagentType.toLowerCase() + ':')
|
||||
) {
|
||||
raw = raw.slice(entry.subagentType.length + 1).trimStart();
|
||||
}
|
||||
const truncated =
|
||||
raw.length > MAX_DESCRIPTION_LENGTH
|
||||
? raw.slice(0, MAX_DESCRIPTION_LENGTH - 1) + '\u2026'
|
||||
: raw;
|
||||
return includePrefix && entry.subagentType
|
||||
? `${entry.subagentType}: ${truncated}`
|
||||
: truncated;
|
||||
}
|
||||
|
||||
// Escape text so it is safe to interpolate into an XML element body.
|
||||
// Subagent-produced strings (description, result, error) can contain `<`,
|
||||
// `>`, or literal `</task-notification>` — without escaping, a subagent
|
||||
|
|
@ -40,7 +71,7 @@ function escapeXml(text: string): string {
|
|||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export type BackgroundAgentStatus =
|
||||
export type BackgroundTaskStatus =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
|
|
@ -52,11 +83,26 @@ export interface AgentCompletionStats {
|
|||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface BackgroundAgentEntry {
|
||||
/**
|
||||
* A compact record of a recent tool invocation — drives the Progress
|
||||
* section of the detail dialog. The Agent tool maintains a rolling
|
||||
* buffer of these on each background entry by subscribing to the
|
||||
* subagent's event emitter.
|
||||
*/
|
||||
export interface BackgroundActivity {
|
||||
/** Tool name (e.g. `Bash`, `Read`). */
|
||||
name: string;
|
||||
/** Short one-line description — the tool's own render-friendly summary. */
|
||||
description: string;
|
||||
/** Emission timestamp (ms). */
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface BackgroundTaskEntry {
|
||||
agentId: string;
|
||||
description: string;
|
||||
subagentType?: string;
|
||||
status: BackgroundAgentStatus;
|
||||
status: BackgroundTaskStatus;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
result?: string;
|
||||
|
|
@ -64,7 +110,22 @@ export interface BackgroundAgentEntry {
|
|||
abortController: AbortController;
|
||||
stats?: AgentCompletionStats;
|
||||
toolUseId?: string;
|
||||
/** Absolute path to the agent's plain-text transcript file. */
|
||||
/**
|
||||
* The original user-supplied prompt for the background task. Surfaced
|
||||
* verbatim in the detail dialog's Prompt section. Optional because
|
||||
* resume-restored entries may not have it.
|
||||
*/
|
||||
prompt?: string;
|
||||
/**
|
||||
* Rolling buffer (newest last, capped at MAX_RECENT_ACTIVITIES) of
|
||||
* recent tool invocations by this agent. Feeds the detail dialog's
|
||||
* Progress section. Replaced as a new array each time an activity is
|
||||
* appended so reference-based change detection works. Optional:
|
||||
* callers may register without providing it, and `appendActivity`
|
||||
* initializes the array lazily.
|
||||
*/
|
||||
recentActivities?: readonly BackgroundActivity[];
|
||||
/** Absolute path to the agent's on-disk JSONL transcript file. */
|
||||
outputFile?: string;
|
||||
/** Messages queued by SendMessage, drained between tool rounds. */
|
||||
pendingMessages?: string[];
|
||||
|
|
@ -79,7 +140,7 @@ export interface BackgroundAgentEntry {
|
|||
|
||||
export interface NotificationMeta {
|
||||
agentId: string;
|
||||
status: BackgroundAgentStatus;
|
||||
status: BackgroundTaskStatus;
|
||||
stats?: AgentCompletionStats;
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
|
@ -90,14 +151,31 @@ export type BackgroundNotificationCallback = (
|
|||
meta: NotificationMeta,
|
||||
) => void;
|
||||
|
||||
export type BackgroundRegisterCallback = (entry: BackgroundAgentEntry) => void;
|
||||
export type BackgroundRegisterCallback = (entry: BackgroundTaskEntry) => void;
|
||||
|
||||
/**
|
||||
* Fires on entry status transitions — register, complete, fail, cancel.
|
||||
* Intentionally does NOT fire on `appendActivity` so consumers that only
|
||||
* care about the pill / roster (Footer, AppContainer) don't re-render
|
||||
* on every tool call a background agent makes.
|
||||
*/
|
||||
export type BackgroundStatusChangeCallback = (
|
||||
entry: BackgroundTaskEntry,
|
||||
) => void;
|
||||
|
||||
/** Fires on `appendActivity` — scoped to detail-view consumers. */
|
||||
export type BackgroundActivityChangeCallback = (
|
||||
entry: BackgroundTaskEntry,
|
||||
) => void;
|
||||
|
||||
export class BackgroundTaskRegistry {
|
||||
private readonly agents = new Map<string, BackgroundAgentEntry>();
|
||||
private readonly agents = new Map<string, BackgroundTaskEntry>();
|
||||
private notificationCallback?: BackgroundNotificationCallback;
|
||||
private registerCallback?: BackgroundRegisterCallback;
|
||||
private statusChangeCallback?: BackgroundStatusChangeCallback;
|
||||
private activityChangeCallback?: BackgroundActivityChangeCallback;
|
||||
|
||||
register(entry: BackgroundAgentEntry): void {
|
||||
register(entry: BackgroundTaskEntry): void {
|
||||
if (!entry.pendingMessages) entry.pendingMessages = [];
|
||||
this.agents.set(entry.agentId, entry);
|
||||
debugLogger.info(`Registered background agent: ${entry.agentId}`);
|
||||
|
|
@ -109,6 +187,7 @@ export class BackgroundTaskRegistry {
|
|||
debugLogger.error('Failed to emit register callback:', error);
|
||||
}
|
||||
}
|
||||
this.emitStatusChange(entry);
|
||||
}
|
||||
|
||||
// Transition a still-running entry to 'completed' and emit the terminal
|
||||
|
|
@ -136,6 +215,7 @@ export class BackgroundTaskRegistry {
|
|||
debugLogger.info(`Background agent completed: ${agentId}`);
|
||||
|
||||
this.emitNotification(entry);
|
||||
this.emitStatusChange(entry);
|
||||
}
|
||||
|
||||
// See complete() for the cancelled → terminal path rationale.
|
||||
|
|
@ -152,6 +232,7 @@ export class BackgroundTaskRegistry {
|
|||
debugLogger.info(`Background agent failed: ${agentId}`);
|
||||
|
||||
this.emitNotification(entry);
|
||||
this.emitStatusChange(entry);
|
||||
}
|
||||
|
||||
// Cancellation aborts the signal and marks the entry as cancelled, but
|
||||
|
|
@ -170,6 +251,7 @@ export class BackgroundTaskRegistry {
|
|||
entry.status = 'cancelled';
|
||||
entry.endTime = Date.now();
|
||||
debugLogger.info(`Background agent cancelled: ${agentId}`);
|
||||
this.emitStatusChange(entry);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.finalizeCancellationIfPending(agentId);
|
||||
|
|
@ -197,6 +279,7 @@ export class BackgroundTaskRegistry {
|
|||
if (partialResult) entry.result = partialResult;
|
||||
entry.stats = stats;
|
||||
this.emitNotification(entry);
|
||||
this.emitStatusChange(entry);
|
||||
}
|
||||
|
||||
// Emit the terminal cancelled notification for entries that were cancelled
|
||||
|
|
@ -208,16 +291,40 @@ export class BackgroundTaskRegistry {
|
|||
const entry = this.agents.get(agentId);
|
||||
if (!entry || entry.status !== 'cancelled' || entry.notified) return;
|
||||
this.emitNotification(entry);
|
||||
this.emitStatusChange(entry);
|
||||
}
|
||||
|
||||
get(agentId: string): BackgroundAgentEntry | undefined {
|
||||
/**
|
||||
* Append a recent tool activity to a running entry's rolling buffer.
|
||||
* No-op if the entry is not running — late events after a cancellation
|
||||
* shouldn't leak into the Progress section.
|
||||
*/
|
||||
appendActivity(agentId: string, activity: BackgroundActivity): void {
|
||||
const entry = this.agents.get(agentId);
|
||||
if (!entry || entry.status !== 'running') return;
|
||||
|
||||
const prior = entry.recentActivities ?? [];
|
||||
const next = [...prior, activity];
|
||||
if (next.length > MAX_RECENT_ACTIVITIES) {
|
||||
next.splice(0, next.length - MAX_RECENT_ACTIVITIES);
|
||||
}
|
||||
entry.recentActivities = next;
|
||||
this.emitActivityChange(entry);
|
||||
}
|
||||
|
||||
get(agentId: string): BackgroundTaskEntry | undefined {
|
||||
return this.agents.get(agentId);
|
||||
}
|
||||
|
||||
getRunning(): BackgroundAgentEntry[] {
|
||||
return Array.from(this.agents.values()).filter(
|
||||
(e) => e.status === 'running',
|
||||
);
|
||||
/**
|
||||
* Snapshot of every entry regardless of status. Used by the TUI
|
||||
* footer/dialog to render rows for still-running AND terminal-state
|
||||
* tasks; the headless holdback loop keys off `hasUnfinalizedTasks`
|
||||
* instead, so callers that only need the running slice can filter
|
||||
* this snapshot at the call site.
|
||||
*/
|
||||
getAll(): BackgroundTaskEntry[] {
|
||||
return Array.from(this.agents.values());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -274,6 +381,18 @@ export class BackgroundTaskRegistry {
|
|||
this.registerCallback = cb;
|
||||
}
|
||||
|
||||
setStatusChangeCallback(
|
||||
cb: BackgroundStatusChangeCallback | undefined,
|
||||
): void {
|
||||
this.statusChangeCallback = cb;
|
||||
}
|
||||
|
||||
setActivityChangeCallback(
|
||||
cb: BackgroundActivityChangeCallback | undefined,
|
||||
): void {
|
||||
this.activityChangeCallback = cb;
|
||||
}
|
||||
|
||||
abortAll(): void {
|
||||
for (const entry of Array.from(this.agents.values())) {
|
||||
this.cancel(entry.agentId);
|
||||
|
|
@ -284,24 +403,11 @@ export class BackgroundTaskRegistry {
|
|||
debugLogger.info('Aborted all background agents');
|
||||
}
|
||||
|
||||
private buildDisplayLabel(entry: BackgroundAgentEntry): string {
|
||||
// Strip the subagent type prefix if the description already starts with it
|
||||
// to avoid duplication like "Explore: Explore: list ts files".
|
||||
let rawDesc = entry.description;
|
||||
if (
|
||||
entry.subagentType &&
|
||||
rawDesc.toLowerCase().startsWith(entry.subagentType.toLowerCase() + ':')
|
||||
) {
|
||||
rawDesc = rawDesc.slice(entry.subagentType.length + 1).trimStart();
|
||||
}
|
||||
const desc =
|
||||
rawDesc.length > MAX_DESCRIPTION_LENGTH
|
||||
? rawDesc.slice(0, MAX_DESCRIPTION_LENGTH) + '...'
|
||||
: rawDesc;
|
||||
return entry.subagentType ? `${entry.subagentType}: ${desc}` : desc;
|
||||
private buildDisplayLabel(entry: BackgroundTaskEntry): string {
|
||||
return buildBackgroundEntryLabel(entry);
|
||||
}
|
||||
|
||||
private emitNotification(entry: BackgroundAgentEntry): void {
|
||||
private emitNotification(entry: BackgroundTaskEntry): void {
|
||||
// Mark notified *before* invoking the callback so that a re-entrant
|
||||
// terminal call inside the callback chain (cancel → complete race)
|
||||
// sees the flag and short-circuits, rather than firing twice.
|
||||
|
|
@ -366,4 +472,22 @@ export class BackgroundTaskRegistry {
|
|||
debugLogger.error('Failed to emit background notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private emitStatusChange(entry: BackgroundTaskEntry): void {
|
||||
if (!this.statusChangeCallback) return;
|
||||
try {
|
||||
this.statusChangeCallback(entry);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to emit background status change:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private emitActivityChange(entry: BackgroundTaskEntry): void {
|
||||
if (!this.activityChangeCallback) return;
|
||||
try {
|
||||
this.activityChangeCallback(entry);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to emit background activity change:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../tools/tools.js';
|
||||
import { getInitialChatHistory } from '../../utils/environmentContext.js';
|
||||
import { FinishReason } from '@google/genai';
|
||||
|
|
@ -46,6 +47,7 @@ import type {
|
|||
ModelConfig,
|
||||
RunConfig,
|
||||
ToolConfig,
|
||||
AgentMessage,
|
||||
} from './agent-types.js';
|
||||
import { AgentTerminateMode } from './agent-types.js';
|
||||
import type {
|
||||
|
|
@ -56,8 +58,9 @@ import type {
|
|||
AgentToolOutputUpdateEvent,
|
||||
AgentUsageEvent,
|
||||
AgentHooks,
|
||||
AgentExternalMessageEvent,
|
||||
} from './agent-events.js';
|
||||
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
|
||||
import { AgentEventEmitter, AgentEventType } from './agent-events.js';
|
||||
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
|
||||
import { matchesMcpPattern } from '../../permissions/rule-parser.js';
|
||||
import { ToolNames } from '../../tools/tool-names.js';
|
||||
|
|
@ -172,10 +175,26 @@ export class AgentCore {
|
|||
readonly modelConfig: ModelConfig;
|
||||
readonly runConfig: RunConfig;
|
||||
readonly toolConfig?: ToolConfig;
|
||||
readonly eventEmitter?: AgentEventEmitter;
|
||||
/**
|
||||
* Event emitter for this agent. Always present — if the caller doesn't
|
||||
* pass one, AgentCore allocates its own so the observable state below
|
||||
* is populated regardless of who constructs the agent.
|
||||
*/
|
||||
readonly eventEmitter: AgentEventEmitter;
|
||||
readonly hooks?: AgentHooks;
|
||||
readonly stats = new AgentStatistics();
|
||||
|
||||
// Observable state lives on Core (not a wrapper) so headless and
|
||||
// background agents can be observed with the same accessors as
|
||||
// interactive ones. Populated by listeners set up in the constructor.
|
||||
private readonly messages: AgentMessage[] = [];
|
||||
private readonly pendingApprovals = new Map<
|
||||
string,
|
||||
ToolCallConfirmationDetails
|
||||
>();
|
||||
private readonly liveOutputs = new Map<string, ToolResultDisplay>();
|
||||
private readonly shellPids = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Legacy execution stats maintained for aggregate tracking.
|
||||
*/
|
||||
|
|
@ -226,8 +245,9 @@ export class AgentCore {
|
|||
this.modelConfig = modelConfig;
|
||||
this.runConfig = runConfig;
|
||||
this.toolConfig = toolConfig;
|
||||
this.eventEmitter = eventEmitter;
|
||||
this.eventEmitter = eventEmitter ?? new AgentEventEmitter();
|
||||
this.hooks = hooks;
|
||||
this.setupStateListeners();
|
||||
}
|
||||
|
||||
// ─── Chat Creation ────────────────────────────────────────
|
||||
|
|
@ -1000,9 +1020,67 @@ export class AgentCore {
|
|||
return [{ role: 'user', parts: toolResponseParts }];
|
||||
}
|
||||
|
||||
// ─── Observable state accessors ────────────────────────────
|
||||
|
||||
getMessages(): readonly AgentMessage[] {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool calls currently awaiting user approval. Mutated by
|
||||
* AgentInteractive's TOOL_WAITING_APPROVAL handler; headless agents
|
||||
* never populate this because they run with
|
||||
* `getShouldAvoidPermissionPrompts === true`.
|
||||
*/
|
||||
getPendingApprovals(): ReadonlyMap<string, ToolCallConfirmationDetails> {
|
||||
return this.pendingApprovals;
|
||||
}
|
||||
|
||||
getLiveOutputs(): ReadonlyMap<string, ToolResultDisplay> {
|
||||
return this.liveOutputs;
|
||||
}
|
||||
|
||||
getShellPids(): ReadonlyMap<string, number> {
|
||||
return this.shellPids;
|
||||
}
|
||||
|
||||
pushMessage(
|
||||
role: AgentMessage['role'],
|
||||
content: string,
|
||||
options?: { thought?: boolean; metadata?: Record<string, unknown> },
|
||||
): void {
|
||||
const message: AgentMessage = {
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
if (options?.thought) {
|
||||
message.thought = true;
|
||||
}
|
||||
if (options?.metadata) {
|
||||
message.metadata = options.metadata;
|
||||
}
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
setPendingApproval(
|
||||
callId: string,
|
||||
details: ToolCallConfirmationDetails,
|
||||
): void {
|
||||
this.pendingApprovals.set(callId, details);
|
||||
}
|
||||
|
||||
deletePendingApproval(callId: string): void {
|
||||
this.pendingApprovals.delete(callId);
|
||||
}
|
||||
|
||||
clearPendingApprovals(): void {
|
||||
this.pendingApprovals.clear();
|
||||
}
|
||||
|
||||
// ─── Stats & Events ───────────────────────────────────────
|
||||
|
||||
getEventEmitter(): AgentEventEmitter | undefined {
|
||||
getEventEmitter(): AgentEventEmitter {
|
||||
return this.eventEmitter;
|
||||
}
|
||||
|
||||
|
|
@ -1115,6 +1193,81 @@ export class AgentCore {
|
|||
|
||||
// ─── Private Helpers ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TOOL_WAITING_APPROVAL is deliberately NOT listened to here because
|
||||
* the correct response depends on whether the consumer is interactive
|
||||
* (needs to wrap onConfirm with cancel-round behavior) or headless
|
||||
* (approvals never fire). AgentInteractive owns that listener and
|
||||
* writes into `pendingApprovals` via the public mutator API.
|
||||
*/
|
||||
private setupStateListeners(): void {
|
||||
const emitter = this.eventEmitter;
|
||||
|
||||
emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => {
|
||||
if (event.thoughtText) {
|
||||
this.pushMessage('assistant', event.thoughtText, { thought: true });
|
||||
}
|
||||
if (event.text) {
|
||||
this.pushMessage('assistant', event.text);
|
||||
}
|
||||
});
|
||||
|
||||
emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => {
|
||||
this.pushMessage('tool_call', `Tool call: ${event.name}`, {
|
||||
metadata: {
|
||||
callId: event.callId,
|
||||
toolName: event.name,
|
||||
args: event.args,
|
||||
description: event.description,
|
||||
renderOutputAsMarkdown: event.isOutputMarkdown,
|
||||
round: event.round,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
emitter.on(
|
||||
AgentEventType.TOOL_OUTPUT_UPDATE,
|
||||
(event: AgentToolOutputUpdateEvent) => {
|
||||
this.liveOutputs.set(event.callId, event.outputChunk);
|
||||
if (event.pid !== undefined) {
|
||||
this.shellPids.set(event.callId, event.pid);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => {
|
||||
this.liveOutputs.delete(event.callId);
|
||||
this.shellPids.delete(event.callId);
|
||||
this.pendingApprovals.delete(event.callId);
|
||||
|
||||
const statusText = event.success ? 'succeeded' : 'failed';
|
||||
const summary = event.error
|
||||
? `Tool ${event.name} ${statusText}: ${event.error}`
|
||||
: `Tool ${event.name} ${statusText}`;
|
||||
this.pushMessage('tool_result', summary, {
|
||||
metadata: {
|
||||
callId: event.callId,
|
||||
toolName: event.name,
|
||||
success: event.success,
|
||||
resultDisplay: event.resultDisplay,
|
||||
outputFile: event.outputFile,
|
||||
round: event.round,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mirror send_message injections into the observable message stream so
|
||||
// the TUI detail dialog shows parent→child messages alongside what the
|
||||
// JSONL transcript records. The framing prefix is stripped — that's a
|
||||
// model-facing detail, not what the user wants to see in the dialog.
|
||||
emitter.on(
|
||||
AgentEventType.EXTERNAL_MESSAGE,
|
||||
(event: AgentExternalMessageEvent) => {
|
||||
this.pushMessage('user', event.text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the system prompt with template substitution and optional
|
||||
* non-interactive instructions suffix.
|
||||
|
|
|
|||
|
|
@ -194,6 +194,16 @@ export class AgentHeadless {
|
|||
context: ContextState,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
// Record the initial user turn in the observable message log before
|
||||
// anything that can throw — createChat / prepareTools failures still
|
||||
// get a transcript showing the task that was asked, which is what
|
||||
// the background-agent detail view reads via AgentCore.getMessages().
|
||||
// Mirrors AgentInteractive's run loop.
|
||||
const initialTaskText = String(
|
||||
(context.get('task_prompt') as string) ?? 'Get Started!',
|
||||
);
|
||||
this.core.pushMessage('user', initialTaskText);
|
||||
|
||||
const chat = await this.core.createChat(context);
|
||||
|
||||
if (!chat) {
|
||||
|
|
@ -215,9 +225,6 @@ export class AgentHeadless {
|
|||
|
||||
const toolsList = await this.core.prepareTools();
|
||||
|
||||
const initialTaskText = String(
|
||||
(context.get('task_prompt') as string) ?? 'Get Started!',
|
||||
);
|
||||
const initialMessages = [
|
||||
{ role: 'user' as const, parts: [{ text: initialTaskText }] },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { AgentInteractive } from './agent-interactive.js';
|
||||
import type { AgentCore } from './agent-core.js';
|
||||
import { AgentEventEmitter, AgentEventType } from './agent-events.js';
|
||||
import type {
|
||||
AgentRoundTextEvent,
|
||||
AgentToolCallEvent,
|
||||
AgentToolResultEvent,
|
||||
AgentToolOutputUpdateEvent,
|
||||
} from './agent-events.js';
|
||||
import { ContextState } from './agent-headless.js';
|
||||
import type { AgentInteractiveConfig } from './agent-types.js';
|
||||
import { AgentStatus } from './agent-types.js';
|
||||
|
|
@ -31,6 +37,15 @@ function createMockCore(
|
|||
: overrides.chatValue !== undefined
|
||||
? overrides.chatValue
|
||||
: createMockChat();
|
||||
|
||||
// Simulate the observable state that the real AgentCore now owns.
|
||||
// AgentInteractive delegates its state accessors to these, so the mock
|
||||
// needs to reflect mutations made via `pushMessage` / approval helpers.
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const pendingApprovals = new Map<string, unknown>();
|
||||
const liveOutputs = new Map<string, unknown>();
|
||||
const shellPids = new Map<string, number>();
|
||||
|
||||
const core = {
|
||||
subagentId: 'test-agent-abc123',
|
||||
name: 'test-agent',
|
||||
|
|
@ -71,8 +86,95 @@ function createMockCore(
|
|||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
}),
|
||||
// Observable state surface (mirrors real AgentCore API).
|
||||
getMessages: () => messages,
|
||||
getPendingApprovals: () => pendingApprovals,
|
||||
getLiveOutputs: () => liveOutputs,
|
||||
getShellPids: () => shellPids,
|
||||
pushMessage: (
|
||||
role: string,
|
||||
content: string,
|
||||
options?: { thought?: boolean; metadata?: Record<string, unknown> },
|
||||
) => {
|
||||
const message: Record<string, unknown> = {
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
if (options?.thought) message['thought'] = true;
|
||||
if (options?.metadata) message['metadata'] = options.metadata;
|
||||
messages.push(message);
|
||||
},
|
||||
setPendingApproval: (callId: string, details: unknown) =>
|
||||
pendingApprovals.set(callId, details),
|
||||
deletePendingApproval: (callId: string) => pendingApprovals.delete(callId),
|
||||
clearPendingApprovals: () => pendingApprovals.clear(),
|
||||
} as unknown as AgentCore;
|
||||
|
||||
// Mirror AgentCore.setupStateListeners: events on the shared emitter
|
||||
// populate the state containers above. The real AgentCore wires this in
|
||||
// its constructor; the mock does it here so tests that drive behavior
|
||||
// by emitting events observe the same resulting state.
|
||||
emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => {
|
||||
if (event.thoughtText) {
|
||||
(core.pushMessage as (...args: unknown[]) => void)(
|
||||
'assistant',
|
||||
event.thoughtText,
|
||||
{ thought: true },
|
||||
);
|
||||
}
|
||||
if (event.text) {
|
||||
(core.pushMessage as (...args: unknown[]) => void)(
|
||||
'assistant',
|
||||
event.text,
|
||||
);
|
||||
}
|
||||
});
|
||||
emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => {
|
||||
(core.pushMessage as (...args: unknown[]) => void)(
|
||||
'tool_call',
|
||||
`Tool call: ${event.name}`,
|
||||
{
|
||||
metadata: {
|
||||
callId: event.callId,
|
||||
toolName: event.name,
|
||||
args: event.args,
|
||||
description: event.description,
|
||||
renderOutputAsMarkdown: event.isOutputMarkdown,
|
||||
round: event.round,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
emitter.on(
|
||||
AgentEventType.TOOL_OUTPUT_UPDATE,
|
||||
(event: AgentToolOutputUpdateEvent) => {
|
||||
liveOutputs.set(event.callId, event.outputChunk);
|
||||
if (event.pid !== undefined) {
|
||||
shellPids.set(event.callId, event.pid);
|
||||
}
|
||||
},
|
||||
);
|
||||
emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => {
|
||||
liveOutputs.delete(event.callId);
|
||||
shellPids.delete(event.callId);
|
||||
pendingApprovals.delete(event.callId);
|
||||
const statusText = event.success ? 'succeeded' : 'failed';
|
||||
const summary = event.error
|
||||
? `Tool ${event.name} ${statusText}: ${event.error}`
|
||||
: `Tool ${event.name} ${statusText}`;
|
||||
(core.pushMessage as (...args: unknown[]) => void)('tool_result', summary, {
|
||||
metadata: {
|
||||
callId: event.callId,
|
||||
toolName: event.name,
|
||||
success: event.success,
|
||||
resultDisplay: event.resultDisplay,
|
||||
outputFile: event.outputFile,
|
||||
round: event.round,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { core, emitter };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@
|
|||
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
|
||||
import type {
|
||||
AgentRoundTextEvent,
|
||||
AgentToolCallEvent,
|
||||
AgentToolResultEvent,
|
||||
AgentToolOutputUpdateEvent,
|
||||
AgentApprovalRequestEvent,
|
||||
AgentToolOutputUpdateEvent,
|
||||
AgentToolResultEvent,
|
||||
} from './agent-events.js';
|
||||
import type { AgentStatsSummary } from './agent-statistics.js';
|
||||
import type { AgentCore } from './agent-core.js';
|
||||
|
|
@ -54,7 +52,6 @@ export class AgentInteractive {
|
|||
readonly config: AgentInteractiveConfig;
|
||||
private readonly core: AgentCore;
|
||||
private readonly queue = new AsyncMessageQueue<string>();
|
||||
private readonly messages: AgentMessage[] = [];
|
||||
|
||||
private status: AgentStatus = AgentStatus.INITIALIZING;
|
||||
private error: string | undefined;
|
||||
|
|
@ -67,28 +64,11 @@ export class AgentInteractive {
|
|||
private processing = false;
|
||||
private roundCancelledByUser = false;
|
||||
|
||||
// Pending tool approval requests. Keyed by callId.
|
||||
// Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when
|
||||
// the user responds. The UI reads this to show confirmation dialogs.
|
||||
private readonly pendingApprovals = new Map<
|
||||
string,
|
||||
ToolCallConfirmationDetails
|
||||
>();
|
||||
|
||||
// Live streaming output for currently-executing tools. Keyed by callId.
|
||||
// Populated by TOOL_OUTPUT_UPDATE (replaces previous), cleared on TOOL_RESULT.
|
||||
// The UI reads this via getLiveOutputs() to show real-time stdout.
|
||||
private readonly liveOutputs = new Map<string, ToolResultDisplay>();
|
||||
|
||||
// PTY PIDs for currently-executing shell tools. Keyed by callId.
|
||||
// Populated by TOOL_OUTPUT_UPDATE when pid is present, cleared on TOOL_RESULT.
|
||||
// The UI reads this via getShellPids() to enable interactive shell input.
|
||||
private readonly shellPids = new Map<string, number>();
|
||||
|
||||
// Wall-clock timestamp when each currently-executing tool transitioned into
|
||||
// the scheduler's `executing` state. Keyed by callId. First TOOL_OUTPUT_UPDATE
|
||||
// carrying executionStartTime wins; later events that re-carry it are ignored
|
||||
// so the timer is stable.
|
||||
// so the timer is stable. Lives on InteractiveAgent (not AgentCore) because
|
||||
// it's only consumed by the interactive UI's elapsed-time indicator.
|
||||
private readonly executionStartTimes = new Map<string, number>();
|
||||
|
||||
constructor(config: AgentInteractiveConfig, core: AgentCore) {
|
||||
|
|
@ -233,7 +213,7 @@ export class AgentInteractive {
|
|||
cancelCurrentRound(): void {
|
||||
this.roundCancelledByUser = true;
|
||||
this.roundAbortController?.abort();
|
||||
this.pendingApprovals.clear();
|
||||
this.core.clearPendingApprovals();
|
||||
this.addMessage('info', 'Agent round cancelled.', {
|
||||
metadata: { level: 'warning' },
|
||||
});
|
||||
|
|
@ -261,7 +241,7 @@ export class AgentInteractive {
|
|||
abort(): void {
|
||||
this.masterAbortController.abort();
|
||||
this.queue.drain();
|
||||
this.pendingApprovals.clear();
|
||||
this.core.clearPendingApprovals();
|
||||
}
|
||||
|
||||
// ─── Message Queue ─────────────────────────────────────────
|
||||
|
|
@ -276,10 +256,10 @@ export class AgentInteractive {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── State Accessors ───────────────────────────────────────
|
||||
// ─── State Accessors (delegates to AgentCore) ──────────────
|
||||
|
||||
getMessages(): readonly AgentMessage[] {
|
||||
return this.messages;
|
||||
return this.core.getMessages();
|
||||
}
|
||||
|
||||
getStatus(): AgentStatus {
|
||||
|
|
@ -307,7 +287,7 @@ export class AgentInteractive {
|
|||
return this.core;
|
||||
}
|
||||
|
||||
getEventEmitter(): AgentEventEmitter | undefined {
|
||||
getEventEmitter(): AgentEventEmitter {
|
||||
return this.core.getEventEmitter();
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +297,7 @@ export class AgentInteractive {
|
|||
* The UI reads this to render confirmation dialogs inside ToolGroupMessage.
|
||||
*/
|
||||
getPendingApprovals(): ReadonlyMap<string, ToolCallConfirmationDetails> {
|
||||
return this.pendingApprovals;
|
||||
return this.core.getPendingApprovals();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -326,7 +306,7 @@ export class AgentInteractive {
|
|||
* Entries are cleared when TOOL_RESULT arrives for the call.
|
||||
*/
|
||||
getLiveOutputs(): ReadonlyMap<string, ToolResultDisplay> {
|
||||
return this.liveOutputs;
|
||||
return this.core.getLiveOutputs();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -336,7 +316,7 @@ export class AgentInteractive {
|
|||
* interactive shell input via HistoryItemDisplay's activeShellPtyId prop.
|
||||
*/
|
||||
getShellPids(): ReadonlyMap<string, number> {
|
||||
return this.shellPids;
|
||||
return this.core.getShellPids();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -394,53 +374,20 @@ export class AgentInteractive {
|
|||
content: string,
|
||||
options?: { thought?: boolean; metadata?: Record<string, unknown> },
|
||||
): void {
|
||||
const message: AgentMessage = {
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
if (options?.thought) {
|
||||
message.thought = true;
|
||||
}
|
||||
if (options?.metadata) {
|
||||
message.metadata = options.metadata;
|
||||
}
|
||||
this.messages.push(message);
|
||||
this.core.pushMessage(role, content, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps TOOL_WAITING_APPROVAL's onConfirm so a Cancel outcome aborts
|
||||
* the current round (headless agents bypass this path entirely).
|
||||
* Core already owns the message / live-output / shell-PID listeners.
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
const emitter = this.core.eventEmitter;
|
||||
if (!emitter) return;
|
||||
|
||||
emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => {
|
||||
if (event.thoughtText) {
|
||||
this.addMessage('assistant', event.thoughtText, { thought: true });
|
||||
}
|
||||
if (event.text) {
|
||||
this.addMessage('assistant', event.text);
|
||||
}
|
||||
});
|
||||
|
||||
emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => {
|
||||
this.addMessage('tool_call', `Tool call: ${event.name}`, {
|
||||
metadata: {
|
||||
callId: event.callId,
|
||||
toolName: event.name,
|
||||
args: event.args,
|
||||
description: event.description,
|
||||
renderOutputAsMarkdown: event.isOutputMarkdown,
|
||||
round: event.round,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
emitter.on(
|
||||
AgentEventType.TOOL_OUTPUT_UPDATE,
|
||||
(event: AgentToolOutputUpdateEvent) => {
|
||||
this.liveOutputs.set(event.callId, event.outputChunk);
|
||||
if (event.pid !== undefined) {
|
||||
this.shellPids.set(event.callId, event.pid);
|
||||
}
|
||||
if (
|
||||
event.executionStartTime !== undefined &&
|
||||
!this.executionStartTimes.has(event.callId)
|
||||
|
|
@ -451,25 +398,7 @@ export class AgentInteractive {
|
|||
);
|
||||
|
||||
emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => {
|
||||
this.liveOutputs.delete(event.callId);
|
||||
this.shellPids.delete(event.callId);
|
||||
this.executionStartTimes.delete(event.callId);
|
||||
this.pendingApprovals.delete(event.callId);
|
||||
|
||||
const statusText = event.success ? 'succeeded' : 'failed';
|
||||
const summary = event.error
|
||||
? `Tool ${event.name} ${statusText}: ${event.error}`
|
||||
: `Tool ${event.name} ${statusText}`;
|
||||
this.addMessage('tool_result', summary, {
|
||||
metadata: {
|
||||
callId: event.callId,
|
||||
toolName: event.name,
|
||||
success: event.success,
|
||||
resultDisplay: event.resultDisplay,
|
||||
outputFile: event.outputFile,
|
||||
round: event.round,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
emitter.on(
|
||||
|
|
@ -481,17 +410,17 @@ export class AgentInteractive {
|
|||
outcome: Parameters<ToolCallConfirmationDetails['onConfirm']>[0],
|
||||
payload?: Parameters<ToolCallConfirmationDetails['onConfirm']>[1],
|
||||
) => {
|
||||
this.pendingApprovals.delete(event.callId);
|
||||
this.core.deletePendingApproval(event.callId);
|
||||
// Nudge the UI to re-render so the tool transitions visually
|
||||
// from Confirming → Executing without waiting for the first
|
||||
// real TOOL_OUTPUT_UPDATE from the tool's execution.
|
||||
this.core.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, {
|
||||
this.core.eventEmitter.emit(AgentEventType.TOOL_OUTPUT_UPDATE, {
|
||||
subagentId: this.core.subagentId,
|
||||
round: event.round,
|
||||
callId: event.callId,
|
||||
outputChunk: '',
|
||||
timestamp: Date.now(),
|
||||
} as AgentToolOutputUpdateEvent);
|
||||
});
|
||||
await event.respond(outcome, payload);
|
||||
// When the user denies a tool, cancel the round immediately
|
||||
// so the agent doesn't waste a turn "acknowledging" the denial.
|
||||
|
|
@ -501,7 +430,7 @@ export class AgentInteractive {
|
|||
},
|
||||
} as ToolCallConfirmationDetails;
|
||||
|
||||
this.pendingApprovals.set(event.callId, fullDetails);
|
||||
this.core.setPendingApproval(event.callId, fullDetails);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1435,6 +1435,13 @@ describe('AgentTool', () => {
|
|||
getFinalText: vi.fn().mockReturnValue('Monitor done'),
|
||||
getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL),
|
||||
getExecutionSummary: vi.fn().mockReturnValue({}),
|
||||
// Background spawn subscribes to the core's event emitter to
|
||||
// populate the entry's recentActivities buffer. Return a stub
|
||||
// whose getEventEmitter() yields a minimal on/off surface so the
|
||||
// test-time listener hookup doesn't throw.
|
||||
getCore: vi.fn().mockReturnValue({
|
||||
getEventEmitter: () => ({ on: vi.fn(), off: vi.fn() }),
|
||||
}),
|
||||
} as unknown as AgentHeadless;
|
||||
|
||||
mockContextState = { set: vi.fn() } as unknown as ContextState;
|
||||
|
|
|
|||
|
|
@ -1122,9 +1122,48 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
|||
startTime: Date.now(),
|
||||
abortController: bgAbortController,
|
||||
toolUseId: this.callId,
|
||||
prompt: this.params.prompt,
|
||||
outputFile: jsonlPath,
|
||||
});
|
||||
|
||||
// Subscribe to the subagent's tool-call event stream so the
|
||||
// detail dialog's Progress section reflects live activity. We
|
||||
// capture the unsubscribe fn and call it when the agent
|
||||
// terminates (success, failure, or cancel) to avoid holding the
|
||||
// event emitter after the agent is gone.
|
||||
const bgEmitter = bgSubagent.getCore().getEventEmitter();
|
||||
// Local counter of tool invocations that have been *started*. The
|
||||
// core's executionStats.totalToolCalls only increments when a tool
|
||||
// result arrives, so using it as the live toolUses number leaves the
|
||||
// subtitle one behind the Progress list while a tool is in flight.
|
||||
// Tracking TOOL_CALL ourselves keeps the subtitle in sync with the
|
||||
// rows the user actually sees.
|
||||
let liveToolCallCount = 0;
|
||||
const refreshLiveStats = () => {
|
||||
const entry = registry.get(hookOpts.agentId);
|
||||
if (!entry || entry.status !== 'running') return;
|
||||
const summary = bgSubagent.getExecutionSummary();
|
||||
entry.stats = {
|
||||
totalTokens: summary.totalTokens,
|
||||
toolUses: liveToolCallCount,
|
||||
durationMs: summary.totalDurationMs,
|
||||
};
|
||||
};
|
||||
const onToolCall = (event: AgentToolCallEvent) => {
|
||||
liveToolCallCount += 1;
|
||||
refreshLiveStats();
|
||||
registry.appendActivity(hookOpts.agentId, {
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
at: event.timestamp,
|
||||
});
|
||||
};
|
||||
const onUsageMetadata = () => {
|
||||
refreshLiveStats();
|
||||
};
|
||||
bgEmitter.on(AgentEventType.TOOL_CALL, onToolCall);
|
||||
bgEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
|
||||
// Wire external message drain so SendMessage can inject messages
|
||||
// into this agent's reasoning loop between tool rounds.
|
||||
bgSubagent.setExternalMessageProvider(() =>
|
||||
|
|
@ -1135,7 +1174,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
|||
const summary = bgSubagent.getExecutionSummary();
|
||||
return {
|
||||
totalTokens: summary.totalTokens,
|
||||
toolUses: summary.totalToolCalls,
|
||||
toolUses: liveToolCallCount,
|
||||
durationMs: summary.totalDurationMs,
|
||||
};
|
||||
};
|
||||
|
|
@ -1200,6 +1239,8 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
|||
registry.fail(hookOpts.agentId, errorMsg, getCompletionStats());
|
||||
}
|
||||
} finally {
|
||||
bgEmitter.off(AgentEventType.TOOL_CALL, onToolCall);
|
||||
bgEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
cleanupJsonl?.();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue