diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index e781de25..5b58e13f 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -467,6 +467,55 @@ describe("ProcessDiagnostics", () => { }); }); + it("never detects a live provider session's own child processes", () => { + const SERVER_PID = 100; + const processRow = ( + pid: number, + ppid: number, + command: string, + elapsed = "05:00", + ): ProcessDiagnostics.ProcessRow => ({ + pid, + ppid, + pgid: pid, + status: "S", + cpuPercent: 0, + rssBytes: 1024, + elapsed, + command, + }); + const processRows = [ + // The provider session and its transient tool children. + processRow(200, SERVER_PID, "claude --output-format stream-json"), + processRow(300, 200, "zsh -c source /Users/will/.claude/shell-snapshots/snapshot.sh"), + processRow(400, 300, "ugrep -G --ignore-files --hidden -I -- pattern ."), + // A genuinely orphaned dev process, reparented to launchd. + processRow(500, 1, "node scripts/dev-runner.ts dev --port 5990"), + ]; + + // Chat prose mentioned the wrapper and helper pids; hints matched them too. + const seeded = ProcessDiagnostics.resolveBackgroundRunsFromListeningPorts({ + urls: [], + pids: [300, 400], + portRows: [], + processRows, + commandHints: ["zsh -c source snapshot", "ugrep -G --ignore-files"], + serverPid: SERVER_PID, + }); + expect(seeded.runs).toEqual([]); + + // The orphan is still found through its command hint. + const orphan = ProcessDiagnostics.resolveBackgroundRunsFromListeningPorts({ + urls: [], + pids: [], + portRows: [], + processRows, + commandHints: ["node scripts/dev-runner.ts dev --port 5990"], + serverPid: SERVER_PID, + }); + expect(orphan.runs.map((run) => run.pid)).toEqual([500]); + }); + it("uses command hints to resolve descendant-owned preview ports", () => { const result = ProcessDiagnostics.resolveBackgroundRunsFromListeningPorts({ urls: ["http://localhost:6013", "http://localhost:14053"], diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index a72ec31e..2234b24f 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -1246,6 +1246,61 @@ function selectHintedProcessOnlyRows(input: { return best ? [best] : []; } +/** Command shapes of the provider CLIs the server spawns for sessions. Path + * segments count (`/opt/x/codex app-server`), dotted dirs (`.claude/…` in a + * wrapper's args) do not. */ +const PROVIDER_SESSION_COMMAND_PATTERN = + /(?:^|[\s/])(?:claude|codex|cursor-agent|opencode)(?=$|[\s.])/i; + +/** + * Pids living under a live provider session's process subtree. A session's + * own children — shell wrappers around tool calls, search helpers, background + * commands — are tracked work (the task stream and terminal rows own them), + * not lost processes, so process-only detection must never resurface them as + * "detected" rows with stop buttons. Anything truly orphaned reparents to + * init/launchd, leaves this subtree, and stays detectable. + */ +export function providerSessionShieldedPids( + rows: ReadonlyArray, + serverPid: number, +): Set { + const childrenByParent = new Map(); + for (const row of rows) { + const children = childrenByParent.get(row.ppid) ?? []; + children.push(row); + childrenByParent.set(row.ppid, children); + } + + const collectSubtree = (rootPid: number, into: Set) => { + const stack = [rootPid]; + while (stack.length > 0) { + const pid = stack.pop(); + if (pid === undefined || into.has(pid)) continue; + into.add(pid); + for (const child of childrenByParent.get(pid) ?? []) { + stack.push(child.pid); + } + } + }; + + const shielded = new Set(); + const serverStack = [...(childrenByParent.get(serverPid) ?? [])]; + const visited = new Set(); + while (serverStack.length > 0) { + const row = serverStack.pop(); + if (!row || visited.has(row.pid)) continue; + visited.add(row.pid); + if (PROVIDER_SESSION_COMMAND_PATTERN.test(row.command)) { + collectSubtree(row.pid, shielded); + continue; + } + for (const child of childrenByParent.get(row.pid) ?? []) { + serverStack.push(child); + } + } + return shielded; +} + export function resolveBackgroundRunsFromListeningPorts(input: { readonly urls: ReadonlyArray; readonly pids?: ReadonlyArray | undefined; @@ -1299,8 +1354,11 @@ export function resolveBackgroundRunsFromListeningPorts(input: { const serverDescendantPids = new Set( buildDescendantEntries(input.processRows ?? [], serverPid).map((entry) => entry.pid), ); + const shieldedPids = providerSessionShieldedPids(input.processRows ?? [], serverPid); const explicitProcessOnlyRows = uniquePositivePids(input.pids ?? []) - .filter((pid) => !portRunPids.has(pid) && serverDescendantPids.has(pid)) + .filter( + (pid) => !portRunPids.has(pid) && serverDescendantPids.has(pid) && !shieldedPids.has(pid), + ) .flatMap((pid) => { const row = processRowsByPid.get(pid); return row ? [row] : []; @@ -1310,7 +1368,7 @@ export function resolveBackgroundRunsFromListeningPorts(input: { ? selectHintedProcessOnlyRows({ processRows: input.processRows ?? [], commandHints: input.commandHints ?? [], - }) + }).filter((row) => !shieldedPids.has(row.pid)) : []; const processOnlyRows = [ ...new Map( diff --git a/apps/server/src/orchestration/subagentProjection.test.ts b/apps/server/src/orchestration/subagentProjection.test.ts index 76fb752d..abebd1e1 100644 --- a/apps/server/src/orchestration/subagentProjection.test.ts +++ b/apps/server/src/orchestration/subagentProjection.test.ts @@ -259,6 +259,52 @@ describe("projectSubagentActivity", () => { expect(settled[0]?.status).toBe("interrupted"); }); + it("coalesces a pending spawn with an id-keyed row instead of duplicating", () => { + const codexItem = (item: Record): OrchestrationThreadActivity => + activity({ + id: `codex-${String(item.id)}-${String(item.status)}`, + kind: "tool.updated", + turnId: TURN_ID, + payload: { itemType: "collab_agent_tool_call", data: { item } }, + }); + + // Spawn starts before the provider names the agent: a pending placeholder. + const pending = projectSubagentActivity( + [], + codexItem({ id: "call-1", tool: "spawnAgent", status: "inProgress", prompt: "Review" }), + ); + expect(pending).toHaveLength(1); + expect(pending[0]?.id).toBe("pending:call-1"); + + // A wait item reveals the agent id first, as its own row. + const revealed = projectSubagentActivity( + pending, + codexItem({ + id: "call-2", + tool: "wait", + status: "inProgress", + receiverThreadIds: ["agent-x"], + }), + ); + expect(revealed).toHaveLength(2); + + // The spawn completion carries both keys. The two rows are the same agent; + // leaving both behind would violate the roster table's unique constraints. + const settled = projectSubagentActivity( + revealed, + codexItem({ + id: "call-1", + tool: "spawnAgent", + status: "completed", + agentThreadId: "agent-x", + }), + ); + expect(settled).toHaveLength(1); + expect(settled[0]?.agentThreadId).toBe("agent-x"); + expect(settled[0]?.spawnCallId).toBe("call-1"); + expect(settled[0]?.objective).toBe("Review"); + }); + it("still folds Codex-shaped collab items", () => { const roster = projectSubagentActivity( [], diff --git a/apps/server/src/orchestration/subagentProjection.ts b/apps/server/src/orchestration/subagentProjection.ts index 33a7d3a7..ac7e3d6f 100644 --- a/apps/server/src/orchestration/subagentProjection.ts +++ b/apps/server/src/orchestration/subagentProjection.ts @@ -326,15 +326,70 @@ export function projectSubagentActivity( if (patches.length === 0) return current; const next = [...current]; for (const patch of patches) { - const index = next.findIndex( - (entry) => - (patch.agentThreadId !== null && patch.agentThreadId === entry.agentThreadId) || - (patch.spawnCallId !== null && patch.spawnCallId === entry.spawnCallId) || - patch.id === entry.id, - ); - const merged = mergeSubagent(index >= 0 ? next[index] : undefined, patch, activity); - if (index >= 0) next[index] = merged; + // A patch can match more than one row: a spawn that started as a + // `pending:` placeholder and an id-keyed row learned from a later item + // are the same agent once a patch carries both keys. All matches merge + // into one row — the persisted table is unique on id, agentThreadId and + // spawnCallId per thread, so leaving both rows behind is not a cosmetic + // duplicate but a constraint violation that fails the write. + const matches: number[] = []; + for (let index = 0; index < next.length; index += 1) { + const entry = next[index]; + if ( + entry && + ((patch.agentThreadId !== null && patch.agentThreadId === entry.agentThreadId) || + (patch.spawnCallId !== null && patch.spawnCallId === entry.spawnCallId) || + patch.id === entry.id) + ) { + matches.push(index); + } + } + const [primary, ...absorbed] = matches; + let base = primary !== undefined ? next[primary] : undefined; + for (const index of absorbed) { + const duplicate = next[index]; + if (base && duplicate) { + base = mergeSubagent( + base, + { ...duplicatePatchFrom(duplicate), id: duplicate.id }, + activity, + ); + } + } + const merged = mergeSubagent(base, patch, activity); + if (primary !== undefined) next[primary] = merged; else next.push(merged); + for (let cursor = absorbed.length - 1; cursor >= 0; cursor -= 1) { + const index = absorbed[cursor]; + if (index !== undefined) next.splice(index, 1); + } } return next.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)); } + +/** Reshapes an absorbed duplicate row into a patch so its learned fields fold + * into the surviving row through the same merge path patches use. */ +function duplicatePatchFrom(duplicate: OrchestrationSubagent): SubagentPatch { + return { + id: duplicate.id, + agentThreadId: duplicate.agentThreadId, + parentAgentThreadId: duplicate.parentAgentThreadId, + spawnCallId: duplicate.spawnCallId, + transcriptAgentId: duplicate.transcriptAgentId, + turnId: duplicate.turnId, + agentPath: duplicate.agentPath, + parentAgentPath: duplicate.parentAgentPath, + treeDepth: duplicate.treeDepth, + nickname: duplicate.nickname, + role: duplicate.role, + objective: duplicate.objective, + status: duplicate.status, + requestedModel: duplicate.requestedModel, + resolvedModel: duplicate.resolvedModel, + reasoningEffort: duplicate.reasoningEffort, + modelProvenance: duplicate.modelProvenance, + reasoningEffortProvenance: duplicate.reasoningEffortProvenance, + resultBody: duplicate.resultBody, + resultCreatedAt: duplicate.resultCreatedAt, + }; +} diff --git a/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.test.ts b/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.test.ts new file mode 100644 index 00000000..09c29f84 --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.test.ts @@ -0,0 +1,162 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +interface SubagentRow { + readonly thread_id: string; + readonly subagent_id: string; + readonly agent_thread_id: string | null; + readonly spawn_call_id: string | null; + readonly role: string | null; + readonly objective: string | null; + readonly status: string; +} + +layer("048_BackfillThreadSubagents", (it) => { + it.effect("rebuilds Claude and Codex rosters and survives hostile rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 47 }); + + const insertActivity = (input: { + threadId: string; + activityId: string; + kind: string; + payload: string; + turnId?: string | null; + createdAt?: string; + }) => sql` + INSERT INTO projection_thread_activities ( + thread_id, activity_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES ( + ${input.threadId}, ${input.activityId}, ${input.turnId ?? null}, 'tool', + ${input.kind}, 'Subagent task', ${input.payload}, NULL, + ${input.createdAt ?? "2026-08-15T00:00:00.000Z"} + ) + `; + + // Claude-shaped thread: spawn + task link + settle. + yield* insertActivity({ + threadId: "thread-claude", + activityId: "c1", + kind: "tool.started", + turnId: "turn-1", + payload: JSON.stringify({ + itemType: "collab_agent_tool_call", + toolCallId: "toolu_spawn", + status: "inProgress", + data: { + toolName: "Agent", + input: { description: "Fix the bug trio", subagent_type: "claude" }, + }, + }), + }); + yield* insertActivity({ + threadId: "thread-claude", + activityId: "c2", + kind: "task.completed", + payload: JSON.stringify({ + taskId: "task-1", + status: "completed", + toolUseId: "toolu_spawn", + }), + createdAt: "2026-08-15T00:05:00.000Z", + }); + + // Codex-shaped thread reproducing the pending-spawn/late-id collision + // that used to violate the roster's unique keys and abort the migration. + yield* insertActivity({ + threadId: "thread-codex", + activityId: "x1", + kind: "tool.updated", + turnId: "turn-2", + payload: JSON.stringify({ + itemType: "collab_agent_tool_call", + data: { + item: { id: "call-1", tool: "spawnAgent", status: "inProgress", prompt: "Review" }, + }, + }), + }); + yield* insertActivity({ + threadId: "thread-codex", + activityId: "x2", + kind: "tool.updated", + payload: JSON.stringify({ + itemType: "collab_agent_tool_call", + data: { + item: { + id: "call-2", + tool: "wait", + status: "inProgress", + receiverThreadIds: ["agent-x"], + }, + }, + }), + createdAt: "2026-08-15T00:01:00.000Z", + }); + yield* insertActivity({ + threadId: "thread-codex", + activityId: "x3", + kind: "tool.updated", + payload: JSON.stringify({ + itemType: "collab_agent_tool_call", + data: { + item: { + id: "call-1", + tool: "spawnAgent", + status: "completed", + agentThreadId: "agent-x", + }, + }, + }), + createdAt: "2026-08-15T00:02:00.000Z", + }); + + // Malformed payload row: json_extract raises on it unless filtered. + yield* insertActivity({ + threadId: "thread-broken", + activityId: "b1", + kind: "task.progress", + payload: "not json", + }); + + // Orphan roster row whose thread has no roster-moving activities left. + yield* sql` + INSERT INTO projection_thread_subagents ( + thread_id, subagent_id, status, created_at, updated_at + ) VALUES ('thread-gone', 'stale-agent', 'running', '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z') + `; + + yield* runMigrations({ toMigrationInclusive: 48 }); + + const rows = (yield* sql` + SELECT thread_id, subagent_id, agent_thread_id, spawn_call_id, role, objective, status + FROM projection_thread_subagents + ORDER BY thread_id ASC + `) as unknown as ReadonlyArray; + + assert.deepStrictEqual( + rows.map((row) => row.thread_id), + ["thread-claude", "thread-codex"], + ); + + const claude = rows[0]; + assert.strictEqual(claude?.subagent_id, "toolu_spawn"); + assert.strictEqual(claude?.role, "claude"); + assert.strictEqual(claude?.objective, "Fix the bug trio"); + assert.strictEqual(claude?.status, "completed"); + + const codex = rows[1]; + assert.strictEqual(codex?.agent_thread_id, "agent-x"); + assert.strictEqual(codex?.spawn_call_id, "call-1"); + assert.strictEqual(codex?.objective, "Review"); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts b/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts index bd2b37b8..59019034 100644 --- a/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts +++ b/apps/server/src/persistence/Migrations/048_BackfillThreadSubagents.ts @@ -18,9 +18,11 @@ interface ActivityRow { /** 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. */ + * projectSubagentActivity, so it never has to be loaded. The json_valid + * guard matters: json_extract RAISES on malformed JSON rather than returning + * NULL, and one corrupt row must not abort the whole migration. */ const CANDIDATE_FILTER = ` - json_extract(payload_json, '$.itemType') = 'collab_agent_tool_call' + (json_valid(payload_json) AND json_extract(payload_json, '$.itemType') = 'collab_agent_tool_call') OR kind IN ('task.started', 'task.progress', 'task.completed', 'subagent.metadata') `; @@ -36,8 +38,14 @@ const CANDIDATE_FILTER = ` * 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. + * update would resurrect a settled agent. (One caveat: the thread.reverted + * handler rewrites a thread's activities in listing order with fresh rowids — + * for those threads rowid order IS listing order, which is also exactly what + * the live rebuild folds, so the results agree.) Threads are processed one at + * a time so memory stays proportional to a single thread's agent activity, + * and a thread whose replay fails is logged and skipped rather than aborting + * the migration — an unbootable server is strictly worse than one thread + * with a stale roster. */ export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -46,7 +54,26 @@ export default Effect.gen(function* () { SELECT DISTINCT thread_id FROM projection_thread_activities WHERE ${CANDIDATE_FILTER} `)) as unknown as ReadonlyArray<{ readonly thread_id: string }>; + // This is a rebuild: rosters for threads with no roster-moving activity + // left (all candidate rows pruned or the 047 SQL backfill guessed wrong) + // would otherwise survive every re-run untouched. + yield* sql.unsafe(` + DELETE FROM projection_thread_subagents WHERE thread_id NOT IN ( + SELECT DISTINCT thread_id FROM projection_thread_activities WHERE ${CANDIDATE_FILTER} + ) + `); + for (const { thread_id: threadId } of threads) { + yield* backfillThread(sql, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("subagent roster backfill skipped a thread", { threadId, error }), + ), + ); + } +}); + +const backfillThread = (sql: SqlClient.SqlClient, threadId: string) => + Effect.gen(function* () { const rows = (yield* sql.unsafe( ` SELECT activity_id, turn_id, tone, kind, summary, payload_json, sequence, created_at @@ -79,7 +106,7 @@ export default Effect.gen(function* () { } yield* sql`DELETE FROM projection_thread_subagents WHERE thread_id = ${threadId}`; - if (subagents.length === 0) continue; + if (subagents.length === 0) return; yield* sql` INSERT INTO projection_thread_subagents ${sql.insert( subagents.map((row) => ({ @@ -109,5 +136,4 @@ export default Effect.gen(function* () { })), )} `; - } -}); + }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 690606e7..ea108696 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6460,12 +6460,8 @@ export default function ChatView(props: ChatViewProps) { /** The closed panel button's live-agent node. */ const headerLiveAgents = useMemo( - () => - summarizeLiveAgents({ - subagents: subagentProgress?.items ?? EMPTY_SUBAGENT_ITEMS, - backgroundRuns, - }), - [backgroundRuns, subagentProgress?.items], + () => summarizeLiveAgents({ subagents: subagentProgress?.items ?? EMPTY_SUBAGENT_ITEMS }), + [subagentProgress?.items], ); /** diff --git a/apps/web/src/components/chat/AgentsPanel.browser.tsx b/apps/web/src/components/chat/AgentsPanel.browser.tsx index 3dd09177..ae3c7526 100644 --- a/apps/web/src/components/chat/AgentsPanel.browser.tsx +++ b/apps/web/src/components/chat/AgentsPanel.browser.tsx @@ -153,12 +153,14 @@ describe("AgentsPanel", () => { await expect.element(page.getByText("Router sweep")).toBeVisible(); const branches = [...document.querySelectorAll("[data-agent-branch='true']")]; + // Agents in attention order first; the provider's command run draws its + // branch in the Commands section after them. expect(branches.map((branch) => branch.getAttribute("data-agent-branch-status"))).toEqual([ - "running", "running", "waiting", "failed", "completed", + "running", ]); expect(branches.map((branch) => branch.getAttribute("data-agent-branch-kind"))).toContain( "run", diff --git a/apps/web/src/components/chat/AgentsPanel.tsx b/apps/web/src/components/chat/AgentsPanel.tsx index dd9f6aa2..3e996542 100644 --- a/apps/web/src/components/chat/AgentsPanel.tsx +++ b/apps/web/src/components/chat/AgentsPanel.tsx @@ -324,13 +324,20 @@ export const AgentsPanel = memo(function AgentsPanel({ [providerLabel, view], ); const providerGlyph = useMemo(() => providerIconForDriverLabel(providerLabel), [providerLabel]); - const anyRunning = hasRunningAgentActivity({ subagents, backgroundRuns }); + const anyRunning = hasRunningAgentActivity({ subagents }); const selectedSubagent = findAgentsPanelSubagent(view, selectedAgentId); const selectedSubagentThreadId = selectedSubagent?.agentThreadId ?? null; const selectedSubagentWorkEntries = useMemo( () => selectedSubagentThreadId - ? workEntries.filter((entry) => entry.sourceAgentThreadId === selectedSubagentThreadId) + ? workEntries.filter( + (entry) => + entry.sourceAgentThreadId === selectedSubagentThreadId || + // Background tasks the agent started in its own conversation: + // for Claude agents the owner spawn call id is the agent's + // thread id, so they belong to the same drill-in view. + entry.ownerAgentToolUseId === selectedSubagentThreadId, + ) : [], [selectedSubagentThreadId, workEntries], ); @@ -508,6 +515,31 @@ export const AgentsPanel = memo(function AgentsPanel({ ) : null} + {/* Background commands are not agents; they keep their rows (and + stop handles) below the agents instead of crowding them out. */} + {view.commands.length > 0 ? ( + <> + + Commands + +
    + {view.commands.map((branch) => ( + + ))} +
+ + ) : null} )} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2ce0afef..860439b2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1743,7 +1743,12 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "message" && row.message.id === ctx.activeSearchTargetMessageId; return (
{ expect(view.earlier[0]?.time).toBe("12m ago"); }); + it("keeps background commands out of the agent list, in their own section", () => { + const view = buildAgentsPanelView({ + subagents: [buildSubagent({ id: "live", agentThreadId: "agent-live", status: "running" })], + backgroundRuns: [ + buildRun({ + id: "provider:task-watch", + source: "provider", + label: "Watch PR 162 CI until settled", + port: null, + }), + ], + }); + + expect(view.current.map((branch) => branch.kind)).toEqual(["subagent"]); + expect(view.commands.map((branch) => branch.kind)).toEqual(["run"]); + expect(view.hasAny).toBe(true); + }); + + it("counts a command-only thread as having content", () => { + const view = buildAgentsPanelView({ + subagents: [], + backgroundRuns: [buildRun({ id: "provider:task-1", source: "provider" })], + }); + expect(view.current).toEqual([]); + expect(view.commands).toHaveLength(1); + expect(view.hasAny).toBe(true); + }); + describe("formatAgentsPanelSummary", () => { const viewOf = (input: Parameters[0]) => buildAgentsPanelView(input); @@ -552,7 +580,7 @@ describe("formatLiveAgentStatusLine", () => { }); describe("summarizeLiveAgents", () => { - it("counts live subagents and runs together and flags one waiting on the user", () => { + it("counts live subagents only and flags one waiting on the user", () => { expect( summarizeLiveAgents({ subagents: [ @@ -560,23 +588,12 @@ describe("summarizeLiveAgents", () => { buildSubagent({ id: "b", status: "waiting" }), buildSubagent({ id: "c", status: "completed" }), ], - // The user's own terminal does not make the count: the indicator - // advertises the agents panel, which no longer lists it. - backgroundRuns: [ - buildRun({ id: "run" }), - buildRun({ id: "shell", source: "terminal", terminalId: "default" }), - ], }), - ).toEqual({ count: 3, waitingCount: 1 }); + ).toEqual({ count: 2, waitingCount: 1 }); }); it("says nothing when the thread is idle", () => { - expect( - summarizeLiveAgents({ - subagents: [buildSubagent({ status: "completed" })], - backgroundRuns: [], - }), - ).toBeNull(); + expect(summarizeLiveAgents({ subagents: [buildSubagent({ status: "completed" })] })).toBeNull(); }); }); diff --git a/apps/web/src/components/chat/agentsPanel.logic.ts b/apps/web/src/components/chat/agentsPanel.logic.ts index 313b1cb5..85b49e4c 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.ts @@ -325,10 +325,15 @@ function historyBranch( } export interface AgentsPanelView { - /** The turn's own branches, in attention order. */ + /** The turn's own agents, in attention order. */ readonly current: ReadonlyArray; /** Agents from earlier in the thread, newest first. */ readonly earlier: ReadonlyArray; + /** Non-agent background work — command tasks, detected processes. A CI + * watcher loop is not an agent, and a tab called Agents putting commands + * above the agents read as exactly that; they keep their rows (and stop + * buttons) in their own section below instead. */ + readonly commands: ReadonlyArray; /** False only when the thread has never run an agent at all. */ readonly hasAny: boolean; } @@ -356,7 +361,7 @@ export function formatAgentsPanelSummary( ); const runningCount = subagents.filter((branch) => branch.status === "running").length; const waitingCount = subagents.filter((branch) => branch.status === "waiting").length; - const runCount = view.current.filter((branch) => branch.kind === "run").length; + const runCount = view.commands.length; const parts = [ providerDisplayLabel(providerLabel), @@ -393,7 +398,11 @@ export function buildAgentsPanelView(input: { readonly providerLabel?: string | null | undefined; readonly nowMs?: number | undefined; }): AgentsPanelView { - const current = buildAgentBranches(input); + const branches = buildAgentBranches(input); + const current = branches.filter( + (branch): branch is AgentSubagentBranch => branch.kind === "subagent", + ); + const commands = branches.filter((branch): branch is AgentRunBranch => branch.kind === "run"); const currentAgentKeys = new Set(input.subagents.map(subagentIdentity)); const earlier = (input.history ?? []) .filter((entry) => !currentAgentKeys.has(subagentIdentity(entry.item))) @@ -403,7 +412,12 @@ export function buildAgentsPanelView(input: { (parseTimestamp(right.item.updatedAt) ?? 0) - (parseTimestamp(left.item.updatedAt) ?? 0), ); - return { current, earlier, hasAny: current.length > 0 || earlier.length > 0 }; + return { + current, + earlier, + commands, + hasAny: current.length > 0 || earlier.length > 0 || commands.length > 0, + }; } /** `agentThreadId` is the id every other surface addresses an agent by; the @@ -500,12 +514,13 @@ export interface LiveAgentIndicator { */ export function summarizeLiveAgents(input: { readonly subagents: ReadonlyArray; - readonly backgroundRuns: ReadonlyArray; }): LiveAgentIndicator | null { - const statuses = [ - ...input.subagents.map((item) => subagentBranchStatus(item.status)), - ...agentInitiatedRuns(input.backgroundRuns).map(backgroundRunBranchStatus), - ].filter(isLiveAgentBranchStatus); + // Background command runs deliberately do not count: a CI watcher is not an + // agent, and every live-agent affordance keyed off this (tab dot, closed + // panel node, launcher counts) would otherwise claim one is working. + const statuses = input.subagents + .map((item) => subagentBranchStatus(item.status)) + .filter(isLiveAgentBranchStatus); if (statuses.length === 0) { return null; } @@ -539,16 +554,13 @@ export function formatSubagentReceiptSummary(body: string): string | null { * Whether the rail has anything live to show right now. The panel's header * node and the rail tab's node read from this so they never disagree. */ +/** Whether an actual agent is running. Background command runs do not count — + * see summarizeLiveAgents — so the Agents tab's live dot and its auto-open + * never fire for a plain background command. */ export function hasRunningAgentActivity(input: { readonly subagents: ReadonlyArray; - readonly backgroundRuns: ReadonlyArray; }): boolean { - return ( - input.subagents.some((item) => subagentBranchStatus(item.status) === "running") || - agentInitiatedRuns(input.backgroundRuns).some( - (run) => backgroundRunBranchStatus(run) === "running", - ) - ); + return input.subagents.some((item) => subagentBranchStatus(item.status) === "running"); } function parseTimestamp(value: string): number | null { diff --git a/apps/web/src/components/chat/rightPanelLauncherState.test.ts b/apps/web/src/components/chat/rightPanelLauncherState.test.ts index dc851a54..3c7621cb 100644 --- a/apps/web/src/components/chat/rightPanelLauncherState.test.ts +++ b/apps/web/src/components/chat/rightPanelLauncherState.test.ts @@ -240,9 +240,21 @@ describe("buildRightPanelLauncherStates", () => { workingTreeFileCount: null, reviewableTurnCount: 0, diffHasExplicitTarget: false, + // The background run is a command, not an agent; it neither inflates + // the agent count nor reads as one running. agents: { subagents: [buildSubagent()], backgroundRuns: [buildRun()], history }, }).agents, - ).toEqual({ description: "2 of 3 agents running.", empty: false }); + ).toEqual({ description: "1 of 2 agents running.", empty: false }); + + // A thread with only background commands is not empty, but has no agents. + expect( + buildRightPanelLauncherStates({ + workingTreeFileCount: null, + reviewableTurnCount: 0, + diffHasExplicitTarget: false, + agents: { subagents: [], backgroundRuns: [buildRun()], history: [] }, + }).agents, + ).toEqual({ description: "1 background command.", empty: false }); expect( buildRightPanelLauncherStates({ diff --git a/apps/web/src/components/chat/rightPanelLauncherState.ts b/apps/web/src/components/chat/rightPanelLauncherState.ts index c20fd75f..fb41d21e 100644 --- a/apps/web/src/components/chat/rightPanelLauncherState.ts +++ b/apps/web/src/components/chat/rightPanelLauncherState.ts @@ -180,7 +180,11 @@ function agentsState(agents: RightPanelLauncherAgentsInput | null): RightPanelSu const view = buildAgentsPanelView(agents); const total = view.current.length + view.earlier.length; if (total === 0) { - return { description: "No agents yet.", empty: true }; + // Commands are not agents, but a surface with live rows on it is not + // empty either. + return view.commands.length > 0 + ? { description: `${pluralize(view.commands.length, "background command")}.`, empty: false } + : { description: "No agents yet.", empty: true }; } const live = summarizeLiveAgents(agents); const waitingCount = live?.waitingCount ?? 0; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 1238aa1f..33100f66 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -3888,6 +3888,73 @@ describe("deriveActiveWorkStartedAt", () => { }); }); +describe("subagent.metadata promoted-run lifecycle", () => { + /** The three metadata activities a promoted `codex exec` run actually + * projects, copied from a live thread: spawn (callId only), the id link + * once the rollout is claimed, and completion with the final message. */ + const spawnMetadata = makeActivity({ + id: "meta-1", + kind: "subagent.metadata", + summary: "Subagent metadata", + tone: "info", + turnId: "76441666-8b1e-471f-973f-9a4df8c68929", + createdAt: "2026-08-16T22:44:40.796Z", + payload: { + callId: "toolu_01VFJG1vAyYoJLjP3U38mFfk", + status: "running", + agentRole: "codex", + objective: "Run sol second-opinion review of sanitizer commit", + reasoningEffort: "medium", + reasoningEffortSource: "explicit", + }, + }); + const linkMetadata = makeActivity({ + id: "meta-2", + kind: "subagent.metadata", + summary: "Subagent metadata", + tone: "info", + createdAt: "2026-08-16T22:44:45.000Z", + payload: { + callId: "toolu_01VFJG1vAyYoJLjP3U38mFfk", + agentThreadId: "codex-exec:01a00cbf", + transcriptAgentId: "codex-exec:01a00cbf", + status: "running", + }, + }); + const completionMetadata = makeActivity({ + id: "meta-3", + kind: "subagent.metadata", + summary: "Subagent metadata", + tone: "info", + createdAt: "2026-08-16T22:45:20.385Z", + payload: { + callId: "toolu_01VFJG1vAyYoJLjP3U38mFfk", + agentThreadId: "codex-exec:01a00cbf", + status: "completed", + resultBody: "**Verdict:** Partially sound.", + }, + }); + + it("shows a promoted run while it is still pending an id", () => { + const history = deriveThreadSubagentHistory([spawnMetadata]); + expect(history).toHaveLength(1); + expect(history[0]?.item.role).toBe("codex"); + expect(history[0]?.item.status).toBe("running"); + expect(history[0]?.item.objective).toBe("Run sol second-opinion review of sanitizer commit"); + expect(history[0]?.item.reasoningEffort).toBe("medium"); + }); + + it("migrates the pending record onto the agent id and settles with the result", () => { + const history = deriveThreadSubagentHistory([spawnMetadata, linkMetadata, completionMetadata]); + expect(history).toHaveLength(1); + const entry = history[0]; + expect(entry?.item.agentThreadId).toBe("codex-exec:01a00cbf"); + expect(entry?.item.status).toBe("completed"); + expect(entry?.item.role).toBe("codex"); + expect(entry?.resultBody).toBe("**Verdict:** Partially sound."); + }); +}); + describe("deriveSubagentProgressState", () => { it("tracks spawned agents through pending, running, and completed states", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b1d3ba1a..ffc6b0be 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -130,6 +130,11 @@ export interface WorkLogEntry { * is the test for "this is not the main agent's activity". The conversation * excludes these rows; the rail's Agents tab owns them. */ sourceAgentThreadId?: string; + /** Spawn call of the agent whose conversation started this background task + * (e.g. a test run the agent kicked off). Set only on task rows the + * provider stamped with an owner; the rail's per-agent work view includes + * these rows alongside the `sourceAgentThreadId` ones. */ + ownerAgentToolUseId?: string; /** Provider tool call id backing this row, when the activity carried one. * Lets the timeline correlate a subagent lane with its spawn row. */ toolCallId?: string; @@ -1240,6 +1245,15 @@ function collectSubagentActivityRecords( continue; } + // Promoted runs (a background `codex exec` launched by the main model) + // narrate their whole lifecycle through semantic metadata activities + // rather than collab tool items; without this fold the agent only ever + // reaches the UI through a durable-roster snapshot, i.e. after a reload. + if (activity.kind === "subagent.metadata") { + applySubagentMetadataActivity(byAgentId, pendingSpawnKeysByCallId, activity, payload); + continue; + } + if (extractWorkLogItemType(payload) !== "collab_agent_tool_call") { continue; } @@ -1531,6 +1545,122 @@ function applySubagentTaskCompletion( }); } +/** Folds a semantic `subagent.metadata` activity (the promoted-run lifecycle + * channel: role, objective, effective settings, status, final result) into + * the same records collab tool items build. Mirrors the server projection's + * metadata patch so the live view and the durable roster tell one story. */ +function applySubagentMetadataActivity( + byAgentId: Map, + pendingSpawnKeysByCallId: Map, + activity: OrchestrationThreadActivity, + payload: Record, +): void { + const agentThreadId = asTrimmedString(payload.agentThreadId); + const callId = asTrimmedString(payload.spawnCallId) ?? asTrimmedString(payload.callId); + const key = agentThreadId ?? (callId ? `pending:${callId}` : null); + if (!key) { + return; + } + + // The id usually arrives on a later metadata update than the spawn; migrate + // the pending record the same way collab items do. + if (agentThreadId && callId) { + const pendingKey = pendingSpawnKeysByCallId.get(callId); + if (pendingKey) { + const pendingRecord = byAgentId.get(pendingKey); + if (pendingRecord) { + byAgentId.set(agentThreadId, { + ...pendingRecord, + id: agentThreadId, + agentThreadId, + updatedAt: activity.createdAt, + }); + byAgentId.delete(pendingKey); + } + pendingSpawnKeysByCallId.delete(callId); + } + } else if (!agentThreadId && callId) { + pendingSpawnKeysByCallId.set(callId, key); + } + + const previous = byAgentId.get(key); + const rawStatus = asTrimmedString(payload.status); + const status: SubagentProgressStatus = + rawStatus === "starting" || + rawStatus === "running" || + rawStatus === "waiting" || + rawStatus === "completed" || + rawStatus === "failed" || + rawStatus === "interrupted" + ? rawStatus + : (previous?.status ?? "running"); + const role = + asTrimmedString(payload.agentRole) ?? + asTrimmedString(payload.role) ?? + asTrimmedString(payload.taskName) ?? + previous?.role ?? + null; + const nickname = + asTrimmedString(payload.nickname) ?? + asTrimmedString(payload.agentNickname) ?? + previous?.nickname ?? + null; + const modelSource = + asTrimmedString(payload.modelSource) ?? asTrimmedString(payload.modelProvenance); + const model = asTrimmedString(payload.model); + const requestedModel = + asTrimmedString(payload.requestedModel) ?? + (modelSource === "explicit" || modelSource === "inherited" ? model : null); + const resolvedModel = + asTrimmedString(payload.resolvedModel) ?? + (modelSource === "provider" ? model : null) ?? + previous?.resolvedModel ?? + null; + const resultBody = + (typeof payload.resultBody === "string" && payload.resultBody.trim().length > 0 + ? payload.resultBody + : null) ?? + previous?.resultBody ?? + null; + const resultIsNew = resultBody !== null && (previous?.resultBody ?? null) === null; + + byAgentId.set(key, { + id: key, + agentThreadId: agentThreadId ?? previous?.agentThreadId ?? null, + transcriptAgentId: + asTrimmedString(payload.transcriptAgentId) ?? previous?.transcriptAgentId ?? agentThreadId, + spawnCallId: callId ?? previous?.spawnCallId ?? null, + agentPath: asTrimmedString(payload.agentPath) ?? previous?.agentPath ?? null, + parentAgentPath: asTrimmedString(payload.parentAgentPath) ?? previous?.parentAgentPath ?? null, + treeDepth: previous?.treeDepth ?? 0, + turnId: activity.turnId ?? previous?.turnId ?? null, + label: subagentDisplayLabel({ role, nickname: null }), + ...(nickname ? { nickname } : {}), + role, + objective: + asTrimmedString(payload.objective) ?? + asTrimmedString(payload.prompt) ?? + previous?.objective ?? + null, + status, + statusLabel: subagentProgressStatusLabel(status), + resolvedModel, + model: resolvedModel ?? requestedModel ?? previous?.model ?? null, + reasoningEffort: asTrimmedString(payload.reasoningEffort) ?? previous?.reasoningEffort ?? null, + // A settled agent's live text no longer describes it. + liveBody: resultBody !== null ? null : (previous?.liveBody ?? null), + liveBodyUpdatedAt: resultBody !== null ? null : (previous?.liveBodyUpdatedAt ?? null), + telemetry: previous?.telemetry ?? null, + createdAt: previous?.createdAt ?? activity.createdAt, + updatedAt: activity.createdAt, + resultActivityId: resultIsNew ? activity.id : (previous?.resultActivityId ?? null), + resultBody, + resultCreatedAt: resultIsNew + ? (asTrimmedString(payload.resultCreatedAt) ?? activity.createdAt) + : (previous?.resultCreatedAt ?? null), + }); +} + function extractCollabAgentStates(value: unknown): Map { const record = asRecord(value); const result = new Map(); @@ -2102,8 +2232,12 @@ function toDerivedWorkLogEntry( 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. + // call so the tracker and the rail attribute them correctly. The owner + // id also stays on the entry itself: for Claude agents the spawn call id + // IS the agent's thread id, so the rail's per-agent work filter can + // include these rows the same way it includes forwarded child work. entry.subagentTask = { subagentType, toolUseId: ownerAgentToolUseId }; + entry.ownerAgentToolUseId = ownerAgentToolUseId; } else if ( subagentType !== null || (taskId !== null && agentTaskIndex.agentTaskIds.has(taskId)) diff --git a/packages/shared/src/claudeSubagentActivity.test.ts b/packages/shared/src/claudeSubagentActivity.test.ts index 666febc9..eebf8183 100644 --- a/packages/shared/src/claudeSubagentActivity.test.ts +++ b/packages/shared/src/claudeSubagentActivity.test.ts @@ -46,6 +46,10 @@ describe("extractClaudeSubagentResultText", () => { // 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"); + // Stacked complete blocks exercise the loop itself: each strip must move + // an index, not rescan the string, or this input goes quadratic. + const stacked = `report${"x".repeat(20_000)}`; + expect(extractClaudeSubagentResultText(stacked)).toBe("report"); }); }); diff --git a/packages/shared/src/claudeSubagentActivity.ts b/packages/shared/src/claudeSubagentActivity.ts index ed974779..faef8586 100644 --- a/packages/shared/src/claudeSubagentActivity.ts +++ b/packages/shared/src/claudeSubagentActivity.ts @@ -104,20 +104,29 @@ function sanitizeClaudeSubagentResultText(value: string | null): string | null { * 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(); + // Lowercased once; the loop only moves an end index backward, so stacked + // trailing blocks stay linear instead of re-scanning the string per block. + const lower = value.toLowerCase(); + let end = value.length; for (;;) { - const lower = result.toLowerCase(); - if (!lower.endsWith("")) { - return result; + while (end > 0 && isWhitespaceCharCode(value.charCodeAt(end - 1))) { + end -= 1; } - const open = lower.lastIndexOf("", result.length - "".length); + if (!lower.endsWith("", end)) { + return value.slice(0, end); + } + const open = lower.lastIndexOf("", end - "".length); if (open === -1) { - return result; + return value.slice(0, end); } - result = result.slice(0, open).trimEnd(); + end = open; } } +function isWhitespaceCharCode(code: number): boolean { + return code === 0x20 || (code >= 0x09 && code <= 0x0d) || code === 0xa0; +} + /** 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