mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'
|
||||
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs';
|
||||
import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js';
|
||||
import {
|
||||
OUTPUT_FILE_TAG,
|
||||
STATUS_TAG,
|
||||
@@ -10,69 +10,58 @@ import {
|
||||
WORKTREE_BRANCH_TAG,
|
||||
WORKTREE_PATH_TAG,
|
||||
WORKTREE_TAG,
|
||||
} from '../../constants/xml.js'
|
||||
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
|
||||
import { createTaskStateBase } from '../../Task.js'
|
||||
import type { Tools } from '../../Tool.js'
|
||||
import { findToolByName } from '../../Tool.js'
|
||||
import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
import { asAgentId } from '../../types/ids.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import {
|
||||
createAbortController,
|
||||
createChildAbortController,
|
||||
} from '../../utils/abortController.js'
|
||||
import { registerCleanup } from '../../utils/cleanupRegistry.js'
|
||||
import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js'
|
||||
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'
|
||||
import { getAgentTranscriptPath } from '../../utils/sessionStorage.js'
|
||||
import {
|
||||
evictTaskOutput,
|
||||
getTaskOutputPath,
|
||||
initTaskOutputAsSymlink,
|
||||
} from '../../utils/task/diskOutput.js'
|
||||
import {
|
||||
PANEL_GRACE_MS,
|
||||
registerTask,
|
||||
updateTaskState,
|
||||
} from '../../utils/task/framework.js'
|
||||
import { emitTaskProgress } from '../../utils/task/sdkProgress.js'
|
||||
import type { TaskState } from '../types.js'
|
||||
} from '../../constants/xml.js';
|
||||
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js';
|
||||
import type { AppState } from '../../state/AppState.js';
|
||||
import type { SetAppState, Task, TaskStateBase } from '../../Task.js';
|
||||
import { createTaskStateBase } from '../../Task.js';
|
||||
import type { Tools } from '../../Tool.js';
|
||||
import { findToolByName } from '../../Tool.js';
|
||||
import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js';
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js';
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js';
|
||||
import { asAgentId } from '../../types/ids.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { createAbortController, createChildAbortController } from '../../utils/abortController.js';
|
||||
import { registerCleanup } from '../../utils/cleanupRegistry.js';
|
||||
import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js';
|
||||
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js';
|
||||
import { getAgentTranscriptPath } from '../../utils/sessionStorage.js';
|
||||
import { evictTaskOutput, getTaskOutputPath, initTaskOutputAsSymlink } from '../../utils/task/diskOutput.js';
|
||||
import { PANEL_GRACE_MS, registerTask, updateTaskState } from '../../utils/task/framework.js';
|
||||
import { emitTaskProgress } from '../../utils/task/sdkProgress.js';
|
||||
import type { TaskState } from '../types.js';
|
||||
|
||||
export type ToolActivity = {
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
/** Pre-computed activity description from the tool, e.g. "Reading src/foo.ts" */
|
||||
activityDescription?: string
|
||||
activityDescription?: string;
|
||||
/** Pre-computed: true if this is a search operation (Grep, Glob, etc.) */
|
||||
isSearch?: boolean
|
||||
isSearch?: boolean;
|
||||
/** Pre-computed: true if this is a read operation (Read, cat, etc.) */
|
||||
isRead?: boolean
|
||||
}
|
||||
isRead?: boolean;
|
||||
};
|
||||
|
||||
export type AgentProgress = {
|
||||
toolUseCount: number
|
||||
tokenCount: number
|
||||
lastActivity?: ToolActivity
|
||||
recentActivities?: ToolActivity[]
|
||||
summary?: string
|
||||
}
|
||||
toolUseCount: number;
|
||||
tokenCount: number;
|
||||
lastActivity?: ToolActivity;
|
||||
recentActivities?: ToolActivity[];
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
const MAX_RECENT_ACTIVITIES = 5
|
||||
const MAX_RECENT_ACTIVITIES = 5;
|
||||
|
||||
export type ProgressTracker = {
|
||||
toolUseCount: number
|
||||
toolUseCount: number;
|
||||
// Track input and output separately to avoid double-counting.
|
||||
// input_tokens in Claude API is cumulative per turn (includes all previous context),
|
||||
// so we keep the latest value. output_tokens is per-turn, so we sum those.
|
||||
latestInputTokens: number
|
||||
cumulativeOutputTokens: number
|
||||
recentActivities: ToolActivity[]
|
||||
}
|
||||
latestInputTokens: number;
|
||||
cumulativeOutputTokens: number;
|
||||
recentActivities: ToolActivity[];
|
||||
};
|
||||
|
||||
export function createProgressTracker(): ProgressTracker {
|
||||
return {
|
||||
@@ -80,11 +69,11 @@ export function createProgressTracker(): ProgressTracker {
|
||||
latestInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
recentActivities: [],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getTokenCountFromTracker(tracker: ProgressTracker): number {
|
||||
return tracker.latestInputTokens + tracker.cumulativeOutputTokens
|
||||
return tracker.latestInputTokens + tracker.cumulativeOutputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,10 +81,7 @@ export function getTokenCountFromTracker(tracker: ProgressTracker): number {
|
||||
* for a given tool name and input. Used to pre-compute descriptions
|
||||
* from Tool.getActivityDescription() at recording time.
|
||||
*/
|
||||
export type ActivityDescriptionResolver = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
) => string | undefined
|
||||
export type ActivityDescriptionResolver = (toolName: string, input: Record<string, unknown>) => string | undefined;
|
||||
|
||||
export function updateProgressFromMessage(
|
||||
tracker: ProgressTracker,
|
||||
@@ -104,42 +90,35 @@ export function updateProgressFromMessage(
|
||||
tools?: Tools,
|
||||
): void {
|
||||
if (message.type !== 'assistant') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const usage = message.message!.usage as BetaUsage | undefined
|
||||
const usage = message.message!.usage as BetaUsage | undefined;
|
||||
if (!usage) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// Keep latest input (it's cumulative in the API), sum outputs
|
||||
tracker.latestInputTokens =
|
||||
(usage.input_tokens as number) +
|
||||
(usage.cache_creation_input_tokens ?? 0) +
|
||||
(usage.cache_read_input_tokens ?? 0)
|
||||
tracker.cumulativeOutputTokens += usage.output_tokens as number
|
||||
(usage.input_tokens as number) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
||||
tracker.cumulativeOutputTokens += usage.output_tokens as number;
|
||||
for (const content of (message.message!.content ?? []) as Array<{ type: string; name?: string; input?: unknown }>) {
|
||||
if (content.type === 'tool_use') {
|
||||
tracker.toolUseCount++
|
||||
tracker.toolUseCount++;
|
||||
// Omit StructuredOutput from preview - it's an internal tool
|
||||
if (content.name !== SYNTHETIC_OUTPUT_TOOL_NAME) {
|
||||
const input = content.input as Record<string, unknown>
|
||||
const classification = tools
|
||||
? getToolSearchOrReadInfo(content.name!, input, tools)
|
||||
: undefined
|
||||
const input = content.input as Record<string, unknown>;
|
||||
const classification = tools ? getToolSearchOrReadInfo(content.name!, input, tools) : undefined;
|
||||
tracker.recentActivities.push({
|
||||
toolName: content.name!,
|
||||
input,
|
||||
activityDescription: resolveActivityDescription?.(
|
||||
content.name!,
|
||||
input,
|
||||
),
|
||||
activityDescription: resolveActivityDescription?.(content.name!, input),
|
||||
isSearch: classification?.isSearch,
|
||||
isRead: classification?.isRead,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
while (tracker.recentActivities.length > MAX_RECENT_ACTIVITIES) {
|
||||
tracker.recentActivities.shift()
|
||||
tracker.recentActivities.shift();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,67 +127,58 @@ export function getProgressUpdate(tracker: ProgressTracker): AgentProgress {
|
||||
toolUseCount: tracker.toolUseCount,
|
||||
tokenCount: getTokenCountFromTracker(tracker),
|
||||
lastActivity:
|
||||
tracker.recentActivities.length > 0
|
||||
? tracker.recentActivities[tracker.recentActivities.length - 1]
|
||||
: undefined,
|
||||
tracker.recentActivities.length > 0 ? tracker.recentActivities[tracker.recentActivities.length - 1] : undefined,
|
||||
recentActivities: [...tracker.recentActivities],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ActivityDescriptionResolver from a tools list.
|
||||
* Looks up the tool by name and calls getActivityDescription if available.
|
||||
*/
|
||||
export function createActivityDescriptionResolver(
|
||||
tools: Tools,
|
||||
): ActivityDescriptionResolver {
|
||||
export function createActivityDescriptionResolver(tools: Tools): ActivityDescriptionResolver {
|
||||
return (toolName, input) => {
|
||||
const tool = findToolByName(tools, toolName)
|
||||
return tool?.getActivityDescription?.(input) ?? undefined
|
||||
}
|
||||
const tool = findToolByName(tools, toolName);
|
||||
return tool?.getActivityDescription?.(input) ?? undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalAgentTaskState = TaskStateBase & {
|
||||
type: 'local_agent'
|
||||
agentId: string
|
||||
prompt: string
|
||||
selectedAgent?: AgentDefinition
|
||||
agentType: string
|
||||
model?: string
|
||||
abortController?: AbortController
|
||||
unregisterCleanup?: () => void
|
||||
error?: string
|
||||
result?: AgentToolResult
|
||||
progress?: AgentProgress
|
||||
retrieved: boolean
|
||||
messages?: Message[]
|
||||
type: 'local_agent';
|
||||
agentId: string;
|
||||
prompt: string;
|
||||
selectedAgent?: AgentDefinition;
|
||||
agentType: string;
|
||||
model?: string;
|
||||
abortController?: AbortController;
|
||||
unregisterCleanup?: () => void;
|
||||
error?: string;
|
||||
result?: AgentToolResult;
|
||||
progress?: AgentProgress;
|
||||
retrieved: boolean;
|
||||
messages?: Message[];
|
||||
// Track what we last reported for computing deltas
|
||||
lastReportedToolCount: number
|
||||
lastReportedTokenCount: number
|
||||
lastReportedToolCount: number;
|
||||
lastReportedTokenCount: number;
|
||||
// Whether the task has been backgrounded (false = foreground running, true = backgrounded)
|
||||
isBackgrounded: boolean
|
||||
isBackgrounded: boolean;
|
||||
// Messages queued mid-turn via SendMessage, drained at tool-round boundaries
|
||||
pendingMessages: string[]
|
||||
pendingMessages: string[];
|
||||
// UI is holding this task: blocks eviction, enables stream-append, triggers
|
||||
// disk bootstrap. Set by enterTeammateView. Separate from viewingAgentTaskId
|
||||
// (which is "what am I LOOKING at") — retain is "what am I HOLDING."
|
||||
retain: boolean
|
||||
retain: boolean;
|
||||
// Bootstrap has read the sidechain JSONL and UUID-merged into messages.
|
||||
// One-shot per retain cycle; stream appends from there.
|
||||
diskLoaded: boolean
|
||||
diskLoaded: boolean;
|
||||
// Panel visibility deadline. undefined = no deadline (running or retained);
|
||||
// timestamp = hide + GC-eligible after this time. Set at terminal transition
|
||||
// and on unselect; cleared on retain.
|
||||
evictAfter?: number
|
||||
}
|
||||
evictAfter?: number;
|
||||
};
|
||||
|
||||
export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState {
|
||||
return (
|
||||
typeof task === 'object' &&
|
||||
task !== null &&
|
||||
'type' in task &&
|
||||
task.type === 'local_agent'
|
||||
)
|
||||
return typeof task === 'object' && task !== null && 'type' in task && task.type === 'local_agent';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,7 +188,7 @@ export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState {
|
||||
* the gate changes, change it here.
|
||||
*/
|
||||
export function isPanelAgentTask(t: unknown): t is LocalAgentTaskState {
|
||||
return isLocalAgentTask(t) && t.agentType !== 'main-session'
|
||||
return isLocalAgentTask(t) && t.agentType !== 'main-session';
|
||||
}
|
||||
|
||||
export function queuePendingMessage(
|
||||
@@ -229,7 +199,7 @@ export function queuePendingMessage(
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => ({
|
||||
...task,
|
||||
pendingMessages: [...task.pendingMessages, msg],
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,7 +216,7 @@ export function appendMessageToLocalAgent(
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => ({
|
||||
...task,
|
||||
messages: [...(task.messages ?? []), message],
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
export function drainPendingMessages(
|
||||
@@ -254,16 +224,16 @@ export function drainPendingMessages(
|
||||
getAppState: () => AppState,
|
||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
||||
): string[] {
|
||||
const task = getAppState().tasks[taskId]
|
||||
const task = getAppState().tasks[taskId];
|
||||
if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
const drained = task.pendingMessages
|
||||
const drained = task.pendingMessages;
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, t => ({
|
||||
...t,
|
||||
pendingMessages: [],
|
||||
}))
|
||||
return drained
|
||||
}));
|
||||
return drained;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,72 +251,70 @@ export function enqueueAgentNotification({
|
||||
worktreePath,
|
||||
worktreeBranch,
|
||||
}: {
|
||||
taskId: string
|
||||
description: string
|
||||
status: 'completed' | 'failed' | 'killed'
|
||||
error?: string
|
||||
setAppState: SetAppState
|
||||
finalMessage?: string
|
||||
taskId: string;
|
||||
description: string;
|
||||
status: 'completed' | 'failed' | 'killed';
|
||||
error?: string;
|
||||
setAppState: SetAppState;
|
||||
finalMessage?: string;
|
||||
usage?: {
|
||||
totalTokens: number
|
||||
toolUses: number
|
||||
durationMs: number
|
||||
}
|
||||
toolUseId?: string
|
||||
worktreePath?: string
|
||||
worktreeBranch?: string
|
||||
totalTokens: number;
|
||||
toolUses: number;
|
||||
durationMs: number;
|
||||
};
|
||||
toolUseId?: string;
|
||||
worktreePath?: string;
|
||||
worktreeBranch?: string;
|
||||
}): void {
|
||||
// Atomically check and set notified flag to prevent duplicate notifications.
|
||||
// If the task was already marked as notified (e.g., by TaskStopTool), skip
|
||||
// enqueueing to avoid sending redundant messages to the model.
|
||||
let shouldEnqueue = false
|
||||
let shouldEnqueue = false;
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
|
||||
if (task.notified) {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
shouldEnqueue = true
|
||||
shouldEnqueue = true;
|
||||
return {
|
||||
...task,
|
||||
notified: true,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
if (!shouldEnqueue) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any active speculation — background task state changed, so speculated
|
||||
// results may reference stale task output. The prompt suggestion text is
|
||||
// preserved; only the pre-computed response is discarded.
|
||||
abortSpeculation(setAppState)
|
||||
abortSpeculation(setAppState);
|
||||
|
||||
const summary =
|
||||
status === 'completed'
|
||||
? `Agent "${description}" completed`
|
||||
: status === 'failed'
|
||||
? `Agent "${description}" failed: ${error || 'Unknown error'}`
|
||||
: `Agent "${description}" was stopped`
|
||||
: `Agent "${description}" was stopped`;
|
||||
|
||||
const outputPath = getTaskOutputPath(taskId)
|
||||
const toolUseIdLine = toolUseId
|
||||
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
|
||||
: ''
|
||||
const resultSection = finalMessage ? `\n<result>${finalMessage}</result>` : ''
|
||||
const outputPath = getTaskOutputPath(taskId);
|
||||
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : '';
|
||||
const resultSection = finalMessage ? `\n<result>${finalMessage}</result>` : '';
|
||||
const usageSection = usage
|
||||
? `\n<usage><total_tokens>${usage.totalTokens}</total_tokens><tool_uses>${usage.toolUses}</tool_uses><duration_ms>${usage.durationMs}</duration_ms></usage>`
|
||||
: ''
|
||||
: '';
|
||||
const worktreeSection = worktreePath
|
||||
? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}</${WORKTREE_PATH_TAG}>${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}</${WORKTREE_BRANCH_TAG}>` : ''}</${WORKTREE_TAG}>`
|
||||
: ''
|
||||
: '';
|
||||
|
||||
const message = `<${TASK_NOTIFICATION_TAG}>
|
||||
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
|
||||
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
|
||||
<${STATUS_TAG}>${status}</${STATUS_TAG}>
|
||||
<${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}>${resultSection}${usageSection}${worktreeSection}
|
||||
</${TASK_NOTIFICATION_TAG}>`
|
||||
</${TASK_NOTIFICATION_TAG}>`;
|
||||
|
||||
enqueuePendingNotification({ value: message, mode: 'task-notification' })
|
||||
enqueuePendingNotification({ value: message, mode: 'task-notification' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,22 +328,22 @@ export const LocalAgentTask: Task = {
|
||||
type: 'local_agent',
|
||||
|
||||
async kill(taskId, setAppState) {
|
||||
killAsyncAgent(taskId, setAppState)
|
||||
killAsyncAgent(taskId, setAppState);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Kill an agent task. No-op if already killed/completed.
|
||||
*/
|
||||
export function killAsyncAgent(taskId: string, setAppState: SetAppState): void {
|
||||
let killed = false
|
||||
let killed = false;
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
killed = true
|
||||
task.abortController?.abort()
|
||||
task.unregisterCleanup?.()
|
||||
killed = true;
|
||||
task.abortController?.abort();
|
||||
task.unregisterCleanup?.();
|
||||
return {
|
||||
...task,
|
||||
status: 'killed',
|
||||
@@ -384,10 +352,10 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void {
|
||||
abortController: undefined,
|
||||
unregisterCleanup: undefined,
|
||||
selectedAgent: undefined,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
if (killed) {
|
||||
void evictTaskOutput(taskId)
|
||||
void evictTaskOutput(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,13 +363,10 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void {
|
||||
* Kill all running agent tasks.
|
||||
* Used by ESC cancellation in coordinator mode to stop all subagents.
|
||||
*/
|
||||
export function killAllRunningAgentTasks(
|
||||
tasks: Record<string, TaskState>,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
export function killAllRunningAgentTasks(tasks: Record<string, TaskState>, setAppState: SetAppState): void {
|
||||
for (const [taskId, task] of Object.entries(tasks)) {
|
||||
if (task.type === 'local_agent' && task.status === 'running') {
|
||||
killAsyncAgent(taskId, setAppState)
|
||||
killAsyncAgent(taskId, setAppState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,19 +376,16 @@ export function killAllRunningAgentTasks(
|
||||
* Used by chat:killAgents bulk kill to suppress per-agent async notifications
|
||||
* when a single aggregate message is sent instead.
|
||||
*/
|
||||
export function markAgentsNotified(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
export function markAgentsNotified(taskId: string, setAppState: SetAppState): void {
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
|
||||
if (task.notified) {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
return {
|
||||
...task,
|
||||
notified: true,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,45 +393,35 @@ export function markAgentsNotified(
|
||||
* Preserves the existing summary field so that background summarization
|
||||
* results are not clobbered by progress updates from assistant messages.
|
||||
*/
|
||||
export function updateAgentProgress(
|
||||
taskId: string,
|
||||
progress: AgentProgress,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
export function updateAgentProgress(taskId: string, progress: AgentProgress, setAppState: SetAppState): void {
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
|
||||
const existingSummary = task.progress?.summary
|
||||
const existingSummary = task.progress?.summary;
|
||||
return {
|
||||
...task,
|
||||
progress: existingSummary
|
||||
? { ...progress, summary: existingSummary }
|
||||
: progress,
|
||||
}
|
||||
})
|
||||
progress: existingSummary ? { ...progress, summary: existingSummary } : progress,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the background summary for an agent task.
|
||||
* Called by the periodic summarization service to store a 1-2 sentence progress summary.
|
||||
*/
|
||||
export function updateAgentSummary(
|
||||
taskId: string,
|
||||
summary: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
export function updateAgentSummary(taskId: string, summary: string, setAppState: SetAppState): void {
|
||||
let captured: {
|
||||
tokenCount: number
|
||||
toolUseCount: number
|
||||
startTime: number
|
||||
toolUseId: string | undefined
|
||||
} | null = null
|
||||
tokenCount: number;
|
||||
toolUseCount: number;
|
||||
startTime: number;
|
||||
toolUseId: string | undefined;
|
||||
} | null = null;
|
||||
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
|
||||
captured = {
|
||||
@@ -477,7 +429,7 @@ export function updateAgentSummary(
|
||||
toolUseCount: task.progress?.toolUseCount ?? 0,
|
||||
startTime: task.startTime,
|
||||
toolUseId: task.toolUseId,
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...task,
|
||||
@@ -487,14 +439,14 @@ export function updateAgentSummary(
|
||||
tokenCount: task.progress?.tokenCount ?? 0,
|
||||
summary,
|
||||
},
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Emit summary to SDK consumers (e.g. VS Code subagent panel). No-op in TUI.
|
||||
// Gate on the SDK option so coordinator-mode sessions without the flag don't
|
||||
// leak summary events to consumers who didn't opt in.
|
||||
if (captured && getSdkAgentProgressSummariesEnabled()) {
|
||||
const { tokenCount, toolUseCount, startTime, toolUseId } = captured
|
||||
const { tokenCount, toolUseCount, startTime, toolUseId } = captured;
|
||||
emitTaskProgress({
|
||||
taskId,
|
||||
toolUseId,
|
||||
@@ -503,24 +455,21 @@ export function updateAgentSummary(
|
||||
totalTokens: tokenCount,
|
||||
toolUses: toolUseCount,
|
||||
summary,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete an agent task with result.
|
||||
*/
|
||||
export function completeAgentTask(
|
||||
result: AgentToolResult,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
const taskId = result.agentId
|
||||
export function completeAgentTask(result: AgentToolResult, setAppState: SetAppState): void {
|
||||
const taskId = result.agentId;
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
|
||||
task.unregisterCleanup?.()
|
||||
task.unregisterCleanup?.();
|
||||
|
||||
return {
|
||||
...task,
|
||||
@@ -531,26 +480,22 @@ export function completeAgentTask(
|
||||
abortController: undefined,
|
||||
unregisterCleanup: undefined,
|
||||
selectedAgent: undefined,
|
||||
}
|
||||
})
|
||||
void evictTaskOutput(taskId)
|
||||
};
|
||||
});
|
||||
void evictTaskOutput(taskId);
|
||||
// Note: Notification is sent by AgentTool via enqueueAgentNotification
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail an agent task with error.
|
||||
*/
|
||||
export function failAgentTask(
|
||||
taskId: string,
|
||||
error: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
export function failAgentTask(taskId: string, error: string, setAppState: SetAppState): void {
|
||||
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
|
||||
task.unregisterCleanup?.()
|
||||
task.unregisterCleanup?.();
|
||||
|
||||
return {
|
||||
...task,
|
||||
@@ -561,9 +506,9 @@ export function failAgentTask(
|
||||
abortController: undefined,
|
||||
unregisterCleanup: undefined,
|
||||
selectedAgent: undefined,
|
||||
}
|
||||
})
|
||||
void evictTaskOutput(taskId)
|
||||
};
|
||||
});
|
||||
void evictTaskOutput(taskId);
|
||||
// Note: Notification is sent by AgentTool via enqueueAgentNotification
|
||||
}
|
||||
|
||||
@@ -584,23 +529,20 @@ export function registerAsyncAgent({
|
||||
parentAbortController,
|
||||
toolUseId,
|
||||
}: {
|
||||
agentId: string
|
||||
description: string
|
||||
prompt: string
|
||||
selectedAgent: AgentDefinition
|
||||
setAppState: SetAppState
|
||||
parentAbortController?: AbortController
|
||||
toolUseId?: string
|
||||
agentId: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
selectedAgent: AgentDefinition;
|
||||
setAppState: SetAppState;
|
||||
parentAbortController?: AbortController;
|
||||
toolUseId?: string;
|
||||
}): LocalAgentTaskState {
|
||||
void initTaskOutputAsSymlink(
|
||||
agentId,
|
||||
getAgentTranscriptPath(asAgentId(agentId)),
|
||||
)
|
||||
void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId)));
|
||||
|
||||
// Create abort controller - if parent provided, create child that auto-aborts with parent
|
||||
const abortController = parentAbortController
|
||||
? createChildAbortController(parentAbortController)
|
||||
: createAbortController()
|
||||
: createAbortController();
|
||||
|
||||
const taskState: LocalAgentTaskState = {
|
||||
...createTaskStateBase(agentId, 'local_agent', description, toolUseId),
|
||||
@@ -618,24 +560,24 @@ export function registerAsyncAgent({
|
||||
pendingMessages: [],
|
||||
retain: false,
|
||||
diskLoaded: false,
|
||||
}
|
||||
};
|
||||
|
||||
// Register cleanup handler
|
||||
const unregisterCleanup = registerCleanup(async () => {
|
||||
killAsyncAgent(agentId, setAppState)
|
||||
})
|
||||
killAsyncAgent(agentId, setAppState);
|
||||
});
|
||||
|
||||
taskState.unregisterCleanup = unregisterCleanup
|
||||
taskState.unregisterCleanup = unregisterCleanup;
|
||||
|
||||
// Register task in AppState
|
||||
registerTask(taskState, setAppState)
|
||||
registerTask(taskState, setAppState);
|
||||
|
||||
return taskState
|
||||
return taskState;
|
||||
}
|
||||
|
||||
// Map of taskId -> resolve function for background signals
|
||||
// When backgroundAgentTask is called, it resolves the corresponding promise
|
||||
const backgroundSignalResolvers = new Map<string, () => void>()
|
||||
const backgroundSignalResolvers = new Map<string, () => void>();
|
||||
|
||||
/**
|
||||
* Register a foreground agent task that could be backgrounded later.
|
||||
@@ -651,28 +593,25 @@ export function registerAgentForeground({
|
||||
autoBackgroundMs,
|
||||
toolUseId,
|
||||
}: {
|
||||
agentId: string
|
||||
description: string
|
||||
prompt: string
|
||||
selectedAgent: AgentDefinition
|
||||
setAppState: SetAppState
|
||||
autoBackgroundMs?: number
|
||||
toolUseId?: string
|
||||
agentId: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
selectedAgent: AgentDefinition;
|
||||
setAppState: SetAppState;
|
||||
autoBackgroundMs?: number;
|
||||
toolUseId?: string;
|
||||
}): {
|
||||
taskId: string
|
||||
backgroundSignal: Promise<void>
|
||||
cancelAutoBackground?: () => void
|
||||
taskId: string;
|
||||
backgroundSignal: Promise<void>;
|
||||
cancelAutoBackground?: () => void;
|
||||
} {
|
||||
void initTaskOutputAsSymlink(
|
||||
agentId,
|
||||
getAgentTranscriptPath(asAgentId(agentId)),
|
||||
)
|
||||
void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId)));
|
||||
|
||||
const abortController = createAbortController()
|
||||
const abortController = createAbortController();
|
||||
|
||||
const unregisterCleanup = registerCleanup(async () => {
|
||||
killAsyncAgent(agentId, setAppState)
|
||||
})
|
||||
killAsyncAgent(agentId, setAppState);
|
||||
});
|
||||
|
||||
const taskState: LocalAgentTaskState = {
|
||||
...createTaskStateBase(agentId, 'local_agent', description, toolUseId),
|
||||
@@ -691,27 +630,27 @@ export function registerAgentForeground({
|
||||
pendingMessages: [],
|
||||
retain: false,
|
||||
diskLoaded: false,
|
||||
}
|
||||
};
|
||||
|
||||
// Create background signal promise
|
||||
let resolveBackgroundSignal: () => void
|
||||
let resolveBackgroundSignal: () => void;
|
||||
const backgroundSignal = new Promise<void>(resolve => {
|
||||
resolveBackgroundSignal = resolve
|
||||
})
|
||||
backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!)
|
||||
resolveBackgroundSignal = resolve;
|
||||
});
|
||||
backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!);
|
||||
|
||||
registerTask(taskState, setAppState)
|
||||
registerTask(taskState, setAppState);
|
||||
|
||||
// Auto-background after timeout if configured
|
||||
let cancelAutoBackground: (() => void) | undefined
|
||||
let cancelAutoBackground: (() => void) | undefined;
|
||||
if (autoBackgroundMs !== undefined && autoBackgroundMs > 0) {
|
||||
const timer = setTimeout(
|
||||
(setAppState, agentId) => {
|
||||
// Mark task as backgrounded and resolve the signal
|
||||
setAppState(prev => {
|
||||
const prevTask = prev.tasks[agentId]
|
||||
const prevTask = prev.tasks[agentId];
|
||||
if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) {
|
||||
return prev
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
@@ -719,44 +658,40 @@ export function registerAgentForeground({
|
||||
...prev.tasks,
|
||||
[agentId]: { ...prevTask, isBackgrounded: true },
|
||||
},
|
||||
}
|
||||
})
|
||||
const resolver = backgroundSignalResolvers.get(agentId)
|
||||
};
|
||||
});
|
||||
const resolver = backgroundSignalResolvers.get(agentId);
|
||||
if (resolver) {
|
||||
resolver()
|
||||
backgroundSignalResolvers.delete(agentId)
|
||||
resolver();
|
||||
backgroundSignalResolvers.delete(agentId);
|
||||
}
|
||||
},
|
||||
autoBackgroundMs,
|
||||
setAppState,
|
||||
agentId,
|
||||
)
|
||||
cancelAutoBackground = () => clearTimeout(timer)
|
||||
);
|
||||
cancelAutoBackground = () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
return { taskId: agentId, backgroundSignal, cancelAutoBackground }
|
||||
return { taskId: agentId, backgroundSignal, cancelAutoBackground };
|
||||
}
|
||||
|
||||
/**
|
||||
* Background a specific foreground agent task.
|
||||
* @returns true if backgrounded successfully, false otherwise
|
||||
*/
|
||||
export function backgroundAgentTask(
|
||||
taskId: string,
|
||||
getAppState: () => AppState,
|
||||
setAppState: SetAppState,
|
||||
): boolean {
|
||||
const state = getAppState()
|
||||
const task = state.tasks[taskId]
|
||||
export function backgroundAgentTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean {
|
||||
const state = getAppState();
|
||||
const task = state.tasks[taskId];
|
||||
if (!isLocalAgentTask(task) || task.isBackgrounded) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update state to mark as backgrounded
|
||||
setAppState(prev => {
|
||||
const prevTask = prev.tasks[taskId]
|
||||
const prevTask = prev.tasks[taskId];
|
||||
if (!isLocalAgentTask(prevTask)) {
|
||||
return prev
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
@@ -764,45 +699,42 @@ export function backgroundAgentTask(
|
||||
...prev.tasks,
|
||||
[taskId]: { ...prevTask, isBackgrounded: true },
|
||||
},
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Resolve the background signal to interrupt the agent loop
|
||||
const resolver = backgroundSignalResolvers.get(taskId)
|
||||
const resolver = backgroundSignalResolvers.get(taskId);
|
||||
if (resolver) {
|
||||
resolver()
|
||||
backgroundSignalResolvers.delete(taskId)
|
||||
resolver();
|
||||
backgroundSignalResolvers.delete(taskId);
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a foreground agent task when the agent completes without being backgrounded.
|
||||
*/
|
||||
export function unregisterAgentForeground(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
export function unregisterAgentForeground(taskId: string, setAppState: SetAppState): void {
|
||||
// Clean up the background signal resolver
|
||||
backgroundSignalResolvers.delete(taskId)
|
||||
backgroundSignalResolvers.delete(taskId);
|
||||
|
||||
let cleanupFn: (() => void) | undefined
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
|
||||
setAppState(prev => {
|
||||
const task = prev.tasks[taskId]
|
||||
const task = prev.tasks[taskId];
|
||||
// Only remove if it's a foreground task (not backgrounded)
|
||||
if (!isLocalAgentTask(task) || task.isBackgrounded) {
|
||||
return prev
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Capture cleanup function to call outside of updater
|
||||
cleanupFn = task.unregisterCleanup
|
||||
cleanupFn = task.unregisterCleanup;
|
||||
|
||||
const { [taskId]: removed, ...rest } = prev.tasks
|
||||
return { ...prev, tasks: rest }
|
||||
})
|
||||
const { [taskId]: removed, ...rest } = prev.tasks;
|
||||
return { ...prev, tasks: rest };
|
||||
});
|
||||
|
||||
// Call cleanup outside of the state updater (avoid side effects in updater)
|
||||
cleanupFn?.()
|
||||
cleanupFn?.();
|
||||
}
|
||||
|
||||
@@ -10,100 +10,100 @@ mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
mock.module('src/utils/sessionStorage.js', () => ({
|
||||
getAgentTranscriptPath: (id: string) => `/tmp/transcripts/${id}.jsonl`,
|
||||
recordSidechainTranscript: async () => {},
|
||||
recordQueueOperation: noop,
|
||||
writeAgentMetadata: async () => {},
|
||||
getAgentTranscriptPath: (id: string) => `/tmp/transcripts/${id}.jsonl`,
|
||||
recordSidechainTranscript: async () => {},
|
||||
recordQueueOperation: noop,
|
||||
writeAgentMetadata: async () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/task/diskOutput.js', () => ({
|
||||
evictTaskOutput: noop,
|
||||
getTaskOutputPath: (id: string) => `/tmp/output/${id}`,
|
||||
initTaskOutputAsSymlink: async () => {},
|
||||
getTaskOutputDelta: async () => null,
|
||||
evictTaskOutput: noop,
|
||||
getTaskOutputPath: (id: string) => `/tmp/output/${id}`,
|
||||
initTaskOutputAsSymlink: async () => {},
|
||||
getTaskOutputDelta: async () => null,
|
||||
}))
|
||||
|
||||
// Capture enqueuePendingNotification calls for verification
|
||||
const enqueuedNotifications: string[] = []
|
||||
mock.module('src/utils/messageQueueManager.js', () => ({
|
||||
enqueuePendingNotification: (cmd: any) => {
|
||||
enqueuedNotifications.push(cmd.value)
|
||||
},
|
||||
enqueuePendingNotification: (cmd: any) => {
|
||||
enqueuedNotifications.push(cmd.value)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('src/bootstrap/state.js', () => ({
|
||||
getSdkAgentProgressSummariesEnabled: () => false,
|
||||
getSessionId: () => 'test-session-001',
|
||||
getProjectRoot: () => '/test/project',
|
||||
getIsNonInteractiveSession: () => false,
|
||||
addSlowOperation: noop,
|
||||
getSdkAgentProgressSummariesEnabled: () => false,
|
||||
getSessionId: () => 'test-session-001',
|
||||
getProjectRoot: () => '/test/project',
|
||||
getIsNonInteractiveSession: () => false,
|
||||
addSlowOperation: noop,
|
||||
}))
|
||||
|
||||
mock.module('src/services/PromptSuggestion/speculation.js', () => ({
|
||||
abortSpeculation: noop,
|
||||
abortSpeculation: noop,
|
||||
}))
|
||||
|
||||
const cleanupFns: (() => void)[] = []
|
||||
mock.module('src/utils/cleanupRegistry.js', () => ({
|
||||
registerCleanup: () => noop,
|
||||
registerCleanup: () => noop,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/abortController.js', () => ({
|
||||
createAbortController: () => new AbortController(),
|
||||
createChildAbortController: (parent: AbortController) => {
|
||||
const ac = new AbortController()
|
||||
parent.signal.addEventListener('abort', () => ac.abort())
|
||||
return ac
|
||||
},
|
||||
createAbortController: () => new AbortController(),
|
||||
createChildAbortController: (parent: AbortController) => {
|
||||
const ac = new AbortController()
|
||||
parent.signal.addEventListener('abort', () => ac.abort())
|
||||
return ac
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/task/sdkProgress.js', () => ({
|
||||
emitTaskProgress: noop,
|
||||
emitTaskProgress: noop,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/sdkEventQueue.js', () => ({
|
||||
enqueueSdkEvent: noop,
|
||||
enqueueSdkEvent: noop,
|
||||
}))
|
||||
|
||||
mock.module('src/constants/xml.js', () => ({
|
||||
TASK_NOTIFICATION_TAG: 'task_notification',
|
||||
TASK_ID_TAG: 'task_id',
|
||||
TOOL_USE_ID_TAG: 'tool_use_id',
|
||||
OUTPUT_FILE_TAG: 'output_file',
|
||||
STATUS_TAG: 'status',
|
||||
SUMMARY_TAG: 'summary',
|
||||
WORKTREE_TAG: 'worktree',
|
||||
WORKTREE_PATH_TAG: 'worktree_path',
|
||||
WORKTREE_BRANCH_TAG: 'worktree_branch',
|
||||
TASK_TYPE_TAG: 'task_type',
|
||||
TASK_NOTIFICATION_TAG: 'task_notification',
|
||||
TASK_ID_TAG: 'task_id',
|
||||
TOOL_USE_ID_TAG: 'tool_use_id',
|
||||
OUTPUT_FILE_TAG: 'output_file',
|
||||
STATUS_TAG: 'status',
|
||||
SUMMARY_TAG: 'summary',
|
||||
WORKTREE_TAG: 'worktree',
|
||||
WORKTREE_PATH_TAG: 'worktree_path',
|
||||
WORKTREE_BRANCH_TAG: 'worktree_branch',
|
||||
TASK_TYPE_TAG: 'task_type',
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: noop,
|
||||
logEventAsync: async () => {},
|
||||
stripProtoFields: (v: any) => v,
|
||||
attachAnalyticsSink: noop,
|
||||
_resetForTesting: noop,
|
||||
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined,
|
||||
logEvent: noop,
|
||||
logEventAsync: async () => {},
|
||||
stripProtoFields: (v: any) => v,
|
||||
attachAnalyticsSink: noop,
|
||||
_resetForTesting: noop,
|
||||
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/collapseReadSearch.js', () => ({
|
||||
getToolSearchOrReadInfo: () => undefined,
|
||||
getToolSearchOrReadInfo: () => undefined,
|
||||
}))
|
||||
|
||||
// ─── Import after mocks ───
|
||||
|
||||
const {
|
||||
createProgressTracker,
|
||||
updateProgressFromMessage,
|
||||
getProgressUpdate,
|
||||
completeAgentTask,
|
||||
failAgentTask,
|
||||
killAsyncAgent,
|
||||
enqueueAgentNotification,
|
||||
registerAsyncAgent,
|
||||
updateAgentProgress,
|
||||
isLocalAgentTask,
|
||||
createProgressTracker,
|
||||
updateProgressFromMessage,
|
||||
getProgressUpdate,
|
||||
completeAgentTask,
|
||||
failAgentTask,
|
||||
killAsyncAgent,
|
||||
enqueueAgentNotification,
|
||||
registerAsyncAgent,
|
||||
updateAgentProgress,
|
||||
isLocalAgentTask,
|
||||
} = await import('../LocalAgentTask.js')
|
||||
|
||||
// ─── Helpers ───
|
||||
@@ -112,376 +112,412 @@ type AppStateLike = { tasks: Record<string, any> }
|
||||
type SetAppStateLike = (f: (prev: AppStateLike) => AppStateLike) => void
|
||||
|
||||
function createSetAppState(initial: AppStateLike = { tasks: {} }): {
|
||||
setAppState: SetAppStateLike
|
||||
getState: () => AppStateLike
|
||||
setAppState: SetAppStateLike
|
||||
getState: () => AppStateLike
|
||||
} {
|
||||
let state = initial
|
||||
return {
|
||||
setAppState: (f) => {
|
||||
state = f(state)
|
||||
},
|
||||
getState: () => state,
|
||||
}
|
||||
let state = initial
|
||||
return {
|
||||
setAppState: f => {
|
||||
state = f(state)
|
||||
},
|
||||
getState: () => state,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRunningTask(overrides: Record<string, any> = {}): any {
|
||||
return {
|
||||
id: 'test-agent-001',
|
||||
type: 'local_agent',
|
||||
status: 'running',
|
||||
description: 'Test agent',
|
||||
agentId: 'test-agent-001',
|
||||
prompt: 'do something',
|
||||
agentType: 'general-purpose',
|
||||
abortController: new AbortController(),
|
||||
retrieved: false,
|
||||
lastReportedToolCount: 0,
|
||||
lastReportedTokenCount: 0,
|
||||
isBackgrounded: true,
|
||||
pendingMessages: [],
|
||||
retain: false,
|
||||
diskLoaded: false,
|
||||
notified: false,
|
||||
startTime: Date.now(),
|
||||
outputFile: '/tmp/output/test-agent-001',
|
||||
outputOffset: 0,
|
||||
...overrides,
|
||||
}
|
||||
return {
|
||||
id: 'test-agent-001',
|
||||
type: 'local_agent',
|
||||
status: 'running',
|
||||
description: 'Test agent',
|
||||
agentId: 'test-agent-001',
|
||||
prompt: 'do something',
|
||||
agentType: 'general-purpose',
|
||||
abortController: new AbortController(),
|
||||
retrieved: false,
|
||||
lastReportedToolCount: 0,
|
||||
lastReportedTokenCount: 0,
|
||||
isBackgrounded: true,
|
||||
pendingMessages: [],
|
||||
retain: false,
|
||||
diskLoaded: false,
|
||||
notified: false,
|
||||
startTime: Date.now(),
|
||||
outputFile: '/tmp/output/test-agent-001',
|
||||
outputOffset: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssistantMessage(usage: any, content: any[] = []): any {
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
usage,
|
||||
content,
|
||||
},
|
||||
}
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
usage,
|
||||
content,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
enqueuedNotifications.length = 0
|
||||
enqueuedNotifications.length = 0
|
||||
})
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
describe('createProgressTracker', () => {
|
||||
test('returns initial state with zero counts', () => {
|
||||
const tracker = createProgressTracker()
|
||||
expect(tracker.toolUseCount).toBe(0)
|
||||
expect(tracker.latestInputTokens).toBe(0)
|
||||
expect(tracker.cumulativeOutputTokens).toBe(0)
|
||||
expect(tracker.recentActivities).toEqual([])
|
||||
})
|
||||
test('returns initial state with zero counts', () => {
|
||||
const tracker = createProgressTracker()
|
||||
expect(tracker.toolUseCount).toBe(0)
|
||||
expect(tracker.latestInputTokens).toBe(0)
|
||||
expect(tracker.cumulativeOutputTokens).toBe(0)
|
||||
expect(tracker.recentActivities).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProgressFromMessage', () => {
|
||||
test('skips non-assistant messages', () => {
|
||||
const tracker = createProgressTracker()
|
||||
updateProgressFromMessage(tracker, { type: 'user', message: {} } as any)
|
||||
expect(tracker.toolUseCount).toBe(0)
|
||||
expect(tracker.latestInputTokens).toBe(0)
|
||||
})
|
||||
test('skips non-assistant messages', () => {
|
||||
const tracker = createProgressTracker()
|
||||
updateProgressFromMessage(tracker, { type: 'user', message: {} } as any)
|
||||
expect(tracker.toolUseCount).toBe(0)
|
||||
expect(tracker.latestInputTokens).toBe(0)
|
||||
})
|
||||
|
||||
test('updates token counts from assistant message usage', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const msg = makeAssistantMessage({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 30,
|
||||
})
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
expect(tracker.latestInputTokens).toBe(150) // 100 + 20 + 30
|
||||
expect(tracker.cumulativeOutputTokens).toBe(50)
|
||||
})
|
||||
test('updates token counts from assistant message usage', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const msg = makeAssistantMessage({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 30,
|
||||
})
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
expect(tracker.latestInputTokens).toBe(150) // 100 + 20 + 30
|
||||
expect(tracker.cumulativeOutputTokens).toBe(50)
|
||||
})
|
||||
|
||||
test('counts tool_use blocks and tracks recent activities', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }, [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/foo.ts' } },
|
||||
{ type: 'text', text: 'thinking...' },
|
||||
{ type: 'tool_use', name: 'Write', input: { file_path: '/bar.ts' } },
|
||||
])
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
expect(tracker.toolUseCount).toBe(2)
|
||||
expect(tracker.recentActivities).toHaveLength(2)
|
||||
expect(tracker.recentActivities[0]!.toolName).toBe('Read')
|
||||
expect(tracker.recentActivities[1]!.toolName).toBe('Write')
|
||||
})
|
||||
test('counts tool_use blocks and tracks recent activities', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }, [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/foo.ts' } },
|
||||
{ type: 'text', text: 'thinking...' },
|
||||
{ type: 'tool_use', name: 'Write', input: { file_path: '/bar.ts' } },
|
||||
])
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
expect(tracker.toolUseCount).toBe(2)
|
||||
expect(tracker.recentActivities).toHaveLength(2)
|
||||
expect(tracker.recentActivities[0]!.toolName).toBe('Read')
|
||||
expect(tracker.recentActivities[1]!.toolName).toBe('Write')
|
||||
})
|
||||
|
||||
test('caps recentActivities at 5', () => {
|
||||
const tracker = createProgressTracker()
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }, [
|
||||
{ type: 'tool_use', name: `Tool${i}`, input: {} },
|
||||
])
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
}
|
||||
expect(tracker.recentActivities).toHaveLength(5)
|
||||
})
|
||||
test('caps recentActivities at 5', () => {
|
||||
const tracker = createProgressTracker()
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }, [
|
||||
{ type: 'tool_use', name: `Tool${i}`, input: {} },
|
||||
])
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
}
|
||||
expect(tracker.recentActivities).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('skips without usage', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const msg = makeAssistantMessage(null)
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
expect(tracker.latestInputTokens).toBe(0)
|
||||
})
|
||||
test('skips without usage', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const msg = makeAssistantMessage(null)
|
||||
updateProgressFromMessage(tracker, msg)
|
||||
expect(tracker.latestInputTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProgressUpdate', () => {
|
||||
test('returns correct progress snapshot', () => {
|
||||
const tracker = createProgressTracker()
|
||||
tracker.toolUseCount = 3
|
||||
tracker.latestInputTokens = 100
|
||||
tracker.cumulativeOutputTokens = 50
|
||||
tracker.recentActivities.push({ toolName: 'Read', input: {} })
|
||||
test('returns correct progress snapshot', () => {
|
||||
const tracker = createProgressTracker()
|
||||
tracker.toolUseCount = 3
|
||||
tracker.latestInputTokens = 100
|
||||
tracker.cumulativeOutputTokens = 50
|
||||
tracker.recentActivities.push({ toolName: 'Read', input: {} })
|
||||
|
||||
const progress = getProgressUpdate(tracker)
|
||||
expect(progress.toolUseCount).toBe(3)
|
||||
expect(progress.tokenCount).toBe(150)
|
||||
expect(progress.lastActivity).toBeDefined()
|
||||
expect(progress.lastActivity!.toolName).toBe('Read')
|
||||
})
|
||||
const progress = getProgressUpdate(tracker)
|
||||
expect(progress.toolUseCount).toBe(3)
|
||||
expect(progress.tokenCount).toBe(150)
|
||||
expect(progress.lastActivity).toBeDefined()
|
||||
expect(progress.lastActivity!.toolName).toBe('Read')
|
||||
})
|
||||
|
||||
test('returns undefined lastActivity when no activities', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const progress = getProgressUpdate(tracker)
|
||||
expect(progress.lastActivity).toBeUndefined()
|
||||
})
|
||||
test('returns undefined lastActivity when no activities', () => {
|
||||
const tracker = createProgressTracker()
|
||||
const progress = getProgressUpdate(tracker)
|
||||
expect(progress.lastActivity).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeAgentTask', () => {
|
||||
test('transitions running task to completed', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask() },
|
||||
})
|
||||
test('transitions running task to completed', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask() },
|
||||
})
|
||||
|
||||
completeAgentTask(
|
||||
{ agentId: 'test-agent-001', content: [], totalToolUseCount: 0, totalDurationMs: 100 } as any,
|
||||
setAppState as any,
|
||||
)
|
||||
completeAgentTask(
|
||||
{
|
||||
agentId: 'test-agent-001',
|
||||
content: [],
|
||||
totalToolUseCount: 0,
|
||||
totalDurationMs: 100,
|
||||
} as any,
|
||||
setAppState as any,
|
||||
)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.endTime).toBeDefined()
|
||||
expect(task.evictAfter).toBeDefined()
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.endTime).toBeDefined()
|
||||
expect(task.evictAfter).toBeDefined()
|
||||
})
|
||||
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ status: 'completed' }) },
|
||||
})
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ status: 'completed' }) },
|
||||
})
|
||||
|
||||
completeAgentTask(
|
||||
{ agentId: 'test-agent-001', content: [], totalToolUseCount: 0, totalDurationMs: 100 } as any,
|
||||
setAppState as any,
|
||||
)
|
||||
completeAgentTask(
|
||||
{
|
||||
agentId: 'test-agent-001',
|
||||
content: [],
|
||||
totalToolUseCount: 0,
|
||||
totalDurationMs: 100,
|
||||
} as any,
|
||||
setAppState as any,
|
||||
)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('completed')
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('failAgentTask', () => {
|
||||
test('transitions running task to failed with error message', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask() },
|
||||
})
|
||||
test('transitions running task to failed with error message', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask() },
|
||||
})
|
||||
|
||||
failAgentTask('test-agent-001', 'Stream idle timeout', setAppState as any)
|
||||
failAgentTask('test-agent-001', 'Stream idle timeout', setAppState as any)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('failed')
|
||||
expect(task.error).toBe('Stream idle timeout')
|
||||
expect(task.endTime).toBeDefined()
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('failed')
|
||||
expect(task.error).toBe('Stream idle timeout')
|
||||
expect(task.endTime).toBeDefined()
|
||||
})
|
||||
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ status: 'killed' }) },
|
||||
})
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ status: 'killed' }) },
|
||||
})
|
||||
|
||||
failAgentTask('test-agent-001', 'error', setAppState as any)
|
||||
failAgentTask('test-agent-001', 'error', setAppState as any)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.error).toBeUndefined()
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('killAsyncAgent', () => {
|
||||
test('transitions running task to killed', () => {
|
||||
const ac = new AbortController()
|
||||
const cleanup = mock(() => {})
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ abortController: ac, unregisterCleanup: cleanup }) },
|
||||
})
|
||||
test('transitions running task to killed', () => {
|
||||
const ac = new AbortController()
|
||||
const cleanup = mock(() => {})
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: {
|
||||
'test-agent-001': makeRunningTask({
|
||||
abortController: ac,
|
||||
unregisterCleanup: cleanup,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
killAsyncAgent('test-agent-001', setAppState as any)
|
||||
killAsyncAgent('test-agent-001', setAppState as any)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('killed')
|
||||
expect(ac.signal.aborted).toBe(true)
|
||||
expect(cleanup).toHaveBeenCalled()
|
||||
expect(task.abortController).toBeUndefined()
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('killed')
|
||||
expect(ac.signal.aborted).toBe(true)
|
||||
expect(cleanup).toHaveBeenCalled()
|
||||
expect(task.abortController).toBeUndefined()
|
||||
})
|
||||
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ status: 'completed' }) },
|
||||
})
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ status: 'completed' }) },
|
||||
})
|
||||
|
||||
killAsyncAgent('test-agent-001', setAppState as any)
|
||||
killAsyncAgent('test-agent-001', setAppState as any)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('completed')
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.status).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enqueueAgentNotification', () => {
|
||||
test('enqueues completed notification with correct XML format', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
test('enqueues completed notification with correct XML format', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'refactor auth',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
finalMessage: 'Done!',
|
||||
usage: { totalTokens: 5000, toolUses: 3, durationMs: 10000 },
|
||||
})
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'refactor auth',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
finalMessage: 'Done!',
|
||||
usage: { totalTokens: 5000, toolUses: 3, durationMs: 10000 },
|
||||
})
|
||||
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
expect(enqueuedNotifications[0]).toContain('<task_notification>')
|
||||
expect(enqueuedNotifications[0]).toContain('<task_id>test-agent-001</task_id>')
|
||||
expect(enqueuedNotifications[0]).toContain('<status>completed</status>')
|
||||
expect(enqueuedNotifications[0]).toContain('Agent "refactor auth" completed')
|
||||
expect(enqueuedNotifications[0]).toContain('<result>Done!</result>')
|
||||
expect(enqueuedNotifications[0]).toContain('<total_tokens>5000</total_tokens>')
|
||||
})
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
expect(enqueuedNotifications[0]).toContain('<task_notification>')
|
||||
expect(enqueuedNotifications[0]).toContain(
|
||||
'<task_id>test-agent-001</task_id>',
|
||||
)
|
||||
expect(enqueuedNotifications[0]).toContain('<status>completed</status>')
|
||||
expect(enqueuedNotifications[0]).toContain(
|
||||
'Agent "refactor auth" completed',
|
||||
)
|
||||
expect(enqueuedNotifications[0]).toContain('<result>Done!</result>')
|
||||
expect(enqueuedNotifications[0]).toContain(
|
||||
'<total_tokens>5000</total_tokens>',
|
||||
)
|
||||
})
|
||||
|
||||
test('enqueues failed notification with error', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
test('enqueues failed notification with error', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'failed',
|
||||
error: 'Stream idle timeout',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'failed',
|
||||
error: 'Stream idle timeout',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
expect(enqueuedNotifications[0]).toContain('<status>failed</status>')
|
||||
expect(enqueuedNotifications[0]).toContain('Agent "test" failed: Stream idle timeout')
|
||||
})
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
expect(enqueuedNotifications[0]).toContain('<status>failed</status>')
|
||||
expect(enqueuedNotifications[0]).toContain(
|
||||
'Agent "test" failed: Stream idle timeout',
|
||||
)
|
||||
})
|
||||
|
||||
test('enqueues killed notification', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
test('enqueues killed notification', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'killed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'killed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
expect(enqueuedNotifications[0]).toContain('<status>killed</status>')
|
||||
expect(enqueuedNotifications[0]).toContain('Agent "test" was stopped')
|
||||
})
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
expect(enqueuedNotifications[0]).toContain('<status>killed</status>')
|
||||
expect(enqueuedNotifications[0]).toContain('Agent "test" was stopped')
|
||||
})
|
||||
|
||||
test('prevents duplicate notifications', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
test('prevents duplicate notifications', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: false }) },
|
||||
})
|
||||
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
|
||||
// Second call — notified flag already set by first call
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
// Second call — notified flag already set by first call
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
})
|
||||
expect(enqueuedNotifications).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('skips if task already notified', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: true }) },
|
||||
})
|
||||
test('skips if task already notified', () => {
|
||||
const { setAppState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ notified: true }) },
|
||||
})
|
||||
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
enqueueAgentNotification({
|
||||
taskId: 'test-agent-001',
|
||||
description: 'test',
|
||||
status: 'completed',
|
||||
setAppState: setAppState as any,
|
||||
})
|
||||
|
||||
expect(enqueuedNotifications).toHaveLength(0)
|
||||
})
|
||||
expect(enqueuedNotifications).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLocalAgentTask', () => {
|
||||
test('returns true for local_agent type', () => {
|
||||
expect(isLocalAgentTask(makeRunningTask())).toBe(true)
|
||||
})
|
||||
test('returns true for local_agent type', () => {
|
||||
expect(isLocalAgentTask(makeRunningTask())).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for other types', () => {
|
||||
expect(isLocalAgentTask({ type: 'local_bash' })).toBe(false)
|
||||
})
|
||||
test('returns false for other types', () => {
|
||||
expect(isLocalAgentTask({ type: 'local_bash' })).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for null/undefined', () => {
|
||||
expect(isLocalAgentTask(null)).toBe(false)
|
||||
expect(isLocalAgentTask(undefined)).toBe(false)
|
||||
})
|
||||
test('returns false for null/undefined', () => {
|
||||
expect(isLocalAgentTask(null)).toBe(false)
|
||||
expect(isLocalAgentTask(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateAgentProgress', () => {
|
||||
test('updates progress while preserving summary', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ progress: { summary: 'Working on auth' } }) },
|
||||
})
|
||||
test('updates progress while preserving summary', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: {
|
||||
'test-agent-001': makeRunningTask({
|
||||
progress: { summary: 'Working on auth' },
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
updateAgentProgress(
|
||||
'test-agent-001',
|
||||
{ toolUseCount: 5, tokenCount: 1000, lastActivity: { toolName: 'Write', input: {} } },
|
||||
setAppState as any,
|
||||
)
|
||||
updateAgentProgress(
|
||||
'test-agent-001',
|
||||
{
|
||||
toolUseCount: 5,
|
||||
tokenCount: 1000,
|
||||
lastActivity: { toolName: 'Write', input: {} },
|
||||
},
|
||||
setAppState as any,
|
||||
)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.progress.toolUseCount).toBe(5)
|
||||
expect(task.progress.tokenCount).toBe(1000)
|
||||
expect(task.progress.summary).toBe('Working on auth')
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.progress.toolUseCount).toBe(5)
|
||||
expect(task.progress.tokenCount).toBe(1000)
|
||||
expect(task.progress.summary).toBe('Working on auth')
|
||||
})
|
||||
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: { 'test-agent-001': makeRunningTask({ status: 'completed', progress: {} }) },
|
||||
})
|
||||
test('no-op if task not running', () => {
|
||||
const { setAppState, getState } = createSetAppState({
|
||||
tasks: {
|
||||
'test-agent-001': makeRunningTask({
|
||||
status: 'completed',
|
||||
progress: {},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
updateAgentProgress(
|
||||
'test-agent-001',
|
||||
{ toolUseCount: 5, tokenCount: 1000 },
|
||||
setAppState as any,
|
||||
)
|
||||
updateAgentProgress(
|
||||
'test-agent-001',
|
||||
{ toolUseCount: 5, tokenCount: 1000 },
|
||||
setAppState as any,
|
||||
)
|
||||
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.progress.toolUseCount).toBeUndefined()
|
||||
})
|
||||
const task = getState().tasks['test-agent-001']
|
||||
expect(task.progress.toolUseCount).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -210,7 +210,10 @@ export function completeMainSessionTask(
|
||||
// Set notified so evictTerminalTask/generateTaskAttachments eviction
|
||||
// guards pass; the backgrounded path sets this inside
|
||||
// enqueueMainSessionNotification's check-and-set.
|
||||
updateTaskState<LocalMainSessionTaskState>(taskId, setAppState, task => ({ ...task, notified: true }))
|
||||
updateTaskState<LocalMainSessionTaskState>(taskId, setAppState, task => ({
|
||||
...task,
|
||||
notified: true,
|
||||
}))
|
||||
emitTaskTerminatedSdk(taskId, success ? 'completed' : 'failed', {
|
||||
toolUseId,
|
||||
summary: 'Background session',
|
||||
@@ -388,10 +391,14 @@ export function startBackgroundSession({
|
||||
// Aborted mid-stream — completeMainSessionTask won't be reached.
|
||||
// chat:killAgents path already marked notified + emitted; stopTask path did not.
|
||||
let alreadyNotified = false
|
||||
updateTaskState<LocalMainSessionTaskState>(taskId, setAppState, task => {
|
||||
alreadyNotified = task.notified === true
|
||||
return alreadyNotified ? task : { ...task, notified: true }
|
||||
})
|
||||
updateTaskState<LocalMainSessionTaskState>(
|
||||
taskId,
|
||||
setAppState,
|
||||
task => {
|
||||
alreadyNotified = task.notified === true
|
||||
return alreadyNotified ? task : { ...task, notified: true }
|
||||
},
|
||||
)
|
||||
if (!alreadyNotified) {
|
||||
emitTaskTerminatedSdk(taskId, 'stopped', {
|
||||
summary: description,
|
||||
@@ -420,7 +427,12 @@ export function startBackgroundSession({
|
||||
lastRecordedUuid = msg.uuid
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
const contentBlocks = (msg.message?.content ?? []) as Array<{ type: string; text?: string; name?: string; input?: unknown }>
|
||||
const contentBlocks = (msg.message?.content ?? []) as Array<{
|
||||
type: string
|
||||
text?: string
|
||||
name?: string
|
||||
input?: unknown
|
||||
}>
|
||||
for (const block of contentBlocks) {
|
||||
if (block.type === 'text') {
|
||||
tokenCount += roughTokenCountEstimation(block.text ?? '')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { stat } from 'fs/promises'
|
||||
import { feature } from 'bun:bundle';
|
||||
import { stat } from 'fs/promises';
|
||||
import {
|
||||
OUTPUT_FILE_TAG,
|
||||
STATUS_TAG,
|
||||
@@ -7,47 +7,31 @@ import {
|
||||
TASK_ID_TAG,
|
||||
TASK_NOTIFICATION_TAG,
|
||||
TOOL_USE_ID_TAG,
|
||||
} from '../../constants/xml.js'
|
||||
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import type {
|
||||
LocalShellSpawnInput,
|
||||
SetAppState,
|
||||
Task,
|
||||
TaskContext,
|
||||
TaskHandle,
|
||||
} from '../../Task.js'
|
||||
import { createTaskStateBase } from '../../Task.js'
|
||||
import type { AgentId } from '../../types/ids.js'
|
||||
import { registerCleanup } from '../../utils/cleanupRegistry.js'
|
||||
import { tailFile } from '../../utils/fsOperations.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'
|
||||
import type { ShellCommand } from '../../utils/ShellCommand.js'
|
||||
import {
|
||||
evictTaskOutput,
|
||||
getTaskOutputPath,
|
||||
} from '../../utils/task/diskOutput.js'
|
||||
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
|
||||
import { escapeXml } from '../../utils/xml.js'
|
||||
import {
|
||||
backgroundAgentTask,
|
||||
isLocalAgentTask,
|
||||
} from '../LocalAgentTask/LocalAgentTask.js'
|
||||
import { isMainSessionTask } from '../LocalMainSessionTask.js'
|
||||
import {
|
||||
type BashTaskKind,
|
||||
isLocalShellTask,
|
||||
type LocalShellTaskState,
|
||||
} from './guards.js'
|
||||
import { killTask } from './killShellTasks.js'
|
||||
} from '../../constants/xml.js';
|
||||
import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js';
|
||||
import type { AppState } from '../../state/AppState.js';
|
||||
import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js';
|
||||
import { createTaskStateBase } from '../../Task.js';
|
||||
import type { AgentId } from '../../types/ids.js';
|
||||
import { registerCleanup } from '../../utils/cleanupRegistry.js';
|
||||
import { tailFile } from '../../utils/fsOperations.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
import { enqueuePendingNotification } from '../../utils/messageQueueManager.js';
|
||||
import type { ShellCommand } from '../../utils/ShellCommand.js';
|
||||
import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js';
|
||||
import { registerTask, updateTaskState } from '../../utils/task/framework.js';
|
||||
import { escapeXml } from '../../utils/xml.js';
|
||||
import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js';
|
||||
import { isMainSessionTask } from '../LocalMainSessionTask.js';
|
||||
import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js';
|
||||
import { killTask } from './killShellTasks.js';
|
||||
|
||||
/** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */
|
||||
export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '
|
||||
export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command ';
|
||||
|
||||
const STALL_CHECK_INTERVAL_MS = 5_000
|
||||
const STALL_THRESHOLD_MS = 45_000
|
||||
const STALL_TAIL_BYTES = 1024
|
||||
const STALL_CHECK_INTERVAL_MS = 5_000;
|
||||
const STALL_THRESHOLD_MS = 45_000;
|
||||
const STALL_TAIL_BYTES = 1024;
|
||||
|
||||
// Last-line patterns that suggest a command is blocked waiting for keyboard
|
||||
// input. Used to gate the stall notification — we stay silent on commands that
|
||||
@@ -61,11 +45,11 @@ const PROMPT_PATTERNS = [
|
||||
/Press (any key|Enter)/i,
|
||||
/Continue\?/i,
|
||||
/Overwrite\?/i,
|
||||
]
|
||||
];
|
||||
|
||||
export function looksLikePrompt(tail: string): boolean {
|
||||
const lastLine = tail.trimEnd().split('\n').pop() ?? ''
|
||||
return PROMPT_PATTERNS.some(p => p.test(lastLine))
|
||||
const lastLine = tail.trimEnd().split('\n').pop() ?? '';
|
||||
return PROMPT_PATTERNS.some(p => p.test(lastLine));
|
||||
}
|
||||
|
||||
// Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot
|
||||
@@ -77,38 +61,36 @@ function startStallWatchdog(
|
||||
toolUseId?: string,
|
||||
agentId?: AgentId,
|
||||
): () => void {
|
||||
if (kind === 'monitor') return () => {}
|
||||
const outputPath = getTaskOutputPath(taskId)
|
||||
let lastSize = 0
|
||||
let lastGrowth = Date.now()
|
||||
let cancelled = false
|
||||
if (kind === 'monitor') return () => {};
|
||||
const outputPath = getTaskOutputPath(taskId);
|
||||
let lastSize = 0;
|
||||
let lastGrowth = Date.now();
|
||||
let cancelled = false;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void stat(outputPath).then(
|
||||
s => {
|
||||
if (s.size > lastSize) {
|
||||
lastSize = s.size
|
||||
lastGrowth = Date.now()
|
||||
return
|
||||
lastSize = s.size;
|
||||
lastGrowth = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return
|
||||
if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return;
|
||||
void tailFile(outputPath, STALL_TAIL_BYTES).then(
|
||||
({ content }) => {
|
||||
if (cancelled) return
|
||||
if (cancelled) return;
|
||||
if (!looksLikePrompt(content)) {
|
||||
// Not a prompt — keep watching. Reset so the next check is
|
||||
// 45s out instead of re-reading the tail on every tick.
|
||||
lastGrowth = Date.now()
|
||||
return
|
||||
lastGrowth = Date.now();
|
||||
return;
|
||||
}
|
||||
// Latch before the async-boundary-visible side effects so an
|
||||
// overlapping tick's callback sees cancelled=true and bails.
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
const toolUseIdLine = toolUseId
|
||||
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
|
||||
: ''
|
||||
const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : '';
|
||||
const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`;
|
||||
// No <status> tag — print.ts treats <status> as a terminal
|
||||
// signal and an unknown value falls through to 'completed',
|
||||
// falsely closing the task for SDK consumers. Statusless
|
||||
@@ -121,26 +103,26 @@ function startStallWatchdog(
|
||||
Last output:
|
||||
${content.trimEnd()}
|
||||
|
||||
The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`
|
||||
The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`;
|
||||
enqueuePendingNotification({
|
||||
value: message,
|
||||
mode: 'task-notification',
|
||||
priority: 'next',
|
||||
agentId,
|
||||
})
|
||||
});
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
);
|
||||
},
|
||||
() => {}, // File may not exist yet
|
||||
)
|
||||
}, STALL_CHECK_INTERVAL_MS)
|
||||
timer.unref()
|
||||
);
|
||||
}, STALL_CHECK_INTERVAL_MS);
|
||||
timer.unref();
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
function enqueueShellNotification(
|
||||
@@ -156,25 +138,25 @@ function enqueueShellNotification(
|
||||
// Atomically check and set notified flag to prevent duplicate notifications.
|
||||
// If the task was already marked as notified (e.g., by TaskStopTool), skip
|
||||
// enqueueing to avoid sending redundant messages to the model.
|
||||
let shouldEnqueue = false
|
||||
let shouldEnqueue = false;
|
||||
updateTaskState(taskId, setAppState, task => {
|
||||
if (task.notified) {
|
||||
return task
|
||||
return task;
|
||||
}
|
||||
shouldEnqueue = true
|
||||
return { ...task, notified: true }
|
||||
})
|
||||
shouldEnqueue = true;
|
||||
return { ...task, notified: true };
|
||||
});
|
||||
|
||||
if (!shouldEnqueue) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any active speculation — background task state changed, so speculated
|
||||
// results may reference stale task output. The prompt suggestion text is
|
||||
// preserved; only the pre-computed response is discarded.
|
||||
abortSpeculation(setAppState)
|
||||
abortSpeculation(setAppState);
|
||||
|
||||
let summary: string
|
||||
let summary: string;
|
||||
if (feature('MONITOR_TOOL') && kind === 'monitor') {
|
||||
// Monitor is streaming-only (post-#22764) — the script exiting means
|
||||
// the stream ended, not "condition met". Distinct from the bash prefix
|
||||
@@ -182,70 +164,68 @@ function enqueueShellNotification(
|
||||
// completed" collapse.
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
summary = `Monitor "${description}" stream ended`
|
||||
break
|
||||
summary = `Monitor "${description}" stream ended`;
|
||||
break;
|
||||
case 'failed':
|
||||
summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`
|
||||
break
|
||||
summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`;
|
||||
break;
|
||||
case 'killed':
|
||||
summary = `Monitor "${description}" stopped`
|
||||
break
|
||||
summary = `Monitor "${description}" stopped`;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`
|
||||
break
|
||||
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`;
|
||||
break;
|
||||
case 'failed':
|
||||
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`
|
||||
break
|
||||
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`;
|
||||
break;
|
||||
case 'killed':
|
||||
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`
|
||||
break
|
||||
summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = getTaskOutputPath(taskId)
|
||||
const toolUseIdLine = toolUseId
|
||||
? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
|
||||
: ''
|
||||
const outputPath = getTaskOutputPath(taskId);
|
||||
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : '';
|
||||
const message = `<${TASK_NOTIFICATION_TAG}>
|
||||
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
|
||||
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
|
||||
<${STATUS_TAG}>${status}</${STATUS_TAG}>
|
||||
<${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
|
||||
</${TASK_NOTIFICATION_TAG}>`
|
||||
</${TASK_NOTIFICATION_TAG}>`;
|
||||
|
||||
enqueuePendingNotification({
|
||||
value: message,
|
||||
mode: 'task-notification',
|
||||
priority: feature('MONITOR_TOOL') ? 'next' : 'later',
|
||||
agentId,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export const LocalShellTask: Task = {
|
||||
name: 'LocalShellTask',
|
||||
type: 'local_bash',
|
||||
async kill(taskId, setAppState) {
|
||||
killTask(taskId, setAppState)
|
||||
killTask(taskId, setAppState);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export async function spawnShellTask(
|
||||
input: LocalShellSpawnInput & { shellCommand: ShellCommand },
|
||||
context: TaskContext,
|
||||
): Promise<TaskHandle> {
|
||||
const { command, description, shellCommand, toolUseId, agentId, kind } = input
|
||||
const { setAppState } = context
|
||||
const { command, description, shellCommand, toolUseId, agentId, kind } = input;
|
||||
const { setAppState } = context;
|
||||
|
||||
// TaskOutput owns the data — use its taskId so disk writes are consistent
|
||||
const { taskOutput } = shellCommand
|
||||
const taskId = taskOutput.taskId
|
||||
const { taskOutput } = shellCommand;
|
||||
const taskId = taskOutput.taskId;
|
||||
|
||||
const unregisterCleanup = registerCleanup(async () => {
|
||||
killTask(taskId, setAppState)
|
||||
})
|
||||
killTask(taskId, setAppState);
|
||||
});
|
||||
|
||||
const taskState: LocalShellTaskState = {
|
||||
...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
|
||||
@@ -259,31 +239,25 @@ export async function spawnShellTask(
|
||||
isBackgrounded: true,
|
||||
agentId,
|
||||
kind,
|
||||
}
|
||||
};
|
||||
|
||||
registerTask(taskState, setAppState)
|
||||
registerTask(taskState, setAppState);
|
||||
|
||||
// Data flows through TaskOutput automatically — no stream listeners needed.
|
||||
// Just transition to backgrounded state so the process keeps running.
|
||||
shellCommand.background(taskId)
|
||||
shellCommand.background(taskId);
|
||||
|
||||
const cancelStallWatchdog = startStallWatchdog(
|
||||
taskId,
|
||||
description,
|
||||
kind,
|
||||
toolUseId,
|
||||
agentId,
|
||||
)
|
||||
const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId);
|
||||
|
||||
void shellCommand.result.then(async result => {
|
||||
cancelStallWatchdog()
|
||||
await flushAndCleanup(shellCommand)
|
||||
let wasKilled = false
|
||||
cancelStallWatchdog();
|
||||
await flushAndCleanup(shellCommand);
|
||||
let wasKilled = false;
|
||||
|
||||
updateTaskState<LocalShellTaskState>(taskId, setAppState, task => {
|
||||
if (task.status === 'killed') {
|
||||
wasKilled = true
|
||||
return task
|
||||
wasKilled = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -293,8 +267,8 @@ export async function spawnShellTask(
|
||||
shellCommand: null,
|
||||
unregisterCleanup: undefined,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
enqueueShellNotification(
|
||||
taskId,
|
||||
@@ -305,17 +279,17 @@ export async function spawnShellTask(
|
||||
toolUseId,
|
||||
kind,
|
||||
agentId,
|
||||
)
|
||||
);
|
||||
|
||||
void evictTaskOutput(taskId)
|
||||
})
|
||||
void evictTaskOutput(taskId);
|
||||
});
|
||||
|
||||
return {
|
||||
taskId,
|
||||
cleanup: () => {
|
||||
unregisterCleanup()
|
||||
unregisterCleanup();
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,13 +302,13 @@ export function registerForeground(
|
||||
setAppState: SetAppState,
|
||||
toolUseId?: string,
|
||||
): string {
|
||||
const { command, description, shellCommand, agentId } = input
|
||||
const { command, description, shellCommand, agentId } = input;
|
||||
|
||||
const taskId = shellCommand.taskOutput.taskId
|
||||
const taskId = shellCommand.taskOutput.taskId;
|
||||
|
||||
const unregisterCleanup = registerCleanup(async () => {
|
||||
killTask(taskId, setAppState)
|
||||
})
|
||||
killTask(taskId, setAppState);
|
||||
});
|
||||
|
||||
const taskState: LocalShellTaskState = {
|
||||
...createTaskStateBase(taskId, 'local_bash', description, toolUseId),
|
||||
@@ -347,41 +321,37 @@ export function registerForeground(
|
||||
lastReportedTotalLines: 0,
|
||||
isBackgrounded: false, // Not yet backgrounded - running in foreground
|
||||
agentId,
|
||||
}
|
||||
};
|
||||
|
||||
registerTask(taskState, setAppState)
|
||||
return taskId
|
||||
registerTask(taskState, setAppState);
|
||||
return taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background a specific foreground task.
|
||||
* @returns true if backgrounded successfully, false otherwise
|
||||
*/
|
||||
function backgroundTask(
|
||||
taskId: string,
|
||||
getAppState: () => AppState,
|
||||
setAppState: SetAppState,
|
||||
): boolean {
|
||||
function backgroundTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean {
|
||||
// Step 1: Get the task and shell command from current state
|
||||
const state = getAppState()
|
||||
const task = state.tasks[taskId]
|
||||
const state = getAppState();
|
||||
const task = state.tasks[taskId];
|
||||
if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
const shellCommand = task.shellCommand
|
||||
const description = task.description
|
||||
const { toolUseId, kind, agentId } = task
|
||||
const shellCommand = task.shellCommand;
|
||||
const description = task.description;
|
||||
const { toolUseId, kind, agentId } = task;
|
||||
|
||||
// Transition to backgrounded — TaskOutput continues receiving data automatically
|
||||
if (!shellCommand.background(taskId)) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
setAppState(prev => {
|
||||
const prevTask = prev.tasks[taskId]
|
||||
const prevTask = prev.tasks[taskId];
|
||||
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
|
||||
return prev
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
@@ -389,32 +359,26 @@ function backgroundTask(
|
||||
...prev.tasks,
|
||||
[taskId]: { ...prevTask, isBackgrounded: true },
|
||||
},
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const cancelStallWatchdog = startStallWatchdog(
|
||||
taskId,
|
||||
description,
|
||||
kind,
|
||||
toolUseId,
|
||||
agentId,
|
||||
)
|
||||
const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId);
|
||||
|
||||
// Set up result handler
|
||||
void shellCommand.result.then(async result => {
|
||||
cancelStallWatchdog()
|
||||
await flushAndCleanup(shellCommand)
|
||||
let wasKilled = false
|
||||
let cleanupFn: (() => void) | undefined
|
||||
cancelStallWatchdog();
|
||||
await flushAndCleanup(shellCommand);
|
||||
let wasKilled = false;
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
|
||||
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
|
||||
if (t.status === 'killed') {
|
||||
wasKilled = true
|
||||
return t
|
||||
wasKilled = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
// Capture cleanup function to call outside of updater
|
||||
cleanupFn = t.unregisterCleanup
|
||||
cleanupFn = t.unregisterCleanup;
|
||||
|
||||
return {
|
||||
...t,
|
||||
@@ -423,41 +387,23 @@ function backgroundTask(
|
||||
shellCommand: null,
|
||||
unregisterCleanup: undefined,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Call cleanup outside of the state updater (avoid side effects in updater)
|
||||
cleanupFn?.()
|
||||
cleanupFn?.();
|
||||
|
||||
if (wasKilled) {
|
||||
enqueueShellNotification(
|
||||
taskId,
|
||||
description,
|
||||
'killed',
|
||||
result.code,
|
||||
setAppState,
|
||||
toolUseId,
|
||||
kind,
|
||||
agentId,
|
||||
)
|
||||
enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId);
|
||||
} else {
|
||||
const finalStatus = result.code === 0 ? 'completed' : 'failed'
|
||||
enqueueShellNotification(
|
||||
taskId,
|
||||
description,
|
||||
finalStatus,
|
||||
result.code,
|
||||
setAppState,
|
||||
toolUseId,
|
||||
kind,
|
||||
agentId,
|
||||
)
|
||||
const finalStatus = result.code === 0 ? 'completed' : 'failed';
|
||||
enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId);
|
||||
}
|
||||
|
||||
void evictTaskOutput(taskId)
|
||||
})
|
||||
void evictTaskOutput(taskId);
|
||||
});
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,42 +417,35 @@ function backgroundTask(
|
||||
export function hasForegroundTasks(state: AppState): boolean {
|
||||
return Object.values(state.tasks).some(task => {
|
||||
if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
// Exclude main session tasks - they display in the main view, not as foreground tasks
|
||||
if (
|
||||
isLocalAgentTask(task) &&
|
||||
!task.isBackgrounded &&
|
||||
!isMainSessionTask(task)
|
||||
) {
|
||||
return true
|
||||
if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) {
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
})
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function backgroundAll(
|
||||
getAppState: () => AppState,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
const state = getAppState()
|
||||
export function backgroundAll(getAppState: () => AppState, setAppState: SetAppState): void {
|
||||
const state = getAppState();
|
||||
|
||||
// Background all foreground bash tasks
|
||||
const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => {
|
||||
const task = state.tasks[id]
|
||||
return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand
|
||||
})
|
||||
const task = state.tasks[id];
|
||||
return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand;
|
||||
});
|
||||
for (const taskId of foregroundBashTaskIds) {
|
||||
backgroundTask(taskId, getAppState, setAppState)
|
||||
backgroundTask(taskId, getAppState, setAppState);
|
||||
}
|
||||
|
||||
// Background all foreground agent tasks
|
||||
const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => {
|
||||
const task = state.tasks[id]
|
||||
return isLocalAgentTask(task) && !task.isBackgrounded
|
||||
})
|
||||
const task = state.tasks[id];
|
||||
return isLocalAgentTask(task) && !task.isBackgrounded;
|
||||
});
|
||||
for (const taskId of foregroundAgentTaskIds) {
|
||||
backgroundAgentTask(taskId, getAppState, setAppState)
|
||||
backgroundAgentTask(taskId, getAppState, setAppState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,46 +465,40 @@ export function backgroundExistingForegroundTask(
|
||||
toolUseId?: string,
|
||||
): boolean {
|
||||
if (!shellCommand.background(taskId)) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
let agentId: AgentId | undefined
|
||||
let agentId: AgentId | undefined;
|
||||
setAppState(prev => {
|
||||
const prevTask = prev.tasks[taskId]
|
||||
const prevTask = prev.tasks[taskId];
|
||||
if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) {
|
||||
return prev
|
||||
return prev;
|
||||
}
|
||||
agentId = prevTask.agentId
|
||||
agentId = prevTask.agentId;
|
||||
return {
|
||||
...prev,
|
||||
tasks: {
|
||||
...prev.tasks,
|
||||
[taskId]: { ...prevTask, isBackgrounded: true },
|
||||
},
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const cancelStallWatchdog = startStallWatchdog(
|
||||
taskId,
|
||||
description,
|
||||
undefined,
|
||||
toolUseId,
|
||||
agentId,
|
||||
)
|
||||
const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId);
|
||||
|
||||
// Set up result handler (mirrors backgroundTask's handler)
|
||||
void shellCommand.result.then(async result => {
|
||||
cancelStallWatchdog()
|
||||
await flushAndCleanup(shellCommand)
|
||||
let wasKilled = false
|
||||
let cleanupFn: (() => void) | undefined
|
||||
cancelStallWatchdog();
|
||||
await flushAndCleanup(shellCommand);
|
||||
let wasKilled = false;
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
|
||||
updateTaskState<LocalShellTaskState>(taskId, setAppState, t => {
|
||||
if (t.status === 'killed') {
|
||||
wasKilled = true
|
||||
return t
|
||||
wasKilled = true;
|
||||
return t;
|
||||
}
|
||||
cleanupFn = t.unregisterCleanup
|
||||
cleanupFn = t.unregisterCleanup;
|
||||
return {
|
||||
...t,
|
||||
status: result.code === 0 ? 'completed' : 'failed',
|
||||
@@ -573,31 +506,18 @@ export function backgroundExistingForegroundTask(
|
||||
shellCommand: null,
|
||||
unregisterCleanup: undefined,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
cleanupFn?.()
|
||||
cleanupFn?.();
|
||||
|
||||
const finalStatus = wasKilled
|
||||
? 'killed'
|
||||
: result.code === 0
|
||||
? 'completed'
|
||||
: 'failed'
|
||||
enqueueShellNotification(
|
||||
taskId,
|
||||
description,
|
||||
finalStatus,
|
||||
result.code,
|
||||
setAppState,
|
||||
toolUseId,
|
||||
undefined,
|
||||
agentId,
|
||||
)
|
||||
const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed';
|
||||
enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId);
|
||||
|
||||
void evictTaskOutput(taskId)
|
||||
})
|
||||
void evictTaskOutput(taskId);
|
||||
});
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -605,47 +525,39 @@ export function backgroundExistingForegroundTask(
|
||||
* Used when backgrounding raced with completion — the tool result already
|
||||
* carries the full output, so the <task_notification> would be redundant.
|
||||
*/
|
||||
export function markTaskNotified(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
updateTaskState(taskId, setAppState, t =>
|
||||
t.notified ? t : { ...t, notified: true },
|
||||
)
|
||||
export function markTaskNotified(taskId: string, setAppState: SetAppState): void {
|
||||
updateTaskState(taskId, setAppState, t => (t.notified ? t : { ...t, notified: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a foreground task when the command completes without being backgrounded.
|
||||
*/
|
||||
export function unregisterForeground(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
let cleanupFn: (() => void) | undefined
|
||||
export function unregisterForeground(taskId: string, setAppState: SetAppState): void {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
|
||||
setAppState(prev => {
|
||||
const task = prev.tasks[taskId]
|
||||
const task = prev.tasks[taskId];
|
||||
// Only remove if it's a foreground task (not backgrounded)
|
||||
if (!isLocalShellTask(task) || task.isBackgrounded) {
|
||||
return prev
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Capture cleanup function to call outside of updater
|
||||
cleanupFn = task.unregisterCleanup
|
||||
cleanupFn = task.unregisterCleanup;
|
||||
|
||||
const { [taskId]: removed, ...rest } = prev.tasks
|
||||
return { ...prev, tasks: rest }
|
||||
})
|
||||
const { [taskId]: removed, ...rest } = prev.tasks;
|
||||
return { ...prev, tasks: rest };
|
||||
});
|
||||
|
||||
// Call cleanup outside of the state updater (avoid side effects in updater)
|
||||
cleanupFn?.()
|
||||
cleanupFn?.();
|
||||
}
|
||||
|
||||
async function flushAndCleanup(shellCommand: ShellCommand): Promise<void> {
|
||||
try {
|
||||
await shellCommand.taskOutput.flush()
|
||||
shellCommand.cleanup()
|
||||
await shellCommand.taskOutput.flush();
|
||||
shellCommand.cleanup();
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
logError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,12 @@ export function registerLocalWorkflowTask(
|
||||
): string {
|
||||
const id = generateTaskId('local_workflow')
|
||||
const task: LocalWorkflowTaskState = {
|
||||
...createTaskStateBase(id, 'local_workflow', opts.description, opts.toolUseId),
|
||||
...createTaskStateBase(
|
||||
id,
|
||||
'local_workflow',
|
||||
opts.description,
|
||||
opts.toolUseId,
|
||||
),
|
||||
type: 'local_workflow',
|
||||
status: 'running',
|
||||
workflowName: opts.workflowName,
|
||||
|
||||
@@ -87,10 +87,7 @@ export function failMonitorMcpTask(
|
||||
}))
|
||||
}
|
||||
|
||||
export function killMonitorMcp(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
export function killMonitorMcp(taskId: string, setAppState: SetAppState): void {
|
||||
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') return task
|
||||
task.abortController?.abort()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user