runner: narrow Claude task progress subagent mapping
This commit is contained in:
@@ -48,6 +48,12 @@ import {
|
||||
type RunnerOutput,
|
||||
writeOutput,
|
||||
} from './output-protocol.js';
|
||||
import {
|
||||
TopLevelAgentTaskTracker,
|
||||
buildTaskNotificationOutput,
|
||||
buildTaskProgressOutput,
|
||||
buildTaskStartedOutput,
|
||||
} from './task-progress-mapping.js';
|
||||
import {
|
||||
createPreCompactHook,
|
||||
createReviewerBashGuardHook,
|
||||
@@ -153,6 +159,7 @@ async function runQuery(
|
||||
let resultCount = 0;
|
||||
let terminalResultObserved = false;
|
||||
let pendingProgressText: string | null = null;
|
||||
const trackedAgentTasks = new TopLevelAgentTaskTracker();
|
||||
|
||||
// Discover additional directories
|
||||
const extraDirs: string[] = [];
|
||||
@@ -366,25 +373,20 @@ async function runQuery(
|
||||
) {
|
||||
const tn = message as {
|
||||
task_id: string;
|
||||
tool_use_id?: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
};
|
||||
log(
|
||||
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
|
||||
);
|
||||
if (
|
||||
tn.status === 'completed' ||
|
||||
tn.status === 'error' ||
|
||||
tn.status === 'cancelled'
|
||||
) {
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
agentId: tn.task_id,
|
||||
agentDone: true,
|
||||
result: tn.summary || null,
|
||||
newSessionId,
|
||||
});
|
||||
const mapped = buildTaskNotificationOutput(
|
||||
trackedAgentTasks,
|
||||
tn,
|
||||
newSessionId,
|
||||
);
|
||||
if (mapped) {
|
||||
writeOutput(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,20 +395,16 @@ async function runQuery(
|
||||
(message as { subtype?: string }).subtype === 'task_progress'
|
||||
) {
|
||||
const tp = message as Record<string, unknown>;
|
||||
const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined;
|
||||
const summary = typeof tp.summary === 'string' ? tp.summary : '';
|
||||
const description =
|
||||
typeof tp.description === 'string' ? tp.description : '';
|
||||
if (description && description.length <= 80) {
|
||||
// Short tool description → show as sub-line in progress
|
||||
const normalized = normalizeStructuredOutput(description);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
phase: 'tool-activity',
|
||||
...normalized,
|
||||
agentId: taskId,
|
||||
newSessionId,
|
||||
});
|
||||
const mapped = buildTaskProgressOutput(
|
||||
trackedAgentTasks,
|
||||
tp,
|
||||
newSessionId,
|
||||
);
|
||||
if (mapped) {
|
||||
writeOutput(mapped);
|
||||
} else if (description) {
|
||||
// Long AI summary → skip (too long for progress sub-line)
|
||||
log(
|
||||
@@ -422,16 +420,13 @@ async function runQuery(
|
||||
const ts = message as { task_id: string; description?: string };
|
||||
const desc = ts.description || '';
|
||||
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
|
||||
if (desc) {
|
||||
const normalized = normalizeStructuredOutput(`🔄 ${desc}`);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
...normalized,
|
||||
agentId: ts.task_id,
|
||||
agentLabel: desc,
|
||||
newSessionId,
|
||||
});
|
||||
const mapped = buildTaskStartedOutput(
|
||||
trackedAgentTasks,
|
||||
ts,
|
||||
newSessionId,
|
||||
);
|
||||
if (mapped) {
|
||||
writeOutput(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +537,7 @@ async function runQuery(
|
||||
}
|
||||
|
||||
if (message.type === 'assistant') {
|
||||
trackedAgentTasks.rememberAssistantMessage(message);
|
||||
const stopReason = (message as { stop_reason?: string }).stop_reason;
|
||||
const textResult = extractAssistantText(message);
|
||||
// Only log when there's something interesting (text or terminal)
|
||||
|
||||
188
runners/agent-runner/src/task-progress-mapping.ts
Normal file
188
runners/agent-runner/src/task-progress-mapping.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
normalizeStructuredOutput,
|
||||
type RunnerOutput,
|
||||
} from './output-protocol.js';
|
||||
|
||||
type AssistantToolUseBlock = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
caller?: { type?: unknown } | null;
|
||||
input?: { description?: unknown } | null;
|
||||
};
|
||||
|
||||
type AssistantMessageLike = {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
content?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type TaskLike = {
|
||||
task_id?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
description?: unknown;
|
||||
status?: unknown;
|
||||
summary?: unknown;
|
||||
};
|
||||
|
||||
function toNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getTaskText(message: TaskLike): {
|
||||
taskId?: string;
|
||||
toolUseId?: string;
|
||||
description?: string;
|
||||
summary?: string;
|
||||
} {
|
||||
return {
|
||||
taskId: toNonEmptyString(message.task_id),
|
||||
toolUseId: toNonEmptyString(message.tool_use_id),
|
||||
description: toNonEmptyString(message.description),
|
||||
summary: toNonEmptyString(message.summary),
|
||||
};
|
||||
}
|
||||
|
||||
export class TopLevelAgentTaskTracker {
|
||||
private readonly topLevelToolUses = new Map<string, string>();
|
||||
private readonly taskLabels = new Map<string, string>();
|
||||
|
||||
rememberAssistantMessage(message: unknown): void {
|
||||
const assistant = message as AssistantMessageLike;
|
||||
const blocks = assistant.message?.content;
|
||||
if (!Array.isArray(blocks)) return;
|
||||
|
||||
for (const block of blocks) {
|
||||
const toolUse = block as AssistantToolUseBlock;
|
||||
if (toolUse.type !== 'tool_use' || toolUse.name !== 'Agent') continue;
|
||||
|
||||
const toolUseId = toNonEmptyString(toolUse.id);
|
||||
if (!toolUseId) continue;
|
||||
|
||||
const callerType = toolUse.caller?.type;
|
||||
if (callerType !== undefined && callerType !== 'direct') continue;
|
||||
|
||||
const description =
|
||||
toNonEmptyString(toolUse.input?.description) || '서브에이전트 작업';
|
||||
this.topLevelToolUses.set(toolUseId, description);
|
||||
}
|
||||
}
|
||||
|
||||
resolveTask(message: TaskLike): {
|
||||
tracked: boolean;
|
||||
taskId?: string;
|
||||
label?: string;
|
||||
} {
|
||||
const { taskId, toolUseId, description } = getTaskText(message);
|
||||
if (!taskId) {
|
||||
return { tracked: false };
|
||||
}
|
||||
|
||||
const existing = this.taskLabels.get(taskId);
|
||||
if (existing) {
|
||||
return { tracked: true, taskId, label: existing };
|
||||
}
|
||||
|
||||
if (toolUseId) {
|
||||
const topLevelLabel = this.topLevelToolUses.get(toolUseId);
|
||||
if (topLevelLabel) {
|
||||
const label = topLevelLabel || description || '서브에이전트 작업';
|
||||
this.taskLabels.set(taskId, label);
|
||||
return { tracked: true, taskId, label };
|
||||
}
|
||||
}
|
||||
|
||||
return { tracked: false, taskId, label: description };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTaskStartedOutput(
|
||||
tracker: TopLevelAgentTaskTracker,
|
||||
message: TaskLike,
|
||||
newSessionId?: string,
|
||||
): RunnerOutput | null {
|
||||
const { description } = getTaskText(message);
|
||||
if (!description) return null;
|
||||
|
||||
const resolved = tracker.resolveTask(message);
|
||||
const label = resolved.label || description;
|
||||
const normalized = normalizeStructuredOutput(`🔄 ${label}`);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
...normalized,
|
||||
...(resolved.tracked && resolved.taskId
|
||||
? {
|
||||
agentId: resolved.taskId,
|
||||
agentLabel: label,
|
||||
}
|
||||
: {}),
|
||||
newSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTaskProgressOutput(
|
||||
tracker: TopLevelAgentTaskTracker,
|
||||
message: TaskLike,
|
||||
newSessionId?: string,
|
||||
): RunnerOutput | null {
|
||||
const { description } = getTaskText(message);
|
||||
if (!description || description.length > 80) return null;
|
||||
|
||||
const resolved = tracker.resolveTask(message);
|
||||
const normalized = normalizeStructuredOutput(description);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
phase: 'tool-activity',
|
||||
...normalized,
|
||||
...(resolved.tracked && resolved.taskId
|
||||
? {
|
||||
agentId: resolved.taskId,
|
||||
}
|
||||
: {}),
|
||||
newSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTaskNotificationOutput(
|
||||
tracker: TopLevelAgentTaskTracker,
|
||||
message: TaskLike,
|
||||
newSessionId?: string,
|
||||
): RunnerOutput | null {
|
||||
const { summary } = getTaskText(message);
|
||||
const status = toNonEmptyString(message.status);
|
||||
if (!status) return null;
|
||||
if (
|
||||
status !== 'completed' &&
|
||||
status !== 'failed' &&
|
||||
status !== 'stopped' &&
|
||||
status !== 'error' &&
|
||||
status !== 'cancelled'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = tracker.resolveTask(message);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
...(summary !== undefined
|
||||
? {
|
||||
...normalizeStructuredOutput(summary),
|
||||
}
|
||||
: { result: null }),
|
||||
...(resolved.tracked && resolved.taskId
|
||||
? {
|
||||
agentId: resolved.taskId,
|
||||
agentDone: true,
|
||||
}
|
||||
: {}),
|
||||
newSessionId,
|
||||
};
|
||||
}
|
||||
128
runners/agent-runner/test/task-progress-mapping.test.ts
Normal file
128
runners/agent-runner/test/task-progress-mapping.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
TopLevelAgentTaskTracker,
|
||||
buildTaskNotificationOutput,
|
||||
buildTaskProgressOutput,
|
||||
buildTaskStartedOutput,
|
||||
} from '../src/task-progress-mapping.js';
|
||||
|
||||
describe('task progress mapping', () => {
|
||||
it('tracks only top-level Agent tool uses as subagents', () => {
|
||||
const tracker = new TopLevelAgentTaskTracker();
|
||||
|
||||
tracker.rememberAssistantMessage({
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_top',
|
||||
name: 'Agent',
|
||||
caller: { type: 'direct' },
|
||||
input: { description: 'Find subagent enabling mechanism' },
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_nested',
|
||||
name: 'Agent',
|
||||
caller: { type: 'subagent' },
|
||||
input: { description: 'Nested helper task' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const started = buildTaskStartedOutput(
|
||||
tracker,
|
||||
{
|
||||
task_id: 'task-top',
|
||||
tool_use_id: 'toolu_top',
|
||||
description: 'Find subagent enabling mechanism',
|
||||
},
|
||||
'session-1',
|
||||
);
|
||||
|
||||
const nested = buildTaskStartedOutput(
|
||||
tracker,
|
||||
{
|
||||
task_id: 'task-nested',
|
||||
tool_use_id: 'toolu_nested',
|
||||
description: 'Nested helper task',
|
||||
},
|
||||
'session-1',
|
||||
);
|
||||
|
||||
expect(started?.agentId).toBe('task-top');
|
||||
expect(started?.agentLabel).toBe('Find subagent enabling mechanism');
|
||||
expect(nested?.agentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps task progress attached to the tracked top-level subagent', () => {
|
||||
const tracker = new TopLevelAgentTaskTracker();
|
||||
|
||||
tracker.rememberAssistantMessage({
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_top',
|
||||
name: 'Agent',
|
||||
caller: { type: 'direct' },
|
||||
input: { description: 'Find codex config.toml location' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
buildTaskStartedOutput(tracker, {
|
||||
task_id: 'task-top',
|
||||
tool_use_id: 'toolu_top',
|
||||
description: 'Find codex config.toml location',
|
||||
});
|
||||
|
||||
const progress = buildTaskProgressOutput(tracker, {
|
||||
task_id: 'task-top',
|
||||
description: 'Extract exact hide_spawn_agent field names',
|
||||
});
|
||||
|
||||
const notification = buildTaskNotificationOutput(tracker, {
|
||||
task_id: 'task-top',
|
||||
status: 'failed',
|
||||
summary: 'Task failed',
|
||||
});
|
||||
|
||||
expect(progress?.agentId).toBe('task-top');
|
||||
expect(progress?.result).toBe('Extract exact hide_spawn_agent field names');
|
||||
expect(notification?.agentId).toBe('task-top');
|
||||
expect(notification?.agentDone).toBe(true);
|
||||
});
|
||||
|
||||
it('flattens untracked internal task progress into generic progress lines', () => {
|
||||
const tracker = new TopLevelAgentTaskTracker();
|
||||
|
||||
const started = buildTaskStartedOutput(tracker, {
|
||||
task_id: 'task-internal',
|
||||
tool_use_id: 'toolu_internal',
|
||||
description: 'Search Codex package for hide_spawn_agent config',
|
||||
});
|
||||
|
||||
const progress = buildTaskProgressOutput(tracker, {
|
||||
task_id: 'task-internal',
|
||||
tool_use_id: 'toolu_internal',
|
||||
description: 'Extract exact hide_spawn_agent field names',
|
||||
});
|
||||
|
||||
const notification = buildTaskNotificationOutput(tracker, {
|
||||
task_id: 'task-internal',
|
||||
tool_use_id: 'toolu_internal',
|
||||
status: 'completed',
|
||||
summary: 'Finished searching config',
|
||||
});
|
||||
|
||||
expect(started?.agentId).toBeUndefined();
|
||||
expect(progress?.agentId).toBeUndefined();
|
||||
expect(progress?.result).toBe('Extract exact hide_spawn_agent field names');
|
||||
expect(notification?.agentId).toBeUndefined();
|
||||
expect(notification?.agentDone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user