diff --git a/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts b/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts index 484a9f4f..5cfd2903 100644 --- a/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts +++ b/apps/server/src/orchestration/Layers/ProviderActivityProjection.ts @@ -734,6 +734,9 @@ export function projectRuntimeEventToActivities( : {}), ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), ...(event.payload.subagentType ? { subagentType: event.payload.subagentType } : {}), + ...(event.payload.ownerAgentToolUseId + ? { ownerAgentToolUseId: event.payload.ownerAgentToolUseId } + : {}), }, }), ]; @@ -758,6 +761,9 @@ export function projectRuntimeEventToActivities( ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), ...(event.payload.subagentType ? { subagentType: event.payload.subagentType } : {}), + ...(event.payload.ownerAgentToolUseId + ? { ownerAgentToolUseId: event.payload.ownerAgentToolUseId } + : {}), }, }), ]; @@ -780,6 +786,9 @@ export function projectRuntimeEventToActivities( ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), + ...(event.payload.ownerAgentToolUseId + ? { ownerAgentToolUseId: event.payload.ownerAgentToolUseId } + : {}), }, }), ]; diff --git a/apps/server/src/orchestration/subagentProjection.test.ts b/apps/server/src/orchestration/subagentProjection.test.ts new file mode 100644 index 00000000..76fb752d --- /dev/null +++ b/apps/server/src/orchestration/subagentProjection.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EventId, TurnId, type OrchestrationThreadActivity } from "@threadlines/contracts"; + +import { projectSubagentActivity } from "./subagentProjection.ts"; + +const TURN_ID = TurnId.make("11111111-1111-4111-8111-111111111111"); +const SPAWN_TOOL_USE_ID = "toolu_01GSFNVFM8ppotb3KXjK3ASy"; + +function activity(input: { + id: string; + kind: string; + payload: unknown; + turnId?: TurnId | null; + createdAt?: string; +}): OrchestrationThreadActivity { + return { + id: EventId.make(input.id), + tone: "tool", + kind: input.kind, + summary: "Subagent task", + payload: input.payload, + turnId: input.turnId ?? null, + createdAt: input.createdAt ?? "2026-08-15T00:00:00.000Z", + }; +} + +/** The shape ClaudeAdapter projects for an Agent/Task tool call: itemType + + * toolName/input under data, no nested `data.item`. */ +function claudeSpawnActivity(input: { + id: string; + kind: string; + status: string; + turnId?: TurnId | null; + data?: Record; + createdAt?: string; +}): OrchestrationThreadActivity { + return activity({ + id: input.id, + kind: input.kind, + turnId: input.turnId ?? null, + ...(input.createdAt ? { createdAt: input.createdAt } : {}), + payload: { + itemType: "collab_agent_tool_call", + toolCallId: SPAWN_TOOL_USE_ID, + status: input.status, + title: "Subagent task", + detail: "claude: Fix missing-worktree bug trio", + data: { + toolName: "Agent", + input: { + description: "Fix missing-worktree bug trio", + subagent_type: "claude", + model: "opus", + }, + ...input.data, + }, + }, + }); +} + +describe("projectSubagentActivity", () => { + it("creates a roster row from a Claude Agent tool call", () => { + const roster = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.started", + status: "inProgress", + turnId: TURN_ID, + }), + ); + + expect(roster).toHaveLength(1); + const agent = roster[0]; + expect(agent?.id).toBe(SPAWN_TOOL_USE_ID); + expect(agent?.spawnCallId).toBe(SPAWN_TOOL_USE_ID); + expect(agent?.turnId).toBe(TURN_ID); + expect(agent?.role).toBe("claude"); + expect(agent?.objective).toBe("Fix missing-worktree bug trio"); + expect(agent?.requestedModel).toBe("opus"); + expect(agent?.status).toBe("running"); + }); + + it("keeps the spawn turn when later live-text updates arrive without a turn", () => { + const spawned = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.started", + status: "inProgress", + turnId: TURN_ID, + }), + ); + const updated = projectSubagentActivity( + spawned, + claudeSpawnActivity({ + id: "a2", + kind: "tool.updated", + status: "inProgress", + turnId: null, + data: { subagentLiveText: "Now writing the shared server-side module." }, + createdAt: "2026-08-15T00:01:00.000Z", + }), + ); + + expect(updated).toHaveLength(1); + expect(updated[0]?.turnId).toBe(TURN_ID); + expect(updated[0]?.status).toBe("running"); + }); + + it("treats a background launch acknowledgment as still running", () => { + const roster = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.completed", + status: "completed", + turnId: TURN_ID, + data: { + result: `Async agent launched successfully. agentId: agent-a46aeb71`, + }, + }), + ); + + expect(roster).toHaveLength(1); + expect(roster[0]?.status).toBe("running"); + expect(roster[0]?.resultBody).toBeNull(); + }); + + it("settles a known agent from task.completed and ignores unknown tasks", () => { + const spawned = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.started", + status: "inProgress", + turnId: TURN_ID, + }), + ); + + const settled = projectSubagentActivity( + spawned, + activity({ + id: "a2", + kind: "task.completed", + payload: { taskId: "a46aeb71b8e19f84b", status: "completed", toolUseId: SPAWN_TOOL_USE_ID }, + createdAt: "2026-08-15T00:05:00.000Z", + }), + ); + expect(settled).toHaveLength(1); + expect(settled[0]?.status).toBe("completed"); + // Claude names the agent's transcript after its task id; the completion is + // the last chance to learn the link. + expect(settled[0]?.transcriptAgentId).toBe("a46aeb71b8e19f84b"); + + // A background bash task's completion links to no roster row and must not + // invent one. + const unchanged = projectSubagentActivity( + settled, + activity({ + id: "a3", + kind: "task.completed", + payload: { taskId: "bash-1", status: "completed", toolUseId: "toolu_bash" }, + }), + ); + expect(unchanged).toHaveLength(1); + }); + + it("links the transcript task id from task progress without inventing rows", () => { + const spawned = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.started", + status: "inProgress", + turnId: TURN_ID, + }), + ); + + const linked = projectSubagentActivity( + spawned, + activity({ + id: "a2", + kind: "task.progress", + payload: { + taskId: "a46aeb71b8e19f84b", + detail: "Running List provider dirs", + toolUseId: SPAWN_TOOL_USE_ID, + subagentType: "claude", + }, + }), + ); + expect(linked).toHaveLength(1); + expect(linked[0]?.transcriptAgentId).toBe("a46aeb71b8e19f84b"); + expect(linked[0]?.status).toBe("running"); + + // A background bash task's progress names no known agent and must not + // create one. + const unchanged = projectSubagentActivity( + linked, + activity({ + id: "a3", + kind: "task.progress", + payload: { taskId: "bash-1", detail: "Running dev server", toolUseId: "toolu_bash" }, + }), + ); + expect(unchanged).toHaveLength(1); + }); + + it("projects a failed Claude agent as failed, not running", () => { + const roster = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.completed", + status: "failed", + turnId: TURN_ID, + }), + ); + + expect(roster).toHaveLength(1); + expect(roster[0]?.status).toBe("failed"); + }); + + it("settles from a restart-synthesized completion that carries only the task id", () => { + const spawned = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.started", + status: "inProgress", + turnId: TURN_ID, + }), + ); + const linked = projectSubagentActivity( + spawned, + activity({ + id: "a2", + kind: "task.progress", + payload: { + taskId: "a46aeb71b8e19f84b", + detail: "Running tests", + toolUseId: SPAWN_TOOL_USE_ID, + subagentType: "claude", + }, + }), + ); + + const settled = projectSubagentActivity( + linked, + activity({ + id: "a3", + kind: "task.completed", + payload: { taskId: "a46aeb71b8e19f84b", status: "stopped" }, + createdAt: "2026-08-15T00:06:00.000Z", + }), + ); + expect(settled).toHaveLength(1); + expect(settled[0]?.status).toBe("interrupted"); + }); + + it("still folds Codex-shaped collab items", () => { + const roster = projectSubagentActivity( + [], + activity({ + id: "a1", + kind: "tool.updated", + turnId: TURN_ID, + payload: { + itemType: "collab_agent_tool_call", + data: { + item: { + id: "call-1", + tool: "spawnAgent", + status: "inProgress", + prompt: "Review the diff", + agentThreadId: "agent-codex-1", + receiverThreadIds: ["agent-codex-1"], + }, + }, + }, + }), + ); + + expect(roster).toHaveLength(1); + expect(roster[0]?.agentThreadId).toBe("agent-codex-1"); + expect(roster[0]?.spawnCallId).toBe("call-1"); + }); +}); diff --git a/apps/server/src/orchestration/subagentProjection.ts b/apps/server/src/orchestration/subagentProjection.ts index c3a94ffc..33a7d3a7 100644 --- a/apps/server/src/orchestration/subagentProjection.ts +++ b/apps/server/src/orchestration/subagentProjection.ts @@ -5,6 +5,12 @@ import type { OrchestrationThreadActivity, TurnId, } from "@threadlines/contracts"; +import { + claudeSubagentActivityItem, + isClaudeSubagentToolName, + isSpawnAgentTool, +} from "@threadlines/shared/claudeSubagentActivity"; +import { isRootAgentPath } from "@threadlines/shared/subagentPath"; type UnknownRecord = Record; @@ -42,7 +48,7 @@ function lifecycleStatus(input: { readonly agentState: string | null; }): OrchestrationSubagentStatus { const status = input.agentState?.toLowerCase() ?? input.itemStatus?.toLowerCase() ?? null; - if (status === "failed" || status === "error") return "failed"; + if (status === "failed" || status === "error" || status === "errored") return "failed"; if (status === "interrupted" || status === "cancelled" || status === "canceled") { return "interrupted"; } @@ -130,8 +136,20 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] { const payload = record(activity.payload); if (text(payload?.itemType) !== "collab_agent_tool_call") return []; const data = record(payload?.data); - const item = record(data?.item); + // Claude's Agent/Task tool rows carry no nested item; the shared shaper + // reconstructs the same collab item the Codex driver emits natively. + const item = + record(data?.item) ?? + (payload + ? claudeSubagentActivityItem({ + activityId: activity.id, + activityKind: activity.kind, + payload, + data, + }) + : null); if (!item) return []; + if (isRootAgentPath(text(item.agentPath))) return []; const tool = text(item.tool); const nativeAgentId = text(item.agentThreadId); const receivers = Array.isArray(item.receiverThreadIds) @@ -143,7 +161,7 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] { ...new Set([...(nativeAgentId ? [nativeAgentId] : []), ...receivers, ...stateAgentIds]), ]; const spawnCallId = - tool === "spawnAgent" + isSpawnAgentTool(tool) || isClaudeSubagentToolName(tool) ? (text(item.id) ?? text(data?.itemId) ?? text(payload?.toolCallId)) : null; const ids = @@ -233,11 +251,77 @@ function mergeSubagent( }; } +/** Settles a spawned agent's status from a task.completed activity that links + * back via toolUseId (the spawn call id doubles as the agent id for Claude). + * Update-only: background command tasks share the activity kind, so a + * completion that matches no known agent must not invent a roster row. */ +function settleTaskCompletion( + current: ReadonlyArray, + activity: OrchestrationThreadActivity, +): ReadonlyArray { + const payload = record(activity.payload); + const toolUseId = text(payload?.toolUseId); + // A completion synthesized after a session restart carries only the taskId; + // the transcript link learned from the task stream is what still names the + // agent then. + const taskId = text(payload?.taskId); + if (!toolUseId && !taskId) return current; + const index = current.findIndex( + (entry) => + (toolUseId !== null && + (entry.agentThreadId === toolUseId || + entry.spawnCallId === toolUseId || + entry.id === toolUseId)) || + (taskId !== null && entry.transcriptAgentId === taskId), + ); + const existing = index >= 0 ? current[index] : undefined; + if (!existing) return current; + const rawStatus = text(payload?.status); + const status: OrchestrationSubagentStatus = + rawStatus === "failed" ? "failed" : rawStatus === "stopped" ? "interrupted" : "completed"; + if (existing.status === status && existing.updatedAt >= activity.createdAt) return current; + const next = [...current]; + next[index] = { ...existing, status, updatedAt: activity.createdAt }; + return next; +} + +/** Claude addresses an agent's on-disk transcript by the task id it reports on + * the task stream, not by the spawning tool_use id this roster is keyed by. + * The task stream is the only place the two are linked, so the link is folded + * in here. Update-only for the same reason completions are: background + * command tasks share these activity kinds and must not become roster rows. */ +function linkTaskTranscript( + current: ReadonlyArray, + activity: OrchestrationThreadActivity, +): ReadonlyArray { + const payload = record(activity.payload); + const taskId = text(payload?.taskId); + const toolUseId = text(payload?.toolUseId); + if (!taskId || !toolUseId) return current; + const index = current.findIndex( + (entry) => + entry.agentThreadId === toolUseId || + entry.spawnCallId === toolUseId || + entry.id === toolUseId, + ); + const existing = index >= 0 ? current[index] : undefined; + if (!existing || existing.transcriptAgentId === taskId) return current; + const next = [...current]; + next[index] = { ...existing, transcriptAgentId: taskId }; + return next; +} + /** Fold one timeline activity into the uncapped durable child-agent roster. */ export function projectSubagentActivity( current: ReadonlyArray, activity: OrchestrationThreadActivity, ): ReadonlyArray { + if (activity.kind === "task.started" || activity.kind === "task.progress") { + return linkTaskTranscript(current, activity); + } + if (activity.kind === "task.completed") { + return linkTaskTranscript(settleTaskCompletion(current, activity), activity); + } const patches = patchForActivity(activity); if (patches.length === 0) return current; const next = [...current]; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 09301695..6fbc786c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -60,6 +60,7 @@ import Migration0044 from "./Migrations/044_ProjectionThreadsEffectiveCwdSource. import Migration0045 from "./Migrations/045_SettleStoppedProjectionTurns.ts"; import Migration0046 from "./Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts"; import Migration0047 from "./Migrations/047_ProjectionThreadSubagents.ts"; +import Migration0048 from "./Migrations/048_BackfillThreadSubagents.ts"; /** * Migration loader with all migrations defined inline. @@ -119,6 +120,7 @@ export const migrationEntries = [ [45, "SettleStoppedProjectionTurns", Migration0045], [46, "ProjectionTurnsCheckpointCompletedAt", Migration0046], [47, "ProjectionThreadSubagents", Migration0047], + [48, "BackfillThreadSubagents", Migration0048], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts b/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts new file mode 100644 index 00000000..bd2b37b8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts @@ -0,0 +1,113 @@ +import type { OrchestrationSubagent, OrchestrationThreadActivity } from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { projectSubagentActivity } from "../../orchestration/subagentProjection.ts"; + +interface ActivityRow { + readonly activity_id: string; + readonly turn_id: string | null; + readonly tone: string; + readonly kind: string; + readonly summary: string; + readonly payload_json: string; + readonly sequence: number | null; + readonly created_at: string; +} + +/** The rows that can move the roster fold: collab tool items create/update + * agents, the task stream links transcripts and settles completions, and + * subagent.metadata enriches identity. Everything else is a no-op in + * projectSubagentActivity, so it never has to be loaded. */ +const CANDIDATE_FILTER = ` + json_extract(payload_json, '$.itemType') = 'collab_agent_tool_call' + OR kind IN ('task.started', 'task.progress', 'task.completed', 'subagent.metadata') +`; + +/** + * Rebuilds the durable subagent roster from the activity projection. The + * incremental fold in the projection pipeline only recognized Codex-shaped + * collab items (`data.item`) until now, so threads whose agents ran through + * Claude's Agent/Task tool never got a roster row. The fold now understands + * both shapes; replaying every affected thread's activities through it settles + * the historical record the same way live ingestion does going forward. + * + * Replay order is `rowid` — the order the rows were first appended, which is + * the order the incremental fold saw them. The listing sort (sequence, then + * created_at, then activity id) is not usable here: lifecycle rows often share + * a timestamp and carry random ids, and replaying a completion before its + * update would resurrect a settled agent. Threads are processed one at a time + * so memory stays proportional to a single thread's agent activity. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const threads = (yield* sql.unsafe(` + SELECT DISTINCT thread_id FROM projection_thread_activities WHERE ${CANDIDATE_FILTER} + `)) as unknown as ReadonlyArray<{ readonly thread_id: string }>; + + for (const { thread_id: threadId } of threads) { + const rows = (yield* sql.unsafe( + ` + SELECT activity_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + FROM projection_thread_activities + WHERE thread_id = ? AND (${CANDIDATE_FILTER}) + ORDER BY rowid ASC + `, + [threadId], + )) as unknown as ReadonlyArray; + + let subagents: ReadonlyArray = []; + for (const row of rows) { + let payload: unknown = null; + try { + payload = JSON.parse(row.payload_json); + } catch { + continue; + } + const activity = { + id: row.activity_id, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload, + turnId: row.turn_id, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.created_at, + } as unknown as OrchestrationThreadActivity; + subagents = projectSubagentActivity(subagents, activity); + } + + yield* sql`DELETE FROM projection_thread_subagents WHERE thread_id = ${threadId}`; + if (subagents.length === 0) continue; + yield* sql` + INSERT INTO projection_thread_subagents ${sql.insert( + subagents.map((row) => ({ + thread_id: threadId, + subagent_id: row.id, + agent_thread_id: row.agentThreadId, + parent_agent_thread_id: row.parentAgentThreadId, + spawn_call_id: row.spawnCallId, + transcript_agent_id: row.transcriptAgentId, + turn_id: row.turnId, + agent_path: row.agentPath, + parent_agent_path: row.parentAgentPath, + tree_depth: row.treeDepth, + nickname: row.nickname, + role: row.role, + objective: row.objective, + status: row.status, + requested_model: row.requestedModel, + resolved_model: row.resolvedModel, + reasoning_effort: row.reasoningEffort, + model_provenance: row.modelProvenance, + reasoning_effort_provenance: row.reasoningEffortProvenance, + result_body: row.resultBody, + result_created_at: row.resultCreatedAt, + created_at: row.createdAt, + updated_at: row.updatedAt, + })), + )} + `; + } +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 359a218b..114dee49 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2913,6 +2913,150 @@ describe("ClaudeAdapterLive", () => { }, ); + it.effect( + "attributes an agent's background task to its spawn through forwarded tool uses", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + 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 and test", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-owned-task", + uuid: "stream-owned-task-1", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-task-owner", + name: "Task", + input: { + description: "Fix the reactor", + prompt: "Fix it and run the tests", + subagent_type: "claude", + }, + }, + }, + } as unknown as SDKMessage); + + // The agent's forwarded envelope carries the tool_use that will start + // the background task. + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-owned-task", + uuid: "assistant-owned-task-1", + parent_tool_use_id: "tool-task-owner", + message: { + id: "subagent-message-owner", + content: [ + { type: "text", text: "Kicking off the test suite in the background." }, + { + type: "tool_use", + id: "subagent-inner-bash", + name: "Bash", + input: { command: "vp test", run_in_background: true }, + }, + ], + }, + } as unknown as SDKMessage); + + // The task stream names the originating tool_use but not whose + // conversation issued it; the owners map is what attributes it. + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-owned-bash", + description: "Run reactor tests", + tool_use_id: "subagent-inner-bash", + task_type: "local_bash", + session_id: "sdk-session-owned-task", + uuid: "owned-task-started", + } as unknown as SDKMessage); + + // A task the main model started stays unowned. + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-main-bash", + description: "Run dev server", + tool_use_id: "toolu-main-bash", + task_type: "local_bash", + session_id: "sdk-session-owned-task", + uuid: "main-task-started", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-owned-bash", + status: "completed", + summary: 'Background command "Run reactor tests" completed (exit code 0)', + session_id: "sdk-session-owned-task", + uuid: "owned-task-notification", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-owned-task", + uuid: "result-owned-task-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const startedEvents = runtimeEvents.filter((event) => event.type === "task.started"); + const ownedStarted = startedEvents.find( + (event) => + event.type === "task.started" && String(event.payload.taskId) === "task-owned-bash", + ); + assert.equal(ownedStarted?.type, "task.started"); + if (ownedStarted?.type === "task.started") { + assert.equal(ownedStarted.payload.ownerAgentToolUseId, "tool-task-owner"); + } + const mainStarted = startedEvents.find( + (event) => + event.type === "task.started" && String(event.payload.taskId) === "task-main-bash", + ); + assert.equal(mainStarted?.type, "task.started"); + if (mainStarted?.type === "task.started") { + assert.isUndefined(mainStarted.payload.ownerAgentToolUseId); + } + const ownedCompleted = runtimeEvents.find( + (event) => + event.type === "task.completed" && String(event.payload.taskId) === "task-owned-bash", + ); + assert.equal(ownedCompleted?.type, "task.completed"); + if (ownedCompleted?.type === "task.completed") { + assert.equal(ownedCompleted.payload.ownerAgentToolUseId, "tool-task-owner"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("routes nested subagent text (depth 2+) to the top-level collab tool item", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 09882b05..c3e4abfd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -312,6 +312,11 @@ interface ClaudeTaskSnapshot { /** SDK task type (e.g. "local_agent", "local_bash"), so notification * handling can tell agent tasks from background commands. */ readonly taskType?: string; + /** Spawn tool call of the subagent this task ran inside, when it was + * started by a tool_use issued in an agent's own conversation rather than + * by the main model. Learned at task_started and replayed on later task + * events, which do not restate the originating tool. */ + readonly ownerAgentToolUseId?: string; } type ClaudeStructuredAgentToolResult = @@ -374,6 +379,13 @@ interface ClaudeSessionContext { * parent_tool_use_id (Claude Code 2.1.219+); without this map they match * no known tool and their text would be dropped. Bounded FIFO. */ readonly subagentSpawnAncestry: Map; + /** Every tool_use id observed inside a subagent's forwarded conversation, + * mapped to the top-level collab tool item that owns the agent. The task + * stream reports a task's originating tool_use id but not whose + * conversation issued it, so this map is what tells an agent's background + * task (e.g. a test run it kicked off) from the main model's. Bounded + * FIFO. */ + readonly subagentToolUseOwners: Map; /** Per-file +/- counts captured by the PostToolUse hook, keyed by tool_use_id. * Consumed when the matching tool_result is emitted. */ readonly fileChangeStatsByToolUseId: Map; @@ -1993,6 +2005,10 @@ const SUBAGENT_AGENT_ID_PATTERN = /^[A-Za-z0-9_-]+$/; /** Spawn-ancestry entries kept per session; oldest evict first. Sized well * past any real session's subagent count while bounding a runaway. */ const SUBAGENT_SPAWN_ANCESTRY_MAX_ENTRIES = 1_024; +/** Inner tool_use ids per session worth remembering for task ownership. A + * long-running agent fleet can issue thousands of tools; the map only has to + * outlive the window between a tool_use and its task_started. */ +const SUBAGENT_TOOL_USE_OWNERS_MAX_ENTRIES = 8_192; /** Role recorded on a promoted `codex exec` row. The agents panel renders the * role as the row's name, so this is what the row reads as. */ @@ -2047,10 +2063,13 @@ function capTranscriptText(value: string, maxChars: number): string { return trimmed.length > maxChars ? trimmed.slice(0, maxChars) : trimmed; } -/** Records Agent spawns found in a forwarded subagent message so the nested - * agent's own forwarded messages (keyed by the spawn's tool_use id) resolve - * to the top-level collab tool item. Chains transitively: a depth-3 spawn - * recorded from a depth-2 message maps to the same top-level item. */ +/** Records the tool_use blocks found in a forwarded subagent message: Agent + * spawns feed the ancestry map so the nested agent's own forwarded messages + * (keyed by the spawn's tool_use id) resolve to the top-level collab tool + * item — chaining transitively, a depth-3 spawn recorded from a depth-2 + * message maps to the same top-level item — and every tool_use id feeds the + * owners map so background tasks those tools start are attributed to the + * agent instead of the main model. */ function recordSubagentSpawns( context: ClaudeSessionContext, content: unknown, @@ -2067,6 +2086,13 @@ function recordSubagentSpawns( if (type !== "tool_use" || typeof name !== "string" || typeof id !== "string") { continue; } + if (context.subagentToolUseOwners.size >= SUBAGENT_TOOL_USE_OWNERS_MAX_ENTRIES) { + const oldest = context.subagentToolUseOwners.keys().next().value; + if (oldest !== undefined) { + context.subagentToolUseOwners.delete(oldest); + } + } + context.subagentToolUseOwners.set(id, topLevelItemId); if (classifyToolItemType(name) !== "collab_agent_tool_call") { continue; } @@ -3765,6 +3791,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( readonly toolUseId?: string; readonly subagentType?: string; readonly taskType?: string; + readonly ownerAgentToolUseId?: string; }, message: SDKMessage, ) { @@ -3778,6 +3805,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(task.toolUseId ? { toolUseId: task.toolUseId } : {}), ...(task.subagentType ? { subagentType: task.subagentType } : {}), ...(task.taskType ? { taskType: task.taskType } : {}), + ...(task.ownerAgentToolUseId ? { ownerAgentToolUseId: task.ownerAgentToolUseId } : {}), status: "running", }); if (context.startedTaskIds.has(task.taskId)) { @@ -3799,6 +3827,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(task.taskType ? { taskType: task.taskType } : {}), ...(task.toolUseId ? { toolUseId: task.toolUseId } : {}), ...(task.subagentType ? { subagentType: task.subagentType } : {}), + ...(task.ownerAgentToolUseId ? { ownerAgentToolUseId: task.ownerAgentToolUseId } : {}), ...(context.backgroundTaskSnapshotObserved ? { pendingCountManagedBySnapshot: true } : {}), }, providerRefs: nativeProviderRefs(context), @@ -3839,6 +3868,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: task.status === "completed" ? "completed" : task.status === "failed" ? "failed" : "killed", }); + const ownerAgentToolUseId = previous?.ownerAgentToolUseId; const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "task.completed", @@ -3853,6 +3883,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(task.summary ? { summary: task.summary } : {}), ...(task.usage !== undefined ? { usage: task.usage } : {}), ...(task.toolUseId ? { toolUseId: task.toolUseId } : {}), + ...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}), ...(context.backgroundTaskSnapshotObserved ? { pendingCountManagedBySnapshot: true } : {}), }, providerRefs: nativeProviderRefs(context), @@ -4788,6 +4819,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const toolUseId = nonEmptyString(message.tool_use_id); const subagentType = nonEmptyString(message.subagent_type); const taskType = nonEmptyString(message.task_type); + const ownerAgentToolUseId = toolUseId + ? context.subagentToolUseOwners.get(toolUseId) + : undefined; yield* emitTaskStartedOnce( context, { @@ -4796,6 +4830,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(toolUseId ? { toolUseId } : {}), ...(subagentType ? { subagentType } : {}), ...(taskType ? { taskType } : {}), + ...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}), }, message, ); @@ -4809,11 +4844,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const toolUseId = nonEmptyString(message.tool_use_id) ?? previous?.toolUseId; const subagentType = nonEmptyString(message.subagent_type) ?? previous?.subagentType; + const ownerAgentToolUseId = + (toolUseId ? context.subagentToolUseOwners.get(toolUseId) : undefined) ?? + previous?.ownerAgentToolUseId; context.tasks.set(message.task_id, { ...previous, ...(description ? { description } : {}), ...(toolUseId ? { toolUseId } : {}), ...(subagentType ? { subagentType } : {}), + ...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}), status: "running", }); yield* offerRuntimeEvent({ @@ -4827,6 +4866,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), ...(toolUseId ? { toolUseId } : {}), ...(subagentType ? { subagentType } : {}), + ...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}), }, }); return; @@ -4877,6 +4917,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), description: description ?? describeClaudeTaskStatus(status), ...(error ? { summary: error } : {}), + ...(previous?.ownerAgentToolUseId + ? { ownerAgentToolUseId: previous.ownerAgentToolUseId } + : {}), }, }); return; @@ -6109,6 +6152,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( completedCollabAgentItemIds: new Set(), structuredCompletedCollabAgentItemIds: new Set(), subagentSpawnAncestry: new Map(), + subagentToolUseOwners: new Map(), fileChangeStatsByToolUseId, tasks: new Map(), bashCommandsByToolUseId: new Map(), diff --git a/apps/web/src/agentsPanelStore.ts b/apps/web/src/agentsPanelStore.ts index 8332d2a1..ef292984 100644 --- a/apps/web/src/agentsPanelStore.ts +++ b/apps/web/src/agentsPanelStore.ts @@ -40,6 +40,11 @@ export interface AgentsPanelSource { * say it is waiting rather than claim the thread has never run an agent * while the provider handoff is still in flight. */ turnInFlight: boolean; + /** Whether the thread's detail snapshot has synced. Until it has, empty + * agent state only means the data has not arrived — consumers that react + * to idle→running transitions (the Agents tab auto-open) must not read an + * unhydrated publish as observed idleness. */ + hydrated: boolean; threadCwd: string | null; onToggleBackgroundRunTerminal: (terminalId: string) => void; onStopBackgroundRun: (run: ThreadBackgroundRunItem) => void; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index afa0bdf8..bc04efe2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -103,6 +103,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { + selectEnvironmentState, selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, selectWorkspaceProjectsAcrossEnvironments, @@ -3352,6 +3353,16 @@ export default function ChatView(props: ChatViewProps) { [activeThreadId, activeThreadRef, requestCloseTerminal, setThreadError], ); + // The detail-only activity slice exists in the store (even empty) exactly + // from the thread's first detail snapshot, which is the honest "the agent + // state below reflects loaded data" signal for consumers of the publish. + const threadDetailHydrated = useStore((state) => + activeThreadRef + ? selectEnvironmentState(state, activeThreadRef.environmentId).activityIdsByThreadId[ + activeThreadRef.threadId + ] !== undefined + : false, + ); // The agents panel mounts in the route's right-panel slot, beside the chat // column, so the live turn state it renders has to be published out of here. useEffect(() => { @@ -3369,6 +3380,7 @@ export default function ChatView(props: ChatViewProps) { workEntries: workLogEntries, providerLabel: activeProviderDriver, turnInFlight: activeTurnInProgress, + hydrated: threadDetailHydrated, threadCwd: gitCwd, onToggleBackgroundRunTerminal: toggleBackgroundRunTerminal, onStopBackgroundRun: stopBackgroundRun, @@ -3384,6 +3396,7 @@ export default function ChatView(props: ChatViewProps) { stopBackgroundRun, subagentHistory, subagentProgress?.items, + threadDetailHydrated, toggleBackgroundRunTerminal, workLogEntries, ]); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 56eaeaae..0e3f62fe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import type { WorkLogEntry } from "../../session-logic"; +import { EventId, type OrchestrationThreadActivity } from "@threadlines/contracts"; +import { deriveWorkLogEntries, type WorkLogEntry } from "../../session-logic"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, @@ -1100,3 +1101,204 @@ describe("computeStableMessagesTimelineRows", () => { expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); }); }); + +describe("agent lifecycle parking through the real work-log derivation", () => { + const makeActivity = (overrides: { + id: string; + kind?: string; + summary?: string; + tone?: OrchestrationThreadActivity["tone"]; + payload?: Record; + turnId?: string | null; + createdAt?: string; + }): OrchestrationThreadActivity => ({ + id: EventId.make(overrides.id), + kind: overrides.kind ?? "tool.started", + summary: overrides.summary ?? "Tool call", + tone: overrides.tone ?? "tool", + payload: overrides.payload ?? {}, + turnId: (overrides.turnId ?? null) as OrchestrationThreadActivity["turnId"], + createdAt: overrides.createdAt ?? "2026-08-15T00:00:00.000Z", + }); + + const SPAWN_TOOL_USE_ID = "toolu_01GSFNVFM8ppotb3KXjK3ASy"; + + /** A Claude subagent's per-tool progress tick, as projected on the parent + * thread: unique activity id, no turn (the agent runs between turns), and + * the agent's identity in `subagentType`. */ + const agentTaskProgress = (id: string, detail: string): OrchestrationThreadActivity => + makeActivity({ + id, + kind: "task.progress", + summary: "Reasoning update", + tone: "thinking", + payload: { + taskId: "a46aeb71b8e19f84b", + detail, + lastToolName: "Bash", + toolUseId: SPAWN_TOOL_USE_ID, + subagentType: "claude", + }, + createdAt: "2026-08-15T00:02:00.000Z", + }); + + it("parks a background agent's task stream as anchors instead of inline rows", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "main-command", + kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "toolu_main", + status: "completed", + data: { command: "grep -n checkoutCwd apps/web/src/store.ts" }, + }, + turnId: "22222222-2222-4222-8222-222222222222", + }), + makeActivity({ + id: "agent-spawn", + kind: "tool.updated", + summary: "Subagent task", + payload: { + itemType: "collab_agent_tool_call", + toolCallId: SPAWN_TOOL_USE_ID, + status: "inProgress", + detail: "claude: Fix missing-worktree bug trio", + data: { + toolName: "Agent", + input: { description: "Fix missing-worktree bug trio", subagent_type: "claude" }, + }, + }, + turnId: "22222222-2222-4222-8222-222222222222", + createdAt: "2026-08-15T00:01:00.000Z", + }), + agentTaskProgress("agent-step-1", "Running List provider dirs"), + agentTaskProgress("agent-step-2", "Editing packages/contracts/src/rpc.ts"), + // A background task the agent started inside its own conversation (a + // test run): owned rows belong to the agent's lane, not the feed. + makeActivity({ + id: "agent-owned-task", + kind: "task.completed", + summary: "Task completed", + tone: "info", + payload: { + taskId: "bb6wn5cu6", + status: "completed", + detail: "Run reactor tests", + toolUseId: "toolu_inner_bash", + ownerAgentToolUseId: SPAWN_TOOL_USE_ID, + }, + createdAt: "2026-08-15T00:02:30.000Z", + }), + // A plain background command task carries no agent identity and must + // keep narrating inline. + makeActivity({ + id: "bash-task", + kind: "task.progress", + summary: "Reasoning update", + tone: "thinking", + payload: { taskId: "bash-1", detail: "Running dev server" }, + createdAt: "2026-08-15T00:03:00.000Z", + }), + ]; + + const workEntries = deriveWorkLogEntries(activities); + const rows = deriveMessagesTimelineRows({ + timelineEntries: workEntries.map((entry) => ({ + id: entry.id, + kind: "work" as const, + createdAt: entry.createdAt, + entry, + })), + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const workRow = rows.find((row) => row.kind === "work"); + expect(workRow?.kind).toBe("work"); + if (workRow?.kind !== "work") return; + + const groupedIds = workRow.groupedEntries.map((entry) => entry.id); + const anchorIds = workRow.agentAnchorEntries.map((entry) => entry.id); + + expect(groupedIds).toContain("main-command"); + expect(groupedIds).toContain("bash-task"); + expect(groupedIds).not.toContain("agent-step-1"); + expect(groupedIds).not.toContain("agent-step-2"); + expect(groupedIds).not.toContain("agent-owned-task"); + expect(anchorIds).toContain("agent-spawn"); + expect(anchorIds).toContain("agent-step-1"); + expect(anchorIds).toContain("agent-step-2"); + expect(anchorIds).toContain("agent-owned-task"); + }); +}); + +describe("tracker spawn-id fallback for turnless groups", () => { + const spawnAnchor: WorkLogEntry = { + id: "spawn", + createdAt: "2026-08-15T00:00:00.000Z", + label: "Subagent task", + tone: "tool", + itemType: "collab_agent_tool_call", + toolCallId: "toolu_spawn", + }; + const agentStep: WorkLogEntry = { + id: "agent-step", + createdAt: "2026-08-15T00:01:00.000Z", + label: "Running List provider dirs", + tone: "thinking", + activityKind: "task.progress", + subagentTask: { subagentType: "claude", toolUseId: "toolu_spawn" }, + }; + + it("names the spawns of a turnless all-anchor group so its tracker can render", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [spawnAnchor, agentStep].map((entry) => ({ + id: entry.id, + kind: "work" as const, + createdAt: entry.createdAt, + entry, + })), + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const workRow = rows.find((row) => row.kind === "work"); + expect(workRow?.kind).toBe("work"); + if (workRow?.kind !== "work") return; + expect(workRow.groupedEntries).toEqual([]); + expect(workRow.trackerTurnIds).toEqual([]); + expect(workRow.trackerAgentSpawnIds).toEqual(["toolu_spawn"]); + }); + + it("keeps the spawn fallback off groups that already track a turn", () => { + const turnedSpawn: WorkLogEntry = { + ...spawnAnchor, + turnId: "33333333-3333-4333-8333-333333333333" as NonNullable, + }; + const rows = deriveMessagesTimelineRows({ + timelineEntries: [turnedSpawn, agentStep].map((entry) => ({ + id: entry.id, + kind: "work" as const, + createdAt: entry.createdAt, + entry, + })), + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const workRow = rows.find((row) => row.kind === "work"); + if (workRow?.kind !== "work") return; + expect(workRow.trackerTurnIds).toEqual(["33333333-3333-4333-8333-333333333333"]); + expect(workRow.trackerAgentSpawnIds).toEqual([]); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4142abc4..c030f86f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -67,6 +67,12 @@ export type MessagesTimelineRow = * in the same turn would repeat the same bars and count. Empty means this * group shows no tracker at all. */ trackerTurnIds: TurnId[]; + /** Spawn call ids the tracker falls back to when the group has no turn to + * key on. A background agent keeps streaming after its spawning turn + * settles, and that activity arrives turnless — without this the group + * at the tail of the conversation would show nothing at all while the + * agent works. Only populated when `trackerTurnIds` is empty. */ + trackerAgentSpawnIds: string[]; isLive: boolean; liveStartedAt: string | null; } @@ -319,6 +325,10 @@ export function deriveMessagesTimelineRows(input: { trackedTurnIds.add(turnId); trackerTurnIds.push(turnId); } + const trackerAgentSpawnIds = + trackerTurnIds.length === 0 + ? deriveTrackerAgentSpawnIds(groupedEntries, agentAnchorEntries) + : []; nextRows.push({ kind: "work", id: timelineEntry.id, @@ -326,6 +336,7 @@ export function deriveMessagesTimelineRows(input: { groupedEntries, agentAnchorEntries, trackerTurnIds, + trackerAgentSpawnIds, isLive: false, liveStartedAt: null, }); @@ -480,6 +491,25 @@ function isAgentLifecycleEntry(entry: TimelineEntry): boolean { ); } +/** The spawn call ids a turnless group's rows reference: an agent's own task + * rows name their spawn through `subagentTask.toolUseId`, and the spawn's + * collab tool item names itself through `toolCallId`. */ +function deriveTrackerAgentSpawnIds( + groupedEntries: ReadonlyArray, + agentAnchorEntries: ReadonlyArray, +): string[] { + const spawnIds: string[] = []; + for (const entry of [...groupedEntries, ...agentAnchorEntries]) { + const spawnId = + entry.subagentTask?.toolUseId ?? + (entry.itemType === "collab_agent_tool_call" ? entry.toolCallId : undefined); + if (spawnId && !spawnIds.includes(spawnId)) { + spawnIds.push(spawnId); + } + } + return spawnIds; +} + function deriveVisibleTimelineEntries(input: { readonly timelineEntries: ReadonlyArray; readonly isWorking: boolean; @@ -671,6 +701,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.liveStartedAt === bw.liveStartedAt && a.trackerTurnIds.length === bw.trackerTurnIds.length && a.trackerTurnIds.every((turnId, index) => turnId === bw.trackerTurnIds[index]) && + a.trackerAgentSpawnIds.length === bw.trackerAgentSpawnIds.length && + a.trackerAgentSpawnIds.every( + (spawnId, index) => spawnId === bw.trackerAgentSpawnIds[index], + ) && Equal.equals(a.groupedEntries, bw.groupedEntries) && Equal.equals(a.agentAnchorEntries, bw.agentAnchorEntries) ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e8969d47..2ce0afef 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2626,7 +2626,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ () => [...row.groupedEntries, ...row.agentAnchorEntries], [row.agentAnchorEntries, row.groupedEntries], ); - const turnAgentTracker = useTurnAgentTracker(row.trackerTurnIds); + const turnAgentTracker = useTurnAgentTracker(row.trackerTurnIds, row.trackerAgentSpawnIds); const isLiveActivity = isWorking && row.isLive; useEffect(() => { @@ -2856,9 +2856,13 @@ interface TurnAgentTracker { * groups shows one tracker rather than the same one repeated down the turn. * Derived once by the group so the receipt and the group's own render decision * cannot disagree about whether there is a tracker to show. */ -function useTurnAgentTracker(trackerTurnIds: ReadonlyArray): TurnAgentTracker { +function useTurnAgentTracker( + trackerTurnIds: ReadonlyArray, + trackerAgentSpawnIds: ReadonlyArray = [], +): TurnAgentTracker { const { turnAgents } = use(TimelineRowCtx); const turnIds = useMemo(() => new Set(trackerTurnIds), [trackerTurnIds]); + const spawnCallIds = useMemo(() => new Set(trackerAgentSpawnIds), [trackerAgentSpawnIds]); const turnSubagents = useMemo( () => turnAgents @@ -2866,9 +2870,10 @@ function useTurnAgentTracker(trackerTurnIds: ReadonlyArray): TurnAgentTr live: turnAgents.subagents, history: turnAgents.history, turnIds, + spawnCallIds, }) : [], - [turnAgents, turnIds], + [turnAgents, turnIds, spawnCallIds], ); return { // An empty selection summarizes to null on its own, so a turn that ran no @@ -2899,12 +2904,27 @@ function ActivityReceipt({ const actionCount = entries.length; const duration = formatActivityDuration(durationEntries); const { summary: agentSummary, liveStatus: liveAgentStatus } = tracker; + // A group whose visible work is all delegation reads as the agents' receipt, + // not the main model's: "Activity · 0 actions" would claim the model did the + // work the rail attributes to its agents. + const liveAgentCount = + agentSummary?.segments.filter( + (segment) => segment.status === "running" || segment.status === "waiting", + ).length ?? 0; + const heading = + actionCount > 0 || !agentSummary + ? "Activity" + : liveAgentCount > 0 + ? liveAgentCount > 1 + ? "Agents working" + : "Agent working" + : "Agent activity"; return (

- Activity + {heading} {/* A turn that only delegated took no actions of its own, and "0 actions" would read as if nothing happened. */} {actionCount > 0 ? ( diff --git a/apps/web/src/components/chat/agentsPanel.logic.test.ts b/apps/web/src/components/chat/agentsPanel.logic.test.ts index 2dbab2cb..225efac1 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.test.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.test.ts @@ -676,6 +676,33 @@ describe("selectTurnAgents", () => { expect(selected.map((item) => item.status)).toEqual(["running", "running"]); expect(selected.map((item) => item.agentThreadId)).toEqual(["shared", "extra"]); }); + + /** A background agent between turns: its activity group carries no turn, so + * the tracker names it through the spawn call id its rows reference. */ + it("selects turnless agents through the spawn-call fallback", () => { + const selected = selectTurnAgents({ + live: [ + buildSubagent({ id: "toolu_spawn", turnId: TURN_ONE, spawnCallId: "toolu_spawn" }), + buildSubagent({ id: "unrelated", turnId: TURN_TWO, spawnCallId: "toolu_other" }), + ], + history: undefined, + turnIds: new Set(), + spawnCallIds: new Set(["toolu_spawn"]), + }); + + expect(selected.map((item) => item.id)).toEqual(["toolu_spawn"]); + }); + + it("does not double-select an agent matched by both turn and spawn id", () => { + const selected = selectTurnAgents({ + live: [buildSubagent({ id: "toolu_spawn", turnId: TURN_ONE, spawnCallId: "toolu_spawn" })], + history: undefined, + turnIds: new Set([TURN_ONE]), + spawnCallIds: new Set(["toolu_spawn"]), + }); + + expect(selected.map((item) => item.id)).toEqual(["toolu_spawn"]); + }); }); describe("formatAgentsHeaderMeta", () => { diff --git a/apps/web/src/components/chat/agentsPanel.logic.ts b/apps/web/src/components/chat/agentsPanel.logic.ts index 505eaee0..313b1cb5 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.ts @@ -567,6 +567,26 @@ export function selectSubagentsForTurns( return subagents.filter((item) => item.turnId !== null && turnIds.has(item.turnId)); } +/** The subagents a turnless activity group references by spawn call. A + * background agent's stream lands between turns, so the group at the tail of + * the conversation can only name its agents through the spawn ids its rows + * carry. Matches every identity a roster item answers to: Claude reuses the + * spawn tool_use id as the agent id, Codex children have their own ids. */ +function selectSubagentsForSpawnIds( + subagents: ReadonlyArray, + spawnCallIds: ReadonlySet, +): ReadonlyArray { + if (spawnCallIds.size === 0) { + return []; + } + return subagents.filter( + (item) => + (item.spawnCallId != null && spawnCallIds.has(item.spawnCallId)) || + spawnCallIds.has(item.id) || + (item.agentThreadId !== null && spawnCallIds.has(item.agentThreadId)), + ); +} + /** * The agents a turn's activity row summarizes. * @@ -585,18 +605,30 @@ export function selectTurnAgents(input: { readonly live: ReadonlyArray; readonly history: ReadonlyArray | undefined; readonly turnIds: ReadonlySet; + /** Spawn-call fallback for turnless groups; see selectSubagentsForSpawnIds. */ + readonly spawnCallIds?: ReadonlySet; }): ReadonlyArray { - const live = selectSubagentsForTurns(input.live, input.turnIds); + const spawnCallIds = input.spawnCallIds ?? new Set(); + const select = (subagents: ReadonlyArray) => { + const byTurn = selectSubagentsForTurns(subagents, input.turnIds); + const turnIdentities = new Set(byTurn.map(subagentIdentity)); + return [ + ...byTurn, + ...selectSubagentsForSpawnIds(subagents, spawnCallIds).filter( + (item) => !turnIdentities.has(subagentIdentity(item)), + ), + ]; + }; + const live = select(input.live); if (input.history === undefined || input.history.length === 0) { return live; } const liveIdentities = new Set(live.map(subagentIdentity)); return [ ...live, - ...selectSubagentsForTurns( - input.history.map((entry) => entry.item), - input.turnIds, - ).filter((item) => !liveIdentities.has(subagentIdentity(item))), + ...select(input.history.map((entry) => entry.item)).filter( + (item) => !liveIdentities.has(subagentIdentity(item)), + ), ]; } diff --git a/apps/web/src/rightPanelTabs.test.ts b/apps/web/src/rightPanelTabs.test.ts index 507f306c..22ed999a 100644 --- a/apps/web/src/rightPanelTabs.test.ts +++ b/apps/web/src/rightPanelTabs.test.ts @@ -4,6 +4,9 @@ import { describe, expect, it } from "vite-plus/test"; import { EMPTY_RIGHT_PANEL_TABS_STATE, activeRightPanelTabFromSearch, + advanceAgentsAutoOpenEdge, + autoOpenAgentsTabState, + type AgentsAutoOpenEdge, availableRightPanelTabs, closeRightPanelTabState, focusRightPanelTabState, @@ -302,3 +305,96 @@ describe("reconcileRightPanelTabsState", () => { expect(state.activeTab).toBe("sourceControl"); }); }); + +describe("autoOpenAgentsTabState", () => { + it("opens the agents tab focused when the sidebar is hidden", () => { + const state = autoOpenAgentsTabState(EMPTY_RIGHT_PANEL_TABS_STATE); + + expect(state.visible).toBe(true); + expect(state.openTabs).toEqual(["agents"]); + expect(state.activeTab).toBe("agents"); + }); + + it("opens focused from the launcher", () => { + const launcher: RightPanelTabsState = { + ...EMPTY_RIGHT_PANEL_TABS_STATE, + visible: true, + }; + + const state = autoOpenAgentsTabState(launcher); + expect(state.activeTab).toBe("agents"); + }); + + it("joins the strip in the background while another tab has focus", () => { + const onSource = openedOn("sourceControl"); + + const state = autoOpenAgentsTabState(onSource); + expect(state.openTabs).toEqual(["sourceControl", "agents"]); + expect(state.activeTab).toBe("sourceControl"); + }); + + it("changes nothing when the agents tab is already in the strip behind another", () => { + const withAgents = focusRightPanelTabState(openedOn("agents"), "sourceControl"); + + expect(autoOpenAgentsTabState(withAgents)).toBe(withAgents); + }); + + it("re-shows a hidden sidebar focused on agents", () => { + const hidden = hideRightPanelState(openedOn("sourceControl")); + + const state = autoOpenAgentsTabState(hidden); + expect(state.visible).toBe(true); + expect(state.activeTab).toBe("agents"); + expect(state.openTabs).toEqual(["sourceControl", "agents"]); + }); +}); + +describe("advanceAgentsAutoOpenEdge", () => { + const freshEdge = (): AgentsAutoOpenEdge => ({ threadKey: null, sawIdle: false }); + const step = ( + edge: AgentsAutoOpenEdge, + agentsKnown: boolean, + agentsRunning: boolean, + threadKey = "thread-1", + ) => advanceAgentsAutoOpenEdge(edge, { threadKey, agentsKnown, agentsRunning }); + + it("triggers on a spawn observed after real idleness", () => { + const edge = freshEdge(); + expect(step(edge, true, false)).toBe(false); + expect(step(edge, true, true)).toBe(true); + }); + + it("consumes the edge: one batch triggers once", () => { + const edge = freshEdge(); + step(edge, true, false); + expect(step(edge, true, true)).toBe(true); + expect(step(edge, true, true)).toBe(false); + // A second spawn while the first batch still runs is the same story. + expect(step(edge, true, true)).toBe(false); + // Settling and spawning again is a new batch. + step(edge, true, false); + expect(step(edge, true, true)).toBe(true); + }); + + it("never reads a pre-hydration empty publish as idleness", () => { + // The sequence a reload of a thread with a live agent produces: the chat + // column publishes empty agent state before the detail snapshot arrives, + // then the loaded state reports the agent running. + const edge = freshEdge(); + expect(step(edge, false, false)).toBe(false); + expect(step(edge, true, true)).toBe(false); + }); + + it("does not treat landing on a thread with running agents as a spawn", () => { + const edge = freshEdge(); + step(edge, true, false, "thread-1"); + expect(step(edge, true, true, "thread-2")).toBe(false); + }); + + it("re-arms per thread after a switch", () => { + const edge = freshEdge(); + step(edge, true, false, "thread-1"); + step(edge, true, false, "thread-2"); + expect(step(edge, true, true, "thread-2")).toBe(true); + }); +}); diff --git a/apps/web/src/rightPanelTabs.ts b/apps/web/src/rightPanelTabs.ts index 786dcc0f..b3b5f33c 100644 --- a/apps/web/src/rightPanelTabs.ts +++ b/apps/web/src/rightPanelTabs.ts @@ -164,6 +164,70 @@ export function focusRightPanelTabState( }; } +/** + * The first agent of a batch just spawned: surface the Agents tab without + * taking anything away from the user. + * + * - Sidebar hidden, or open on the launcher: the Agents tab is the most useful + * thing to be looking at, so it opens focused. + * - Sidebar open on another tab: the Agents tab joins the strip in the + * background — its live node advertises the running agent — and focus stays + * exactly where the user put it. + * + * Deciding *when* to call this (the 0→running edge, once per batch, wide + * layouts only) is the caller's job; this is only what opening looks like. + */ +export function autoOpenAgentsTabState(state: RightPanelTabsState): RightPanelTabsState { + if (state.visible && state.activeTab !== null && state.activeTab !== "agents") { + return state.openTabs.includes("agents") + ? state + : { ...state, openTabs: orderedTabs([...state.openTabs, "agents"]) }; + } + return focusRightPanelTabState(state, "agents"); +} + +/** Mutable per-thread state for the auto-open trigger. */ +export interface AgentsAutoOpenEdge { + threadKey: string | null; + sawIdle: boolean; +} + +/** + * Advances the auto-open edge detector and returns whether this observation is + * a fresh spawn — an idle→running transition seen while the thread was on + * screen. Idle is only believed while `agentsKnown` (the thread's detail data + * has actually loaded): before that, "no agents" just means the data has not + * arrived, and counting it made every load of a thread with a live agent read + * as a spawn. A thread switch re-arms nothing — landing on a thread whose + * agents are already running is not a spawn either — and a returned true + * consumes the edge, so one batch of spawns triggers exactly once. + */ +export function advanceAgentsAutoOpenEdge( + edge: AgentsAutoOpenEdge, + input: { + readonly threadKey: string | null; + readonly agentsKnown: boolean; + readonly agentsRunning: boolean; + }, +): boolean { + if (edge.threadKey !== input.threadKey) { + edge.threadKey = input.threadKey; + edge.sawIdle = false; + } + if (!input.agentsKnown) { + return false; + } + if (!input.agentsRunning) { + edge.sawIdle = true; + return false; + } + if (!edge.sawIdle) { + return false; + } + edge.sawIdle = false; + return true; +} + /** Open-or-retarget the single Diff tab. */ export function retargetRightPanelDiffState( state: RightPanelTabsState, @@ -454,6 +518,18 @@ export function focusRightPanelTab(threadKey: string | null, tab: RightPanelTab) mutate(threadKey, (state) => focusRightPanelTabState(state, tab)); } +/** Returns "agents" when the tab came up focused and the URL should follow; + * null when it only joined the strip in the background (or was already up). */ +export function autoOpenAgentsTab(threadKey: string | null): RightPanelTab | null { + const previous = readState(threadKey); + const next = mutate(threadKey, autoOpenAgentsTabState); + const focused = + next.visible && + next.activeTab === "agents" && + !(previous.visible && previous.activeTab === "agents"); + return focused ? "agents" : null; +} + export function retargetRightPanelDiff( threadKey: string | null, target: RightPanelDiffTarget | null, diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index f9996119..fd169a81 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -2,7 +2,7 @@ import { scopeProjectRef } from "@threadlines/client-runtime"; import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import ChatView from "../components/ChatView"; import { ChatRightPanelInlineSidebar } from "../components/ChatRightPanelInlineSidebar"; @@ -34,7 +34,10 @@ import { } from "../rightPanelLayout"; import { activeRightPanelTabFromSearch, + advanceAgentsAutoOpenEdge, + autoOpenAgentsTab, availableRightPanelTabs, + type AgentsAutoOpenEdge, closeRightPanelTab, focusRightPanelTab, hideRightPanel, @@ -330,6 +333,35 @@ function ChatThreadRouteView() { () => (agentsSource && hasRunningAgentActivity(agentsSource) ? ["agents"] : []), [agentsSource], ); + // A fresh delegation surfaces the Agents tab: focused when the sidebar had + // nothing better to show, in the background (live node only) when the user is + // mid-something on another tab. The edge detector owns what counts as a + // fresh spawn; closing the tab or hiding the sidebar while those agents + // still run is a choice this effect does not override, and sheet layouts + // opt out entirely — there the panel would cover the conversation. + const agentsRunning = liveTabs.includes("agents"); + const agentsKnown = agentsSource !== null && agentsSource.hydrated; + const agentsAutoOpenRef = useRef({ threadKey: null, sawIdle: false }); + useEffect(() => { + const spawned = advanceAgentsAutoOpenEdge(agentsAutoOpenRef.current, { + threadKey: currentThreadKey, + agentsKnown, + agentsRunning, + }); + if (!spawned || shouldUseDiffSheet || !availableTabs.includes("agents")) { + return; + } + if (autoOpenAgentsTab(currentThreadKey) === "agents") { + navigateToTab("agents"); + } + }, [ + agentsKnown, + agentsRunning, + availableTabs, + currentThreadKey, + navigateToTab, + shouldUseDiffSheet, + ]); // What the launcher's rows say. Only assembled while the launcher is the // thing on screen, since that is the only place it is read. const launcherSurfaceStates = useRightPanelLauncherStates({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 41aadedd..b1d3ba1a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -29,6 +29,12 @@ import { isProviderAuthErrorMessage, providerAuthReconnectCommand, } from "@threadlines/shared/providerAuth"; +import { + claudeSubagentActivityItem, + isClaudeSubagentToolName, + isSpawnAgentTool, + normalizeStatusToken, +} from "@threadlines/shared/claudeSubagentActivity"; import { isRootAgentPath } from "@threadlines/shared/subagentPath"; import { extensionMcpOAuthActionIntent, @@ -1239,7 +1245,14 @@ function collectSubagentActivityRecords( } const data = asRecord(payload?.data); - const item = asRecord(data?.item) ?? asClaudeSubagentActivityItem({ activity, payload, data }); + const item = + asRecord(data?.item) ?? + claudeSubagentActivityItem({ + activityId: activity.id, + activityKind: activity.kind, + payload, + data, + }); if (!item) { continue; } @@ -1518,189 +1531,6 @@ function applySubagentTaskCompletion( }); } -function asClaudeSubagentActivityItem(input: { - activity: OrchestrationThreadActivity; - payload: Record; - data: Record | null; -}): Record | null { - const toolName = asTrimmedString(input.data?.toolName); - if (!isClaudeSubagentToolName(toolName)) { - return null; - } - - const toolInput = asRecord(input.data?.input); - const toolCallId = - asTrimmedString(input.payload.toolCallId) ?? - asTrimmedString(input.data?.itemId) ?? - input.activity.id; - const itemStatus = - asTrimmedString(input.payload.status) ?? claudeSubagentStatusFromActivityKind(input.activity); - const resultText = extractClaudeSubagentResultText(input.data?.result); - const notificationStatus = asTrimmedString(asRecord(input.data?.taskNotification)?.status); - const structuredResultStatus = asTrimmedString(asRecord(input.data?.structuredResult)?.status); - // A background launch acknowledgment is harness plumbing, not agent output: - // the agent keeps running and its real message arrives later through the - // task-notification completion replay (which carries data.taskNotification). - const stateStatus = - notificationStatus !== null - ? claudeSubagentStateStatus( - notificationStatus === "stopped" ? "interrupted" : notificationStatus, - ) - : structuredResultStatus === "async_launched" || structuredResultStatus === "remote_launched" - ? "running" - : isClaudeAsyncAgentLaunchAcknowledgment(resultText) - ? "running" - : claudeSubagentStateStatus(itemStatus); - const agentMessage = isTerminalClaudeSubagentState(stateStatus) ? resultText : null; - const role = - asTrimmedString(toolInput?.subagent_type) ?? - asTrimmedString(toolInput?.subagentType) ?? - asTrimmedString(toolInput?.agent_type) ?? - asTrimmedString(toolInput?.agentType); - const nickname = - asTrimmedString(toolInput?.agentNickname) ?? - asTrimmedString(toolInput?.agent_nickname) ?? - asTrimmedString(toolInput?.nickname) ?? - asTrimmedString(toolInput?.name) ?? - asTrimmedString(toolInput?.displayName); - const prompt = - asTrimmedString(toolInput?.description) ?? - asTrimmedString(toolInput?.prompt) ?? - asTrimmedString(input.payload.detail); - // A spawn only names a model when the parent overrides one, and names it as - // an alias ("opus"). The agent's own forwarded messages state the resolved - // id, which is what the UI should prefer. - const model = asTrimmedString(toolInput?.model); - const resolvedModel = asTrimmedString(input.data?.subagentModel); - const reasoningEffort = - asTrimmedString(toolInput?.effort) ?? asTrimmedString(toolInput?.reasoning_effort); - - return { - id: toolCallId, - type: "collabAgentToolCall", - tool: toolName, - status: itemStatus, - ...(prompt ? { prompt } : {}), - ...(role ? { agentRole: role } : {}), - ...(nickname ? { agentNickname: nickname } : {}), - ...(model ? { model } : {}), - ...(resolvedModel ? { resolvedModel } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - receiverThreadIds: [toolCallId], - agentsStates: { - [toolCallId]: { - status: stateStatus, - ...(agentMessage ? { message: agentMessage } : { message: null }), - }, - }, - }; -} - -function isClaudeSubagentToolName(toolName: string | null): boolean { - const normalized = toolName?.trim().toLowerCase(); - return ( - normalized === "agent" || - normalized === "task" || - normalized === "subagent" || - normalized === "sub-agent" || - normalized?.includes("subagent") === true || - normalized?.includes("sub-agent") === true - ); -} - -function claudeSubagentStateStatus(itemStatus: string): string { - const normalized = normalizeStatusToken(itemStatus); - if (normalized === "completed") { - return "completed"; - } - if (normalized === "failed" || normalized === "errored" || normalized === "error") { - return "errored"; - } - if (normalized === "interrupted" || normalized === "aborted" || normalized === "cancelled") { - return "interrupted"; - } - return "running"; -} - -function claudeSubagentStatusFromActivityKind(activity: OrchestrationThreadActivity): string { - if (activity.kind === "tool.completed") { - return "completed"; - } - return "inProgress"; -} - -function isTerminalClaudeSubagentState(stateStatus: string): boolean { - return stateStatus === "completed" || stateStatus === "errored" || stateStatus === "interrupted"; -} - -function extractClaudeSubagentResultText(result: unknown): string | null { - const direct = asTrimmedString(result); - if (direct) { - return sanitizeClaudeSubagentResultText(direct); - } - - const resultRecord = asRecord(result); - if (!resultRecord) { - return null; - } - - const text = - extractClaudeTextContent(resultRecord.content) ?? - extractClaudeTextContent(resultRecord.text) ?? - extractClaudeTextContent(resultRecord.message); - return sanitizeClaudeSubagentResultText(text); -} - -function sanitizeClaudeSubagentResultText(value: string | null): string | null { - if (!value) { - return null; - } - - const withoutUsage = value.replace(/\s*[\s\S]*?<\/usage>\s*$/iu, "").trimEnd(); - // The parenthetical wording varies by harness version ("use SendMessage - // with ..." vs "internal ID - do not mention to user. Use SendMessage ..."). - const withoutContinuationFooter = withoutUsage - .replace( - /\s*agentId:\s*[A-Za-z0-9_-]+\s*\([^()]*?use\s+SendMessage\s+with\s+to:\s*['"`][^'"`]+['"`],\s*summary:\s*['"`][\s\S]*?['"`]\s+to\s+continue\s+this\s+agent\.?\)\s*$/iu, - "", - ) - .trim(); - - return withoutContinuationFooter.length > 0 ? withoutContinuationFooter : null; -} - -/** The Task tool_result for a `run_in_background` launch is an acknowledgment - * ("Async agent launched successfully. agentId: ..."), not the agent's - * output — the agent is still running at that point. */ -function isClaudeAsyncAgentLaunchAcknowledgment(value: string | null): boolean { - return value !== null && /^async agent launched successfully\b/iu.test(value); -} - -function extractClaudeTextContent(content: unknown): string | null { - const direct = asTrimmedString(content); - if (direct) { - return direct; - } - - if (!Array.isArray(content)) { - return null; - } - - const parts = content - .flatMap((entry) => { - const text = asTrimmedString(entry); - if (text) { - return [text]; - } - const entryRecord = asRecord(entry); - const entryText = asTrimmedString(entryRecord?.text); - return entryText ? [entryText] : []; - }) - .filter((part) => part.length > 0); - - return parts.length > 0 ? parts.join("\n\n") : null; -} - function extractCollabAgentStates(value: unknown): Map { const record = asRecord(value); const result = new Map(); @@ -1730,10 +1560,6 @@ function stringArray(value: unknown): string[] { .filter((entry): entry is string => entry !== null); } -function isSpawnAgentTool(tool: string | null): boolean { - return tool?.trim().toLowerCase() === "spawnagent"; -} - function subagentRoleFromAgentPath(agentPath: string | null): string | null { const lastSegment = agentPath ?.split(/[\\/]+/u) @@ -1850,15 +1676,6 @@ function normalizeSubagentProgressStatus(input: { return "running"; } -function normalizeStatusToken(value: string | null): string { - return ( - value - ?.trim() - .toLowerCase() - .replace(/[_\s-]+/gu, "") ?? "" - ); -} - export function isActiveSubagentStatus(status: SubagentProgressStatus): boolean { return status === "starting" || status === "running" || status === "waiting"; } @@ -2034,16 +1851,13 @@ export function deriveWorkLogEntries( .filter((activity) => !isPlanBoundaryToolActivity(activity)) .filter((activity) => !isSubagentNotificationReplayActivity(activity)) .map((activity) => toDerivedWorkLogEntry(activity, agentTaskIndex)); + // `activityKind` stays on the emitted entries: the conversation timeline + // keys its agent-lifecycle parking on it (task.progress/task.completed rows + // with an agent identity never render inline). Only the derivation-internal + // fields come off. return enrichGenericThinkingEntries( collapseDerivedWorkLogEntries(entries).filter(shouldKeepDerivedWorkLogEntry), - ).map( - ({ - activityKind: _activityKind, - collapseKey: _collapseKey, - redactedThinking: _redactedThinking, - ...entry - }) => entry, - ); + ).map(({ collapseKey: _collapseKey, redactedThinking: _redactedThinking, ...entry }) => entry); } /** The task-notification completion replay re-emits the original Task tool @@ -2284,7 +2098,16 @@ function toDerivedWorkLogEntry( if (activity.kind === "task.progress" || activity.kind === "task.completed") { const subagentType = asTrimmedString(payload?.subagentType); const taskId = asTrimmedString(payload?.taskId); - if (subagentType !== null || (taskId !== null && agentTaskIndex.agentTaskIds.has(taskId))) { + const ownerAgentToolUseId = asTrimmedString(payload?.ownerAgentToolUseId); + if (ownerAgentToolUseId !== null) { + // A task an agent started inside its own conversation (e.g. a background + // test run): its rows belong to that agent's lane, keyed by the spawn + // call so the tracker and the rail attribute them correctly. + entry.subagentTask = { subagentType, toolUseId: ownerAgentToolUseId }; + } else if ( + subagentType !== null || + (taskId !== null && agentTaskIndex.agentTaskIds.has(taskId)) + ) { entry.subagentTask = { subagentType, toolUseId: asTrimmedString(payload?.toolUseId), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 35e31996..b039795b 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -589,6 +589,11 @@ const TaskStartedPayload = Schema.Struct({ /** Authoritative live-task snapshots own this provider's pending count, so * this edge enriches lifecycle UI without incrementing it. */ pendingCountManagedBySnapshot: Schema.optional(Schema.Boolean), + /** Spawn tool call of the agent this task ran INSIDE, when the task was + * started by a subagent rather than the main model (e.g. a background test + * run the agent kicked off). Lets consumers attribute the task's rows to + * the agent instead of narrating them as the conversation's own work. */ + ownerAgentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), }); export type TaskStartedPayload = typeof TaskStartedPayload.Type; @@ -613,6 +618,8 @@ const TaskProgressPayload = Schema.Struct({ lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), subagentType: Schema.optional(TrimmedNonEmptyStringSchema), + /** See TaskStartedPayload.ownerAgentToolUseId. */ + ownerAgentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), }); export type TaskProgressPayload = typeof TaskProgressPayload.Type; @@ -627,6 +634,8 @@ const TaskCompletedPayload = Schema.Struct({ /** Authoritative live-task snapshots own this provider's pending count, so * this edge enriches lifecycle UI without decrementing it. */ pendingCountManagedBySnapshot: Schema.optional(Schema.Boolean), + /** See TaskStartedPayload.ownerAgentToolUseId. */ + ownerAgentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), }); export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 316bfeb7..600fbb85 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -43,6 +43,10 @@ "types": "./src/subagentPath.ts", "import": "./src/subagentPath.ts" }, + "./claudeSubagentActivity": { + "types": "./src/claudeSubagentActivity.ts", + "import": "./src/claudeSubagentActivity.ts" + }, "./logging": { "types": "./src/logging.ts", "import": "./src/logging.ts" diff --git a/packages/shared/src/claudeSubagentActivity.test.ts b/packages/shared/src/claudeSubagentActivity.test.ts new file mode 100644 index 00000000..666febc9 --- /dev/null +++ b/packages/shared/src/claudeSubagentActivity.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + claudeSubagentActivityItem, + extractClaudeSubagentResultText, +} from "./claudeSubagentActivity.ts"; + +describe("extractClaudeSubagentResultText", () => { + it("strips a trailing usage block and the whitespace around it", () => { + expect( + extractClaudeSubagentResultText("All three fixes landed.\n\ntokens: 12345\n"), + ).toBe("All three fixes landed."); + }); + + it("strips stacked trailing usage blocks but leaves mid-text ones alone", () => { + expect( + extractClaudeSubagentResultText( + "Done. a and more\nb c", + ), + ).toBe("Done. a and more"); + }); + + it("strips the SendMessage continuation footer in both harness wordings", () => { + expect( + extractClaudeSubagentResultText( + "Report ready.\n\nagentId: agent-a1b2 (use SendMessage with to: 'agent-a1b2', summary: 'continue the review' to continue this agent.)", + ), + ).toBe("Report ready."); + expect( + extractClaudeSubagentResultText( + "Report ready.\n\nagentId: agent-a1b2 (internal ID - do not mention to user. Use SendMessage with to: 'agent-a1b2', summary: 'continue' to continue this agent)", + ), + ).toBe("Report ready."); + }); + + it("keeps prose that merely mentions an agentId", () => { + expect(extractClaudeSubagentResultText("The log shows agentId: agent-a1b2 crashed.")).toBe( + "The log shows agentId: agent-a1b2 crashed.", + ); + }); + + it("stays linear on adversarial repetition instead of backtracking", () => { + // Regression guard for the CodeQL polynomial-redos finding: agent output + // is provider-influenced, so pathological whitespace/tag repetition must + // not hang the sanitizer. Correctness is the assertion; a quadratic + // implementation would time the suite out long before failing it. + const hostile = `report${"\t".repeat(50_000)}${"".repeat(2_000)}`; + expect(extractClaudeSubagentResultText(hostile)).toContain("report"); + }); +}); + +describe("claudeSubagentActivityItem", () => { + const source = (overrides: { + data?: Record; + payload?: Record; + activityKind?: string; + }) => ({ + activityId: "activity-1", + activityKind: overrides.activityKind ?? "tool.updated", + payload: { + toolCallId: "toolu_spawn", + status: "inProgress", + ...overrides.payload, + }, + data: { + toolName: "Agent", + input: { description: "Fix the reactor", subagent_type: "claude" }, + ...overrides.data, + }, + }); + + it("shapes a Claude Agent tool call into a collab item keyed by the spawn", () => { + const item = claudeSubagentActivityItem(source({})); + expect(item?.id).toBe("toolu_spawn"); + expect(item?.tool).toBe("Agent"); + expect(item?.agentRole).toBe("claude"); + expect(item?.prompt).toBe("Fix the reactor"); + expect(item?.receiverThreadIds).toEqual(["toolu_spawn"]); + expect((item?.agentsStates as Record)["toolu_spawn"]?.status).toBe( + "running", + ); + }); + + it("treats a background launch acknowledgment as still running", () => { + const item = claudeSubagentActivityItem( + source({ + activityKind: "tool.completed", + payload: { status: "completed" }, + data: { result: "Async agent launched successfully. agentId: agent-1" }, + }), + ); + const state = (item?.agentsStates as Record) + .toolu_spawn; + expect(state?.status).toBe("running"); + expect(state?.message).toBeNull(); + }); + + it("returns null for tools that are not agent spawns", () => { + expect(claudeSubagentActivityItem(source({ data: { toolName: "Bash" } }))).toBeNull(); + }); +}); diff --git a/packages/shared/src/claudeSubagentActivity.ts b/packages/shared/src/claudeSubagentActivity.ts new file mode 100644 index 00000000..ed974779 --- /dev/null +++ b/packages/shared/src/claudeSubagentActivity.ts @@ -0,0 +1,265 @@ +/** + * Recognizes Claude's Agent/Task tool activities and reshapes them into the + * collab-agent item shape the Codex driver emits natively (`data.item`), so + * every consumer of the subagent roster — the web session derivation and the + * server's durable projection — reads one canonical shape. Claude's tool rows + * carry `data.toolName` + `data.input` instead of a nested item, and their + * lifecycle is spread across tool status, background-launch acknowledgments, + * structured results, and task-notification replays; this module owns all of + * that interpretation. + */ + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | null { + return value && typeof value === "object" ? (value as UnknownRecord) : null; +} + +function asTrimmedString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function normalizeStatusToken(value: string | null): string { + return ( + value + ?.trim() + .toLowerCase() + .replace(/[_\s-]+/gu, "") ?? "" + ); +} + +export function isSpawnAgentTool(tool: string | null): boolean { + return tool?.trim().toLowerCase() === "spawnagent"; +} + +export function isClaudeSubagentToolName(toolName: string | null): boolean { + const normalized = toolName?.trim().toLowerCase(); + return ( + normalized === "agent" || + normalized === "task" || + normalized === "subagent" || + normalized === "sub-agent" || + normalized?.includes("subagent") === true || + normalized?.includes("sub-agent") === true + ); +} + +export function claudeSubagentStateStatus(itemStatus: string): string { + const normalized = normalizeStatusToken(itemStatus); + if (normalized === "completed") { + return "completed"; + } + if (normalized === "failed" || normalized === "errored" || normalized === "error") { + return "errored"; + } + if (normalized === "interrupted" || normalized === "aborted" || normalized === "cancelled") { + return "interrupted"; + } + return "running"; +} + +function claudeSubagentStatusFromActivityKind(activityKind: string): string { + if (activityKind === "tool.completed") { + return "completed"; + } + return "inProgress"; +} + +export function isTerminalClaudeSubagentState(stateStatus: string): boolean { + return stateStatus === "completed" || stateStatus === "errored" || stateStatus === "interrupted"; +} + +export function extractClaudeSubagentResultText(result: unknown): string | null { + const direct = asTrimmedString(result); + if (direct) { + return sanitizeClaudeSubagentResultText(direct); + } + + const resultRecord = asRecord(result); + if (!resultRecord) { + return null; + } + + const text = + extractClaudeTextContent(resultRecord.content) ?? + extractClaudeTextContent(resultRecord.text) ?? + extractClaudeTextContent(resultRecord.message); + return sanitizeClaudeSubagentResultText(text); +} + +function sanitizeClaudeSubagentResultText(value: string | null): string | null { + if (!value) { + return null; + } + + const withoutFooter = stripClaudeContinuationFooter(stripTrailingUsageBlocks(value)).trim(); + return withoutFooter.length > 0 ? withoutFooter : null; +} + +/** Strips trailing `` blocks (and the whitespace around them) + * by index arithmetic. The result text is provider-influenced input, so this + * deliberately avoids a backtracking regex over the whole string. */ +function stripTrailingUsageBlocks(value: string): string { + let result = value.trimEnd(); + for (;;) { + const lower = result.toLowerCase(); + if (!lower.endsWith("")) { + return result; + } + const open = lower.lastIndexOf("", result.length - "".length); + if (open === -1) { + return result; + } + result = result.slice(0, open).trimEnd(); + } +} + +/** The footer is one short parenthetical; anchoring the pattern to a bounded + * slice from the last `agentId:` keeps the regex input — and its worst + * case — small. The parenthetical wording varies by harness version ("use + * SendMessage with ..." vs "internal ID - do not mention to user. Use + * SendMessage ..."). */ +const AGENT_CONTINUATION_FOOTER_PATTERN = + /^agentId:\s*[A-Za-z0-9_-]+\s*\([^()]*?use\s+SendMessage\s+with\s+to:\s*['"`][^'"`]+['"`],\s*summary:\s*['"`][\s\S]*?['"`]\s+to\s+continue\s+this\s+agent\.?\)$/iu; + +const AGENT_CONTINUATION_FOOTER_MAX_CHARS = 600; + +function stripClaudeContinuationFooter(value: string): string { + const trimmed = value.trimEnd(); + const start = trimmed.toLowerCase().lastIndexOf("agentid:"); + if (start === -1 || trimmed.length - start > AGENT_CONTINUATION_FOOTER_MAX_CHARS) { + return trimmed; + } + return AGENT_CONTINUATION_FOOTER_PATTERN.test(trimmed.slice(start)) + ? trimmed.slice(0, start).trimEnd() + : trimmed; +} + +/** The Task tool_result for a `run_in_background` launch is an acknowledgment + * ("Async agent launched successfully. agentId: ..."), not the agent's + * output — the agent is still running at that point. */ +function isClaudeAsyncAgentLaunchAcknowledgment(value: string | null): boolean { + return value !== null && /^async agent launched successfully\b/iu.test(value); +} + +function extractClaudeTextContent(content: unknown): string | null { + const direct = asTrimmedString(content); + if (direct) { + return direct; + } + + if (!Array.isArray(content)) { + return null; + } + + const parts = content + .flatMap((entry) => { + const text = asTrimmedString(entry); + if (text) { + return [text]; + } + const entryRecord = asRecord(entry); + const entryText = asTrimmedString(entryRecord?.text); + return entryText ? [entryText] : []; + }) + .filter((part) => part.length > 0); + + return parts.length > 0 ? parts.join("\n\n") : null; +} + +export interface ClaudeSubagentActivitySource { + /** Fallback identity when the activity payload carries no tool call id. */ + readonly activityId: string; + /** The projected activity kind (e.g. "tool.updated", "tool.completed"). */ + readonly activityKind: string; + readonly payload: UnknownRecord; + readonly data: UnknownRecord | null; +} + +/** + * Builds a collab-agent item from a Claude Agent/Task tool activity, or null + * when the activity is not a Claude subagent tool call. The returned shape + * mirrors `data.item` on Codex collab activities: the spawn's tool_use id + * doubles as the agent id, and `agentsStates` carries the lifecycle state and + * (once terminal) the agent's final message. + */ +export function claudeSubagentActivityItem( + input: ClaudeSubagentActivitySource, +): UnknownRecord | null { + const toolName = asTrimmedString(input.data?.toolName); + if (!isClaudeSubagentToolName(toolName)) { + return null; + } + + const toolInput = asRecord(input.data?.input); + const toolCallId = + asTrimmedString(input.payload.toolCallId) ?? + asTrimmedString(input.data?.itemId) ?? + input.activityId; + const itemStatus = + asTrimmedString(input.payload.status) ?? + claudeSubagentStatusFromActivityKind(input.activityKind); + const resultText = extractClaudeSubagentResultText(input.data?.result); + const notificationStatus = asTrimmedString(asRecord(input.data?.taskNotification)?.status); + const structuredResultStatus = asTrimmedString(asRecord(input.data?.structuredResult)?.status); + // A background launch acknowledgment is harness plumbing, not agent output: + // the agent keeps running and its real message arrives later through the + // task-notification completion replay (which carries data.taskNotification). + const stateStatus = + notificationStatus !== null + ? claudeSubagentStateStatus( + notificationStatus === "stopped" ? "interrupted" : notificationStatus, + ) + : structuredResultStatus === "async_launched" || structuredResultStatus === "remote_launched" + ? "running" + : isClaudeAsyncAgentLaunchAcknowledgment(resultText) + ? "running" + : claudeSubagentStateStatus(itemStatus); + const agentMessage = isTerminalClaudeSubagentState(stateStatus) ? resultText : null; + const role = + asTrimmedString(toolInput?.subagent_type) ?? + asTrimmedString(toolInput?.subagentType) ?? + asTrimmedString(toolInput?.agent_type) ?? + asTrimmedString(toolInput?.agentType); + const nickname = + asTrimmedString(toolInput?.agentNickname) ?? + asTrimmedString(toolInput?.agent_nickname) ?? + asTrimmedString(toolInput?.nickname) ?? + asTrimmedString(toolInput?.name) ?? + asTrimmedString(toolInput?.displayName); + const prompt = + asTrimmedString(toolInput?.description) ?? + asTrimmedString(toolInput?.prompt) ?? + asTrimmedString(input.payload.detail); + // A spawn only names a model when the parent overrides one, and names it as + // an alias ("opus"). The agent's own forwarded messages state the resolved + // id, which is what the UI should prefer. + const model = asTrimmedString(toolInput?.model); + const resolvedModel = asTrimmedString(input.data?.subagentModel); + const reasoningEffort = + asTrimmedString(toolInput?.effort) ?? asTrimmedString(toolInput?.reasoning_effort); + + return { + id: toolCallId, + type: "collabAgentToolCall", + tool: toolName, + status: itemStatus, + ...(prompt ? { prompt } : {}), + ...(role ? { agentRole: role } : {}), + ...(nickname ? { agentNickname: nickname } : {}), + ...(model ? { model } : {}), + ...(resolvedModel ? { resolvedModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + receiverThreadIds: [toolCallId], + agentsStates: { + [toolCallId]: { + status: stateStatus, + ...(agentMessage ? { message: agentMessage } : { message: null }), + }, + }, + }; +}