Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10179,4 +10179,70 @@ describe("ClaudeAdapterLive", () => {
Effect.provide(harness.layer),
);
});

it.effect("attributes live subagent tool heartbeats when Claude omits task_id", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const progressFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.type === "tool.progress"),
Stream.take(4),
Stream.runCollect,
Effect.forkChild,
);
const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({ threadId: session.threadId, input: "Delegate work" });

harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-heartbeat",
description: "Inspect code",
task_type: "local_agent",
tool_use_id: "tool-task-heartbeat",
uuid: "task-heartbeat-start",
session_id: "sdk-heartbeat",
} as unknown as SDKMessage);
const heartbeat = (uuid: string, parentToolUseId: string, taskId?: string) =>
harness.query.emit({
type: "tool_progress",
tool_use_id: `tool-${uuid}`,
tool_name: "Bash",
elapsed_time_seconds: 30,
parent_tool_use_id: parentToolUseId,
...(taskId ? { task_id: taskId } : {}),
uuid,
session_id: "sdk-heartbeat",
} as unknown as SDKMessage);
heartbeat("live", "tool-task-heartbeat");
heartbeat("explicit", "tool-task-heartbeat", "task-explicit");
heartbeat("unknown", "tool-unknown");
harness.query.emit({
type: "system",
subtype: "task_notification",
task_id: "task-heartbeat",
status: "completed",
uuid: "task-heartbeat-end",
session_id: "sdk-heartbeat",
} as unknown as SDKMessage);
heartbeat("late", "tool-task-heartbeat");

const progress = Array.from(yield* Fiber.join(progressFiber));
assert.deepEqual(
progress.map((event) =>
event.type === "tool.progress" && event.payload.taskId
? String(event.payload.taskId)
: undefined,
),
["task-heartbeat", "task-explicit", undefined, undefined],
);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});
});
9 changes: 8 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4346,14 +4346,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
};

if (message.type === "tool_progress") {
// Claude omits task_id on subagent tool heartbeats but includes the
// launching Task tool's id. Recover only a still-live agent: a late
// heartbeat for a completed task must not revive its activity row.
const owningTaskId = agentIdForParentToolUse(context.taskAgents, message.parent_tool_use_id);
const taskId =
message.task_id ??
(owningTaskId && context.liveTaskIds.has(owningTaskId) ? owningTaskId : undefined);
yield* offerRuntimeEvent(context.sessionIncarnationId, {
...base,
type: "tool.progress",
payload: {
toolUseId: message.tool_use_id,
toolName: message.tool_name,
elapsedSeconds: message.elapsed_time_seconds,
...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}),
...(taskId ? { taskId: RuntimeTaskId.make(taskId) } : {}),
...(message.parent_tool_use_id !== null
? { parentToolUseId: message.parent_tool_use_id }
: {}),
Expand Down
Loading