From af8b8e15a2e1089e6c65027ca6175cdefaddebd5 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:03:33 -0400 Subject: [PATCH 1/2] Keep a subagent's work out of the inline feed and surface the Agents tab A background Claude agent's activity flooded the parent conversation: every per-tool task.progress tick rendered as its own inline row ("Activity - 128 actions" with 110+ leaked entries), while the agent tracker that should summarize the delegation never showed for turnless background stretches. Conversation feed: - deriveWorkLogEntries no longer strips activityKind from emitted entries, which had made the timeline's agent-lifecycle parking (isAgentLifecycleEntry) dead code in production - the exact check #157 shipped. - Background tasks an agent starts inside its own conversation (e.g. its test runs) are now attributed to the spawning agent: the Claude adapter records every tool_use id seen in forwarded subagent messages and stamps task events with ownerAgentToolUseId (new optional field on the task payload contracts), which the web parks out of the feed alongside the agent's own task stream. - Work groups with no turn to key on carry trackerAgentSpawnIds, and selectTurnAgents gained a spawn-call fallback, so a turnless background-agent group at the conversation tail shows the tracker bars and live status line instead of nothing. An all-delegation group's receipt reads "Agent working" / "Agents working" / "Agent activity" instead of claiming zero actions. Agents tab: - A fresh idle-to-running agent transition auto-surfaces the Agents tab: focused when the sidebar is hidden or on the launcher, joined in the background (live node only) when another tab has focus. The edge detector (advanceAgentsAutoOpenEdge) only believes idleness after the thread's detail snapshot has synced - the chat column now publishes a hydrated flag with its agent state - so loading or reloading a thread whose agent is already running never yanks the panel open. Once per batch, never on sheet layouts, and closing the tab mid-batch is respected. Durable roster: - projection_thread_subagents was empty for every thread: the projection only recognized Codex-shaped collab items (data.item). The Claude item shaping moved from web session-logic into @threadlines/shared/claudeSubagentActivity and the server projection now uses it too, links Claude transcript task ids, settles agents from task.completed (including restart-synthesized completions that carry only a taskId), and maps the shaper's "errored" state to failed. - Migration 048 backfills the roster by replaying each thread's roster-moving activities through the same fold, in rowid (append) order so same-timestamp lifecycle rows cannot resurrect a settled agent, one thread at a time. Verified end to end against a snapshot of real data: migration backfilled 27 agents in ~6s, the flood thread renders clean with the tracker live at the tail, and load/reload of a running-agent thread no longer auto-opens the panel. --- .../Layers/ProviderActivityProjection.ts | 9 + .../orchestration/subagentProjection.test.ts | 289 ++++++++++++++++++ .../src/orchestration/subagentProjection.ts | 90 +++++- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/048_BackfillThreadSubagents.ts | 113 +++++++ .../src/provider/Layers/ClaudeAdapter.test.ts | 144 +++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 52 +++- apps/web/src/agentsPanelStore.ts | 5 + apps/web/src/components/ChatView.tsx | 13 + .../chat/MessagesTimeline.logic.test.ts | 204 ++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 34 +++ .../src/components/chat/MessagesTimeline.tsx | 28 +- .../components/chat/agentsPanel.logic.test.ts | 27 ++ .../src/components/chat/agentsPanel.logic.ts | 42 ++- apps/web/src/rightPanelTabs.test.ts | 96 ++++++ apps/web/src/rightPanelTabs.ts | 76 +++++ .../routes/_chat.$environmentId.$threadId.tsx | 34 ++- apps/web/src/session-logic.ts | 235 ++------------ packages/contracts/src/providerRuntime.ts | 9 + packages/shared/package.json | 4 + packages/shared/src/claudeSubagentActivity.ts | 235 ++++++++++++++ 21 files changed, 1517 insertions(+), 224 deletions(-) create mode 100644 apps/server/src/orchestration/subagentProjection.test.ts create mode 100644 apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts create mode 100644 packages/shared/src/claudeSubagentActivity.ts 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 8710f083..43be4379 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -303,6 +303,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 = @@ -365,6 +370,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; @@ -1984,6 +1996,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. */ @@ -2038,10 +2054,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, @@ -2058,6 +2077,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; } @@ -3756,6 +3782,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( readonly toolUseId?: string; readonly subagentType?: string; readonly taskType?: string; + readonly ownerAgentToolUseId?: string; }, message: SDKMessage, ) { @@ -3769,6 +3796,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)) { @@ -3790,6 +3818,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), @@ -3830,6 +3859,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", @@ -3844,6 +3874,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), @@ -4779,6 +4810,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, { @@ -4787,6 +4821,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(toolUseId ? { toolUseId } : {}), ...(subagentType ? { subagentType } : {}), ...(taskType ? { taskType } : {}), + ...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}), }, message, ); @@ -4800,11 +4835,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({ @@ -4818,6 +4857,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), ...(toolUseId ? { toolUseId } : {}), ...(subagentType ? { subagentType } : {}), + ...(ownerAgentToolUseId ? { ownerAgentToolUseId } : {}), }, }); return; @@ -4868,6 +4908,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; @@ -6053,6 +6096,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 77282fc0..88c72274 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -100,6 +100,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { + selectEnvironmentState, selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, selectWorkspaceProjectsAcrossEnvironments, @@ -3336,6 +3337,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(() => { @@ -3353,6 +3364,7 @@ export default function ChatView(props: ChatViewProps) { workEntries: workLogEntries, providerLabel: activeProviderDriver, turnInFlight: activeTurnInProgress, + hydrated: threadDetailHydrated, threadCwd: gitCwd, onToggleBackgroundRunTerminal: toggleBackgroundRunTerminal, onStopBackgroundRun: stopBackgroundRun, @@ -3368,6 +3380,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 ec190a2b..833a701c 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.ts b/packages/shared/src/claudeSubagentActivity.ts new file mode 100644 index 00000000..3da8239e --- /dev/null +++ b/packages/shared/src/claudeSubagentActivity.ts @@ -0,0 +1,235 @@ +/** + * 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 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; +} + +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 }), + }, + }, + }; +} From 25dea6ae66005bcc593f6889a83ee2eafcc0ce3f Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:18:09 -0400 Subject: [PATCH 2/2] Sanitize subagent result text without polynomial backtracking CodeQL flagged the moved sanitizer's regexes (js/polynomial-redos): the trailing stripper and the SendMessage continuation-footer stripper both backtracked polynomially, and agent output is provider-influenced input. The usage stripper is now index arithmetic, and the footer pattern only ever runs anchored against a bounded slice from the last "agentId:". Covered by direct tests for the shared module, including an adversarial repetition case that would time out on the quadratic implementation. --- .../shared/src/claudeSubagentActivity.test.ts | 101 ++++++++++++++++++ packages/shared/src/claudeSubagentActivity.ts | 52 +++++++-- 2 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 packages/shared/src/claudeSubagentActivity.test.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 index 3da8239e..ed974779 100644 --- a/packages/shared/src/claudeSubagentActivity.ts +++ b/packages/shared/src/claudeSubagentActivity.ts @@ -96,17 +96,47 @@ function sanitizeClaudeSubagentResultText(value: string | null): string | null { 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; + 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