Simplify Claude agent session flow
This commit is contained in:
@@ -0,0 +1,414 @@
|
|||||||
|
import { describe, expect, test, beforeAll } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgentSession,
|
||||||
|
AgentStreamEvent,
|
||||||
|
ToolCallTimelineItem,
|
||||||
|
} from "../agent-sdk-types.js";
|
||||||
|
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||||
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
|
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
|
||||||
|
function tmpCwd(prefix: string): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(value: string): string {
|
||||||
|
return value.replace(/\s+/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||||
|
return (
|
||||||
|
event.type === "turn_completed" ||
|
||||||
|
event.type === "turn_failed" ||
|
||||||
|
event.type === "turn_canceled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextStreamEvent(
|
||||||
|
stream: AsyncGenerator<AgentStreamEvent>,
|
||||||
|
timeoutMs: number,
|
||||||
|
label: string
|
||||||
|
): Promise<IteratorResult<AgentStreamEvent>> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
stream.next(),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
reject(new Error(`Timed out waiting for ${label}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUntilTerminal(
|
||||||
|
stream: AsyncGenerator<AgentStreamEvent>,
|
||||||
|
options?: {
|
||||||
|
timeoutMs?: number;
|
||||||
|
onEvent?: (event: AgentStreamEvent) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
): Promise<AgentStreamEvent[]> {
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
while (true) {
|
||||||
|
const next = await nextStreamEvent(
|
||||||
|
stream,
|
||||||
|
options?.timeoutMs ?? 45_000,
|
||||||
|
"stream event"
|
||||||
|
);
|
||||||
|
if (next.done || !next.value) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
const event = next.value;
|
||||||
|
events.push(event);
|
||||||
|
await options?.onEvent?.(event);
|
||||||
|
if (isTerminalEvent(event)) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUntil(
|
||||||
|
stream: AsyncGenerator<AgentStreamEvent>,
|
||||||
|
predicate: (event: AgentStreamEvent) => boolean,
|
||||||
|
timeoutMs = 45_000
|
||||||
|
): Promise<AgentStreamEvent[]> {
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
while (true) {
|
||||||
|
const next = await nextStreamEvent(stream, timeoutMs, "matching stream event");
|
||||||
|
if (next.done || !next.value) {
|
||||||
|
throw new Error("Stream ended before the expected event arrived");
|
||||||
|
}
|
||||||
|
const event = next.value;
|
||||||
|
events.push(event);
|
||||||
|
if (predicate(event) || isTerminalEvent(event)) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantText(events: AgentStreamEvent[]): string {
|
||||||
|
return events
|
||||||
|
.flatMap((event) => {
|
||||||
|
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [event.item.text];
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCalls(events: AgentStreamEvent[]): ToolCallTimelineItem[] {
|
||||||
|
return events.flatMap((event) => {
|
||||||
|
if (event.type !== "timeline" || event.item.type !== "tool_call") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [event.item];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestCompletedBashCall(
|
||||||
|
events: AgentStreamEvent[]
|
||||||
|
): ToolCallTimelineItem | undefined {
|
||||||
|
return [...getToolCalls(events)]
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
item.status === "completed" &&
|
||||||
|
item.name.toLowerCase() === "bash"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInternalQuery(session: AgentSession): unknown {
|
||||||
|
return (session as AgentSession & { query?: unknown | null }).query ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(params?: {
|
||||||
|
cwdPrefix?: string;
|
||||||
|
modeId?: string;
|
||||||
|
title?: string;
|
||||||
|
}): Promise<{ cwd: string; session: AgentSession }> {
|
||||||
|
const cwd = tmpCwd(params?.cwdPrefix ?? "claude-agent-integration-");
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
title: params?.title ?? "ClaudeAgentSession integration",
|
||||||
|
modeId: params?.modeId ?? "acceptEdits",
|
||||||
|
model: "haiku",
|
||||||
|
});
|
||||||
|
return { cwd, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupSession(handle: {
|
||||||
|
cwd: string;
|
||||||
|
session: AgentSession;
|
||||||
|
}): Promise<void> {
|
||||||
|
await handle.session.close().catch(() => undefined);
|
||||||
|
rmSync(handle.cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClaudeAgentSession integration", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
expect(isCommandAvailable("claude")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"streams a basic response turn end-to-end",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-basic-response-",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectUntilTerminal(
|
||||||
|
handle.session.stream("Respond with exactly: HELLO_WORLD")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "turn_started",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
events.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "assistant_message" &&
|
||||||
|
compactText(event.item.text).includes("hello_world")
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"runs a real Bash tool call and completes it",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-basic-tool-",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectUntilTerminal(
|
||||||
|
handle.session.stream(
|
||||||
|
[
|
||||||
|
"Use the Bash tool.",
|
||||||
|
"Run exactly: echo TOOL_TEST_OUTPUT",
|
||||||
|
"After the command completes, reply with exactly: TOOL_DONE",
|
||||||
|
].join(" ")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const bashCalls = getToolCalls(events).filter(
|
||||||
|
(item) => item.name.toLowerCase() === "bash"
|
||||||
|
);
|
||||||
|
const completedBashCall = getLatestCompletedBashCall(events);
|
||||||
|
|
||||||
|
expect(bashCalls.length).toBeGreaterThan(0);
|
||||||
|
expect(completedBashCall).toBeDefined();
|
||||||
|
expect(completedBashCall?.detail.type).toBe("shell");
|
||||||
|
expect(
|
||||||
|
completedBashCall?.detail.type === "shell" &&
|
||||||
|
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT")
|
||||||
|
).toBe(true);
|
||||||
|
expect(compactText(getAssistantText(events))).toContain("tool_done");
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"interrupts a running Bash turn and continues on the same query",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-interrupt-continue-",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstStream = handle.session.stream(
|
||||||
|
[
|
||||||
|
"Use the Bash tool.",
|
||||||
|
"Run exactly: sleep 10",
|
||||||
|
"Do not use a background task.",
|
||||||
|
"Do not do anything after starting the command.",
|
||||||
|
].join(" ")
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialEvents = await collectUntil(
|
||||||
|
firstStream,
|
||||||
|
(event) =>
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "tool_call" &&
|
||||||
|
event.item.name.toLowerCase() === "bash",
|
||||||
|
45_000
|
||||||
|
);
|
||||||
|
const firstQuery = getInternalQuery(handle.session);
|
||||||
|
|
||||||
|
expect(firstQuery).toBeTruthy();
|
||||||
|
|
||||||
|
await handle.session.interrupt();
|
||||||
|
|
||||||
|
const canceledEvents = await collectUntilTerminal(firstStream, {
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
});
|
||||||
|
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
allFirstTurnEvents.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === "turn_canceled" && event.provider === "claude"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const followUpEvents = await collectUntilTerminal(
|
||||||
|
handle.session.stream("Respond with exactly: AFTER_INTERRUPT_OK")
|
||||||
|
);
|
||||||
|
const secondQuery = getInternalQuery(handle.session);
|
||||||
|
|
||||||
|
expect(secondQuery).toBe(firstQuery);
|
||||||
|
expect(compactText(getAssistantText(followUpEvents))).toContain(
|
||||||
|
"after_interrupt_ok"
|
||||||
|
);
|
||||||
|
expect(followUpEvents.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"creates an autonomous live turn when a background task completes",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-autonomous-",
|
||||||
|
});
|
||||||
|
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const liveEventsStream = handle.session.streamLiveEvents();
|
||||||
|
const foregroundEvents = await collectUntilTerminal(
|
||||||
|
handle.session.stream(
|
||||||
|
[
|
||||||
|
"Use the Task tool to start a background sub-agent.",
|
||||||
|
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
|
||||||
|
"Do not wait for task completion.",
|
||||||
|
"Reply immediately with exactly: SPAWNED",
|
||||||
|
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||||
|
].join(" ")
|
||||||
|
),
|
||||||
|
{ timeoutMs: 45_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
|
||||||
|
|
||||||
|
const liveEvents = await collectUntilTerminal(liveEventsStream, {
|
||||||
|
timeoutMs: 45_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
liveEvents.some(
|
||||||
|
(event) => event.type === "turn_started" && event.provider === "claude"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
compactText(getAssistantText(liveEvents))
|
||||||
|
).toContain(autonomousWakeToken.toLowerCase());
|
||||||
|
expect(liveEvents.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"surfaces permission requests and resumes after approval",
|
||||||
|
async () => {
|
||||||
|
const handle = await createSession({
|
||||||
|
cwdPrefix: "claude-agent-permission-",
|
||||||
|
modeId: "default",
|
||||||
|
});
|
||||||
|
const permissionFile = path.join(handle.cwd, "permission.txt");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await collectUntilTerminal(
|
||||||
|
handle.session.stream(
|
||||||
|
[
|
||||||
|
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
|
||||||
|
"If approval is required, wait for approval.",
|
||||||
|
"After the command succeeds, reply with exactly: PERM_DONE",
|
||||||
|
].join(" ")
|
||||||
|
),
|
||||||
|
{
|
||||||
|
timeoutMs: 45_000,
|
||||||
|
onEvent: async (event) => {
|
||||||
|
if (event.type !== "permission_requested") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handle.session.respondToPermission(event.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissionRequest = events.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
|
event.type === "permission_requested"
|
||||||
|
);
|
||||||
|
const permissionResolved = events.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
|
||||||
|
event.type === "permission_resolved"
|
||||||
|
);
|
||||||
|
const completedBashCall = getLatestCompletedBashCall(events);
|
||||||
|
|
||||||
|
expect(permissionRequest?.request.kind).toBe("tool");
|
||||||
|
expect(permissionResolved).toMatchObject({
|
||||||
|
type: "permission_resolved",
|
||||||
|
provider: "claude",
|
||||||
|
resolution: { behavior: "allow" },
|
||||||
|
});
|
||||||
|
expect(completedBashCall).toBeDefined();
|
||||||
|
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
|
||||||
|
expect(compactText(getAssistantText(events))).toContain("perm_done");
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await cleanupSession(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
});
|
||||||
+416
-2054
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
|||||||
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
|
import { mapClaudeRunningToolCall } from "./tool-call-mapper.js";
|
||||||
|
import { buildToolCallDisplayModel } from "../../../../shared/tool-call-display.js";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgentMetadata,
|
||||||
|
AgentStreamEvent,
|
||||||
|
AgentTimelineItem,
|
||||||
|
} from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
|
type ClaudeContentChunk = { type: string; [key: string]: unknown };
|
||||||
|
|
||||||
|
type SubAgentActionEntry = {
|
||||||
|
index: number;
|
||||||
|
toolName: string;
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubAgentActivityState = {
|
||||||
|
subAgentType?: string;
|
||||||
|
description?: string;
|
||||||
|
actions: SubAgentActionEntry[];
|
||||||
|
actionKeys: string[];
|
||||||
|
nextActionIndex: number;
|
||||||
|
actionIndexByKey: Map<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubAgentActionCandidate = {
|
||||||
|
key: string;
|
||||||
|
toolName: string;
|
||||||
|
input: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
||||||
|
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||||
|
|
||||||
|
function readTrimmedString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
|
||||||
|
return Boolean(
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
typeof (value as { type?: unknown }).type === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaudeSidechainTracker {
|
||||||
|
private readonly activeSidechains = new Map<string, SubAgentActivityState>();
|
||||||
|
private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
|
||||||
|
|
||||||
|
constructor(input: {
|
||||||
|
getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
|
||||||
|
}) {
|
||||||
|
this.getToolInput = input.getToolInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
|
||||||
|
const state =
|
||||||
|
this.activeSidechains.get(parentToolUseId) ??
|
||||||
|
({
|
||||||
|
actions: [],
|
||||||
|
actionKeys: [],
|
||||||
|
nextActionIndex: 1,
|
||||||
|
actionIndexByKey: new Map<string, number>(),
|
||||||
|
} satisfies SubAgentActivityState);
|
||||||
|
this.activeSidechains.set(parentToolUseId, state);
|
||||||
|
|
||||||
|
const contextUpdated = this.updateSubAgentContextFromTaskInput(
|
||||||
|
state,
|
||||||
|
parentToolUseId
|
||||||
|
);
|
||||||
|
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||||
|
let actionUpdated = false;
|
||||||
|
for (const action of actionCandidates) {
|
||||||
|
if (this.appendSubAgentAction(state, action)) {
|
||||||
|
actionUpdated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contextUpdated && !actionUpdated) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCall = mapClaudeRunningToolCall({
|
||||||
|
name: "Task",
|
||||||
|
callId: parentToolUseId,
|
||||||
|
input: null,
|
||||||
|
output: null,
|
||||||
|
});
|
||||||
|
if (!toolCall) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"] = {
|
||||||
|
type: "sub_agent",
|
||||||
|
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||||
|
...(state.description ? { description: state.description } : {}),
|
||||||
|
log: state.actions
|
||||||
|
.map((action) =>
|
||||||
|
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
actions: state.actions.map((action) => ({
|
||||||
|
index: action.index,
|
||||||
|
toolName: action.toolName,
|
||||||
|
...(action.summary ? { summary: action.summary } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
item: {
|
||||||
|
...toolCall,
|
||||||
|
detail,
|
||||||
|
},
|
||||||
|
provider: "claude",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(toolUseId: string): void {
|
||||||
|
this.activeSidechains.delete(toolUseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.activeSidechains.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSubAgentContextFromTaskInput(
|
||||||
|
state: SubAgentActivityState,
|
||||||
|
parentToolUseId: string
|
||||||
|
): boolean {
|
||||||
|
const taskInput = this.getToolInput(parentToolUseId);
|
||||||
|
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
|
||||||
|
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (nextSubAgentType && nextSubAgentType !== state.subAgentType) {
|
||||||
|
state.subAgentType = nextSubAgentType;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (nextDescription && nextDescription !== state.description) {
|
||||||
|
state.description = nextDescription;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSubAgentText(value: unknown): string | undefined {
|
||||||
|
const normalized = readTrimmedString(value)?.replace(/\s+/g, " ");
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (normalized.length <= MAX_SUB_AGENT_SUMMARY_CHARS) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||||
|
if (message.type === "assistant") {
|
||||||
|
const content = message.message?.content;
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const actions: SubAgentActionCandidate[] = [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (
|
||||||
|
!isClaudeContentChunk(block) ||
|
||||||
|
!(
|
||||||
|
block.type === "tool_use" ||
|
||||||
|
block.type === "mcp_tool_use" ||
|
||||||
|
block.type === "server_tool_use"
|
||||||
|
) ||
|
||||||
|
typeof block.name !== "string"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`;
|
||||||
|
actions.push({
|
||||||
|
key,
|
||||||
|
toolName: block.name,
|
||||||
|
input: block.input ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "stream_event") {
|
||||||
|
const event = message.event;
|
||||||
|
if (event.type !== "content_block_start") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const block = isClaudeContentChunk(event.content_block) ? event.content_block : null;
|
||||||
|
if (
|
||||||
|
!block ||
|
||||||
|
!(
|
||||||
|
block.type === "tool_use" ||
|
||||||
|
block.type === "mcp_tool_use" ||
|
||||||
|
block.type === "server_tool_use"
|
||||||
|
) ||
|
||||||
|
typeof block.name !== "string"
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const key =
|
||||||
|
readTrimmedString(block.id) ??
|
||||||
|
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
toolName: block.name,
|
||||||
|
input: block.input ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "tool_progress") {
|
||||||
|
const toolName = readTrimmedString(message.tool_name);
|
||||||
|
if (!toolName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const key = readTrimmedString(message.tool_use_id) ?? `progress:${toolName}`;
|
||||||
|
return [{ key, toolName, input: null }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendSubAgentAction(
|
||||||
|
state: SubAgentActivityState,
|
||||||
|
candidate: SubAgentActionCandidate
|
||||||
|
): boolean {
|
||||||
|
const normalizedToolName = readTrimmedString(candidate.toolName);
|
||||||
|
if (!normalizedToolName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = this.deriveSubAgentActionSummary(
|
||||||
|
normalizedToolName,
|
||||||
|
candidate.input
|
||||||
|
);
|
||||||
|
const existingIndex = state.actionIndexByKey.get(candidate.key);
|
||||||
|
|
||||||
|
if (existingIndex !== undefined) {
|
||||||
|
const existing = state.actions[existingIndex];
|
||||||
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextSummary = existing.summary ?? summary;
|
||||||
|
if (
|
||||||
|
existing.toolName === normalizedToolName &&
|
||||||
|
existing.summary === nextSummary
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.actions[existingIndex] = {
|
||||||
|
...existing,
|
||||||
|
toolName: normalizedToolName,
|
||||||
|
...(nextSummary ? { summary: nextSummary } : {}),
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.actions.push({
|
||||||
|
index: state.nextActionIndex,
|
||||||
|
toolName: normalizedToolName,
|
||||||
|
...(summary ? { summary } : {}),
|
||||||
|
});
|
||||||
|
state.nextActionIndex += 1;
|
||||||
|
state.actionKeys.push(candidate.key);
|
||||||
|
this.trimSubAgentTail(state);
|
||||||
|
this.rebuildSubAgentActionIndex(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimSubAgentTail(state: SubAgentActivityState): void {
|
||||||
|
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
|
||||||
|
state.actions.shift();
|
||||||
|
state.actionKeys.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
|
||||||
|
state.actionIndexByKey.clear();
|
||||||
|
for (let index = 0; index < state.actionKeys.length; index += 1) {
|
||||||
|
const key = state.actionKeys[index];
|
||||||
|
if (key) {
|
||||||
|
state.actionIndexByKey.set(key, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveSubAgentActionSummary(
|
||||||
|
toolName: string,
|
||||||
|
input: unknown
|
||||||
|
): string | undefined {
|
||||||
|
const runningToolCall = mapClaudeRunningToolCall({
|
||||||
|
name: toolName,
|
||||||
|
callId: `sub-agent-summary-${toolName}`,
|
||||||
|
input,
|
||||||
|
output: null,
|
||||||
|
});
|
||||||
|
if (!runningToolCall) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const display = buildToolCallDisplayModel({
|
||||||
|
name: runningToolCall.name,
|
||||||
|
status: runningToolCall.status,
|
||||||
|
error: runningToolCall.error,
|
||||||
|
detail: runningToolCall.detail,
|
||||||
|
metadata: runningToolCall.metadata,
|
||||||
|
});
|
||||||
|
return this.normalizeSubAgentText(display.summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user