From e1b562bf333e4fb14ce7a958a0cfef44e035e2dc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 1 Aug 2026 23:57:56 -0700 Subject: [PATCH 01/26] feat: native subagent & workflow observability (Agents panel, quiet timeline) Surface Claude Code subagents/workflows and Codex collab agents in the UI using only native provider emissions. Zero migrations, zero new tables: widened task.* activity payloads ride the existing event-sourced activity path, and a client-side fold in client-runtime derives v2-shaped subagent state (field names match #4779 so the orchestration-v2 merge is mechanical). Server: - contracts: TaskAgentLinkage on all task payloads, new task.updated event, typed RuntimeTaskUsage, tool attribution (agentId/parentToolUseId) - ClaudeAdapter: carry subagent_type/workflow_name/tool_use_id/outputFile, handle task_updated (was dropped), attribute subagent tool events via parent_tool_use_id, defensive workflow_progress parse, Workflow run handles - CodexSessionRuntime/Adapter: register multi-agent-v2 children from thread/started + subAgentActivity, intercept child notifications, and synthesize task.* lifecycle (idle=resumable, cumulative usage) [WIP: routing is probe-gated per spec] - ingestion: task.updated + agent-owned tool.progress persisted; wire-slim regression test proving agent fields survive to the client Web: - Agents right-panel surface (workflow phase groups, direct spawns, static status dots, DOM-write elapsed timers, expandable activity ring) - composer live strip + inline workflow run card (8-row urgency cap) - quiet timeline: one lifecycle row per agent (collapse by taskId), agent- attributed tool rows re-homed to the panel, timelineBypass rows suppressed Mobile: same quiet-timeline fold; task.completed kept as terminal signal. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/lib/threadActivity.ts | 46 + .../ActivityPayloadProjection.test.ts | 62 ++ .../Layers/ProviderRuntimeIngestion.ts | 110 +++ .../src/provider/Layers/ClaudeAdapter.ts | 444 +++++++++- .../src/provider/Layers/CodexAdapter.ts | 231 +++++ .../provider/Layers/CodexSessionRuntime.ts | 224 +++++ apps/web/src/components/AgentsPanel.tsx | 315 +++++++ apps/web/src/components/ChatView.tsx | 53 ++ apps/web/src/components/RightPanelTabs.tsx | 21 +- .../src/components/chat/AgentsLiveStrip.tsx | 47 ++ .../src/components/chat/MessagesTimeline.tsx | 8 +- .../src/components/chat/WorkflowRunCard.tsx | 137 +++ apps/web/src/rightPanelStore.ts | 17 +- apps/web/src/session-logic.test.ts | 90 ++ apps/web/src/session-logic.ts | 62 ++ packages/client-runtime/package.json | 4 + .../src/state/subagentRuntime.test.ts | 441 ++++++++++ .../src/state/subagentRuntime.ts | 793 ++++++++++++++++++ packages/contracts/src/providerRuntime.ts | 120 +++ 19 files changed, 3208 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/orchestration/ActivityPayloadProjection.test.ts create mode 100644 apps/web/src/components/AgentsPanel.tsx create mode 100644 apps/web/src/components/chat/AgentsLiveStrip.tsx create mode 100644 apps/web/src/components/chat/WorkflowRunCard.tsx create mode 100644 packages/client-runtime/src/state/subagentRuntime.test.ts create mode 100644 packages/client-runtime/src/state/subagentRuntime.ts diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index dd568e6f045..85e4260b17e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -80,6 +80,8 @@ interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + /** Grouping key for subagent lifecycle rows (one row per agent). */ + taskId?: string; } type RawThreadFeedEntry = @@ -235,6 +237,26 @@ function resolvePendingUserInputAnswer( return normalizeDraftAnswer(draft?.selectedOptionLabel); } +/** + * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal + * activity lives in the Agents sheet, not the work log. task.completed rows + * are kept — with no workflow card on mobile they are the terminal signal + * (a surface that hides rows must keep its own terminal signal). + */ +function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + if (!payload) { + return false; + } + if (payload.timelineBypass === true && activity.kind !== "task.completed") { + return true; + } + return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; +} + function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -243,9 +265,12 @@ function deriveWorkLogEntries( for (const activity of ordered) { if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; + if (activity.kind === "task.updated") continue; + if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; + if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } return collapseDerivedWorkLogEntries(entries); @@ -284,10 +309,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? payload.detail : null; const taskLabel = taskSummary || taskDetailAsLabel; + const taskId = + isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0 + ? payload.taskId + : undefined; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, turnId: activity.turnId, + ...(taskId ? { taskId } : {}), label: taskLabel || activity.summary, tone: activity.kind === "task.progress" @@ -352,7 +382,23 @@ function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; + // Subagent rows collapse by identity, not adjacency (quiet-timeline + // guarantee; mirrors web's session-logic). + const taskRowIndex = new Map(); for (const entry of entries) { + const isTaskRow = + entry.taskId !== undefined && + (entry.activityKind === "task.progress" || entry.activityKind === "task.completed"); + if (isTaskRow && entry.taskId !== undefined) { + const existingIndex = taskRowIndex.get(entry.taskId); + if (existingIndex !== undefined) { + collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + continue; + } + taskRowIndex.set(entry.taskId, collapsed.length); + collapsed.push(entry); + continue; + } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts new file mode 100644 index 00000000000..cf498124ea4 --- /dev/null +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { projectActivityPayload } from "./ActivityPayloadProjection.ts"; + +function activity(payload: Record): OrchestrationThreadActivity { + return { + id: "activity-1", + tone: "tool", + kind: "tool.completed", + summary: "Tool", + payload, + turnId: null, + createdAt: "2026-08-01T10:00:00.000Z", + } as unknown as OrchestrationThreadActivity; +} + +/** + * Wire-survival regression: the slimming pass rewrites payload.data but must + * never strip the top-level per-agent fields the subagent fold depends on. + * If slimming ever moves to an allowlist over the whole payload, these + * assertions are the tripwire. + */ +describe("projectActivityPayload agent-field survival", () => { + it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { + const projected = projectActivityPayload( + activity({ + itemType: "command_execution", + agentId: "task-123", + parentToolUseId: "toolu_abc", + data: { + toolName: "Bash", + input: { command: "ls" }, + command: "ls", + rawOutput: { content: "x".repeat(10) }, + somethingClientNeverReads: { big: "blob" }, + }, + }), + ); + const payload = projected.payload as Record; + expect(payload.agentId).toBe("task-123"); + expect(payload.parentToolUseId).toBe("toolu_abc"); + // Slimming itself still applies to data. + const data = payload.data as Record; + expect(data.somethingClientNeverReads).toBeUndefined(); + }); + + it("passes task lifecycle payloads (no data field) through untouched", () => { + const source = activity({ + taskId: "task-9", + title: "Audit auth", + role: "explorer", + model: "opus", + workflowName: "audit-flow", + phases: [{ index: 0, title: "Audit" }], + typedUsage: { totalTokens: 1200 }, + runHandles: { runId: "run-1", scriptPath: "/tmp/wf.js" }, + timelineBypass: true, + }); + const projected = projectActivityPayload(source); + expect(projected.payload).toEqual(source.payload); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index c8d619270d3..a58e0312c56 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -307,6 +307,40 @@ function requestKindFromCanonicalRequestType( } } +/** + * Copies the optional TaskAgentLinkage bundle from a task.* runtime payload + * into the persisted activity payload. Identity fields ride on every row so + * client folds survive activity retention; absent fields stay absent. + */ +function taskLinkageActivityFields(payload: Record): Record { + const fields: Record = {}; + for (const key of [ + "title", + "role", + "model", + "toolUseId", + "parentAgentId", + "workflowName", + "agentIndex", + "phaseIndex", + "phaseTitle", + "phases", + "attempt", + "runHandles", + "outputFile", + "agentPath", + "timelineBypass", + "typedUsage", + "status", + "error", + ] as const) { + if (payload[key] !== undefined) { + fields[key] = payload[key]; + } + } + return fields; +} + export function runtimeEventToActivities( event: ProviderRuntimeEvent, taskTitle?: string, @@ -505,6 +539,7 @@ export function runtimeEventToActivities( ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), + ...taskLinkageActivityFields(event.payload as Record), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -532,6 +567,68 @@ export function runtimeEventToActivities( ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...taskLinkageActivityFields(event.payload as Record), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "task.updated": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: event.payload.status === "failed" ? "error" : "info", + kind: "task.updated", + summary: + event.payload.status === "failed" + ? "Task failed" + : event.payload.status + ? `Task ${event.payload.status}` + : "Task updated", + payload: { + taskId: event.payload.taskId, + ...(event.payload.description + ? { detail: truncateDetail(event.payload.description) } + : {}), + ...(event.payload.endedAt ? { endedAt: event.payload.endedAt } : {}), + ...(event.payload.isBackgrounded !== undefined + ? { isBackgrounded: event.payload.isBackgrounded } + : {}), + ...taskLinkageActivityFields(event.payload as Record), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "tool.progress": { + // Only agent-owned heartbeats are persisted: they feed the owning + // agent's activity line. Parent-conversation tool progress stays + // ephemeral (item lifecycle already covers it). + if (event.payload.taskId === undefined) { + return []; + } + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "tool.progress", + summary: event.payload.toolName ?? "Tool progress", + payload: { + taskId: event.payload.taskId, + ...(event.payload.toolName ? { toolName: event.payload.toolName } : {}), + ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), + ...(event.payload.elapsedSeconds !== undefined + ? { elapsedSeconds: event.payload.elapsedSeconds } + : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -565,6 +662,7 @@ export function runtimeEventToActivities( } : {}), ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...taskLinkageActivityFields(event.payload as Record), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -630,6 +728,10 @@ export function runtimeEventToActivities( ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -652,6 +754,10 @@ export function runtimeEventToActivities( itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -673,6 +779,10 @@ export function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), + ...(event.payload.parentToolUseId + ? { parentToolUseId: event.payload.parentToolUseId } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f87d5be7446..5a337cd1fe2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -42,6 +42,10 @@ import { RuntimeItemId, RuntimeRequestId, RuntimeTaskId, + type RuntimeTaskStatus, + type RuntimeTaskUsage, + type TaskAgentLinkage, + type TaskRunHandles, ThreadId, TurnId, type UserInputQuestion, @@ -169,6 +173,9 @@ interface ToolInFlight { readonly input: Record; readonly partialInputJson: string; readonly lastEmittedInputFingerprint?: string; + /** Owning agent when this tool ran inside a subagent (see attribution note). */ + readonly agentId?: string; + readonly parentToolUseId?: string; } interface ClaudeTaskState { @@ -178,6 +185,22 @@ interface ClaudeTaskState { readonly blockedBy: Set; } +/** + * Agent identity captured from task_started and repeated on every subsequent + * task.* payload, so client folds can reconstruct an agent even when its + * start row aged out of activity retention. + */ +interface ClaudeTaskAgentState { + readonly taskId: string; + toolUseId: string | undefined; + description: string | undefined; + subagentType: string | undefined; + taskType: string | undefined; + workflowName: string | undefined; + skipTranscript: boolean; + runHandles: TaskRunHandles | undefined; +} + interface ClaudeSessionContext { session: ProviderSession; readonly promptQueue: Queue.Queue; @@ -195,6 +218,7 @@ interface ClaudeSessionContext { }>; readonly inFlightTools: Map; readonly claudeTasks: Map; + readonly taskAgents: Map; turnState: ClaudeTurnState | undefined; lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; @@ -826,6 +850,221 @@ function planStepsFromClaudeTasks(tasks: Map): PlanStep }); } +/** Only http/https survive; anything else (javascript:, file:, …) is dropped. */ +function sanitizeSessionUrl(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!/^https?:\/\//i.test(trimmed)) { + return undefined; + } + return trimmed; +} + +function nonNegativeInt(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + +function trimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * SDK task usage ({total_tokens, tool_uses, duration_ms}, sometimes with + * input/output/cache breakdowns) → the typed contract shape. Unknown or + * malformed input yields undefined rather than a partial guess. + */ +function normalizeTaskUsage(usage: unknown): RuntimeTaskUsage | undefined { + if (typeof usage !== "object" || usage === null) { + return undefined; + } + const record = usage as Record; + const totalTokens = nonNegativeInt(record.total_tokens); + if (totalTokens === undefined) { + return undefined; + } + const inputTokens = nonNegativeInt(record.input_tokens); + const cachedInputTokens = nonNegativeInt(record.cache_read_input_tokens); + const outputTokens = nonNegativeInt(record.output_tokens); + const toolUses = nonNegativeInt(record.tool_uses); + const durationMs = nonNegativeInt(record.duration_ms); + return { + totalTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }; +} + +/** SDK task_updated patch status → the shared wire vocabulary. */ +const CLAUDE_TASK_PATCH_STATUS: Record = { + pending: "pending", + running: "running", + completed: "completed", + failed: "failed", + killed: "cancelled", + paused: "idle", +}; + +/** + * Resolves a stream message's parent_tool_use_id to the owning agent's + * taskId. The Task tool's tool_use_id is remembered on task_started; any + * subagent-forwarded block carries that id as its parent. Returns undefined + * for parent-conversation traffic. + */ +function agentIdForParentToolUse( + agents: Map, + parentToolUseId: string | null | undefined, +): string | undefined { + if (parentToolUseId === null || parentToolUseId === undefined) { + return undefined; + } + for (const agent of agents.values()) { + if (agent.toolUseId === parentToolUseId) { + return agent.taskId; + } + } + return undefined; +} + +/** + * Linkage bundle repeated on every task.* payload for `taskId`. Reads the + * remembered identity (from task_started) so progress/terminal rows are + * self-describing even when the start row ages out of activity retention. + */ +function taskLinkageFor( + agents: Map, + taskId: string, +): TaskAgentLinkage { + const agent = agents.get(taskId); + if (!agent) { + return {}; + } + return { + ...(agent.description ? { title: agent.description } : {}), + ...(agent.subagentType ? { role: agent.subagentType } : {}), + ...(agent.toolUseId ? { toolUseId: agent.toolUseId } : {}), + ...(agent.workflowName ? { workflowName: agent.workflowName } : {}), + ...(agent.runHandles ? { runHandles: agent.runHandles } : {}), + }; +} + +const WORKFLOW_PHASE_CAP = 64; +const WORKFLOW_AGENT_CAP = 100; + +interface ClaudeWorkflowAgentEntry { + readonly index: number; + readonly state: string; + readonly label: string | undefined; + readonly phaseIndex: number | undefined; + readonly phaseTitle: string | undefined; + readonly model: string | undefined; + readonly attempt: number | undefined; + readonly lastToolName: string | undefined; + readonly startedAt: string | undefined; + readonly error: string | undefined; + readonly tokens: number | undefined; + readonly toolCalls: number | undefined; +} + +interface ClaudeWorkflowProgress { + readonly phases: ReadonlyArray<{ index: number; title: string }>; + readonly agents: ReadonlyArray; +} + +/** + * Defensive parse of the SDK's undeclared-but-real workflow_progress array on + * task_progress messages (wire-confirmed; absent from sdk.d.ts). Unknown + * shapes are skipped per-entry; phases and agents dedupe by index before + * caps; a vanished field never throws. If the array disappears upstream the + * caller keeps the coordinator row and plain task lifecycle. + */ +function parseWorkflowProgress(value: unknown): ClaudeWorkflowProgress | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + const phasesByIndex = new Map(); + const agentsByIndex = new Map(); + for (const entry of value) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + const entryType = trimmedString(record.type); + if (entryType === "workflow_phase") { + const index = nonNegativeInt(record.index); + const title = trimmedString(record.title); + if (index !== undefined && title && !phasesByIndex.has(index)) { + phasesByIndex.set(index, title); + } + continue; + } + if (entryType !== "workflow_agent") { + continue; + } + const index = nonNegativeInt(record.index); + const state = trimmedString(record.state); + if (index === undefined || !state || agentsByIndex.has(index)) { + continue; + } + agentsByIndex.set(index, { + index, + state, + label: trimmedString(record.label), + phaseIndex: nonNegativeInt(record.phaseIndex), + phaseTitle: trimmedString(record.phaseTitle), + model: trimmedString(record.model), + attempt: nonNegativeInt(record.attempt), + lastToolName: trimmedString(record.lastToolName), + startedAt: trimmedString(record.startedAt), + error: trimmedString(record.error), + tokens: nonNegativeInt(record.tokens), + toolCalls: nonNegativeInt(record.toolCalls), + }); + } + if (phasesByIndex.size === 0 && agentsByIndex.size === 0) { + return undefined; + } + const phases = Array.from(phasesByIndex.entries()) + .map(([index, title]) => ({ index, title })) + .toSorted((a, b) => a.index - b.index) + .slice(0, WORKFLOW_PHASE_CAP); + const agents = Array.from(agentsByIndex.values()) + .toSorted((a, b) => a.index - b.index) + .slice(0, WORKFLOW_AGENT_CAP); + return { phases, agents }; +} + +/** + * Workflow member states from workflow_progress → shared task status. + * Unknown states read running after startedAt, pending before it. + */ +function workflowAgentStatus(entry: ClaudeWorkflowAgentEntry): RuntimeTaskStatus { + switch (entry.state) { + case "queued": + case "pending": + return "pending"; + case "start": + case "running": + return entry.startedAt === undefined ? "pending" : "running"; + case "done": + return "completed"; + case "error": + return "failed"; + default: + return entry.startedAt === undefined ? "pending" : "running"; + } +} + function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; @@ -833,17 +1072,17 @@ function summarizeToolRequest(toolName: string, input: Record): return `${toolName}: ${command.trim().slice(0, 400)}`; } - // For agent/subagent tools, prefer human-readable description or prompt over raw JSON + // For agent/subagent tools, prefer the human-readable description or prompt + // over raw JSON. The structured subagent_type is carried separately on the + // task.* payloads (role) — the label is display-only. const itemType = classifyToolItemType(toolName); if (itemType === "collab_agent_tool_call") { const description = typeof input.description === "string" ? input.description.trim() : undefined; const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; - const subagentType = - typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; const label = description || (prompt ? prompt.slice(0, 200) : undefined); if (label) { - return subagentType ? `${subagentType}: ${label}` : label; + return label; } } @@ -2205,6 +2444,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "inProgress", title: nextTool.title, ...(nextTool.detail ? { detail: nextTool.detail } : {}), + ...(nextTool.agentId ? { agentId: nextTool.agentId } : {}), + ...(nextTool.parentToolUseId ? { parentToolUseId: nextTool.parentToolUseId } : {}), data: { toolName: nextTool.toolName, input: nextTool.input, @@ -2274,6 +2515,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; + // Attribute tools that ran inside a subagent to their owning agent so + // clients can re-home them out of the main timeline (quiet-timeline + // guarantee): the SDK forwards subagent tool_use blocks tagged with the + // spawning Task tool's id as parent_tool_use_id. + const parentToolUseId = + (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? undefined; + const owningAgentId = agentIdForParentToolUse(context.taskAgents, parentToolUseId); + const tool: ToolInFlight = { itemId, itemType, @@ -2283,6 +2532,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: toolInput, partialInputJson: "", ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), + ...(owningAgentId ? { agentId: owningAgentId } : {}), + ...(parentToolUseId ? { parentToolUseId } : {}), }; context.inFlightTools.set(index, tool); @@ -2300,6 +2551,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: { toolName: tool.toolName, input: toolInput, @@ -2378,6 +2631,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: toolResult.isError ? "failed" : "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2430,6 +2685,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: itemStatus, title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), + ...(tool.agentId ? { agentId: tool.agentId } : {}), + ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}), data: toolData, }, providerRefs: nativeProviderRefs(context, { @@ -2442,6 +2699,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); + // The Workflow tool's result carries the run handles (runId, scriptPath, + // transcriptDir, sessionUrl). Attach them to the workflow's task agent so + // the next task.* payload advertises them to clients. + if (!toolResult.isError && tool.toolName.toLowerCase() === "workflow" && toolUseResult) { + const workflowTaskId = trimmedString(toolUseResult.taskId); + if (workflowTaskId) { + const runHandles: TaskRunHandles = { + ...(trimmedString(toolUseResult.runId) + ? { runId: trimmedString(toolUseResult.runId) } + : {}), + ...(trimmedString(toolUseResult.scriptPath) + ? { scriptPath: trimmedString(toolUseResult.scriptPath) } + : {}), + ...(trimmedString(toolUseResult.transcriptDir) + ? { transcriptDir: trimmedString(toolUseResult.transcriptDir) } + : {}), + ...(sanitizeSessionUrl(toolUseResult.sessionUrl) + ? { sessionUrl: sanitizeSessionUrl(toolUseResult.sessionUrl) } + : {}), + }; + const existing = context.taskAgents.get(workflowTaskId); + context.taskAgents.set(workflowTaskId, { + taskId: workflowTaskId, + toolUseId: existing?.toolUseId ?? tool.itemId, + description: existing?.description, + subagentType: existing?.subagentType, + taskType: existing?.taskType ?? "local_workflow", + workflowName: existing?.workflowName, + skipTranscript: existing?.skipTranscript ?? false, + runHandles, + }); + } + } + if ( !toolResult.isError && applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult) @@ -2563,6 +2854,79 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* completeTurn(context, status, errorMessage, message); }); + /** + * Synthesizes per-member task.progress rows from the coordinator's + * workflow_progress array. Member identity is the stable slot + * `:wf:` (never the per-attempt agent id, which + * changes on retry and would split one member into duplicate rows). + * timelineBypass keeps these out of the parent chat; the Agents surface and + * workflow card consume them. + */ + const emitWorkflowMemberProgress = Effect.fn("emitWorkflowMemberProgress")(function* ( + context: ClaudeSessionContext, + base: Omit, + message: Extract, + ) { + const progress = parseWorkflowProgress( + (message as unknown as Record).workflow_progress, + ); + if (!progress) { + return; + } + const coordinatorId = message.task_id; + const coordinatorLinkage = taskLinkageFor(context.taskAgents, coordinatorId); + if (progress.phases.length > 0) { + // Phases ride on a coordinator-addressed progress row. + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.make(coordinatorId), + description: message.description, + phases: progress.phases, + ...coordinatorLinkage, + }, + }); + } + for (const entry of progress.agents) { + const memberTaskId = `${coordinatorId}:wf:${entry.index}`; + const status = workflowAgentStatus(entry); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.progress", + payload: { + taskId: RuntimeTaskId.make(memberTaskId), + description: entry.label ?? `agent ${entry.index}`, + status, + ...(entry.error ? { error: entry.error } : {}), + ...(entry.label ? { title: entry.label } : {}), + ...(entry.model ? { model: entry.model } : {}), + ...(entry.lastToolName ? { lastToolName: entry.lastToolName } : {}), + ...(entry.tokens !== undefined + ? { + typedUsage: { + totalTokens: entry.tokens, + ...(entry.toolCalls !== undefined ? { toolUses: entry.toolCalls } : {}), + }, + } + : {}), + parentAgentId: coordinatorId, + agentIndex: entry.index, + ...(entry.phaseIndex !== undefined ? { phaseIndex: entry.phaseIndex } : {}), + ...(entry.phaseTitle ? { phaseTitle: entry.phaseTitle } : {}), + ...(entry.attempt !== undefined ? { attempt: entry.attempt } : {}), + timelineBypass: true, + }, + }); + } + }); + const handleSystemMessage = Effect.fn("handleSystemMessage")(function* ( context: ClaudeSessionContext, message: SDKMessage, @@ -2678,7 +3042,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; - case "task_started": + case "task_started": { + // Remember the agent identity so every later task.* payload for this + // taskId is self-describing (identity must survive activity retention). + context.taskAgents.set(message.task_id, { + taskId: message.task_id, + toolUseId: message.tool_use_id, + description: message.description, + subagentType: message.subagent_type, + taskType: message.task_type, + workflowName: message.workflow_name, + skipTranscript: message.skip_transcript === true, + runHandles: context.taskAgents.get(message.task_id)?.runHandles, + }); yield* offerRuntimeEvent({ ...base, type: "task.started", @@ -2686,10 +3062,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), + ...(message.description ? { title: message.description } : {}), + ...(message.subagent_type ? { role: message.subagent_type } : {}), + ...(message.tool_use_id ? { toolUseId: message.tool_use_id } : {}), + ...(message.workflow_name ? { workflowName: message.workflow_name } : {}), }, }); return; - case "task_progress": + } + case "task_progress": { yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2698,6 +3079,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: message, }, ); + const linkage = taskLinkageFor(context.taskAgents, message.task_id); + const typedUsage = normalizeTaskUsage(message.usage); yield* offerRuntimeEvent({ ...base, type: "task.progress", @@ -2706,16 +3089,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( description: message.description, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), + ...(typedUsage ? { typedUsage } : {}), ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + ...linkage, + ...(message.subagent_type ? { role: message.subagent_type } : {}), }, }); + yield* emitWorkflowMemberProgress(context, base, message); return; - // Task state patch (status/backgrounded/end_time). No runtime mapping - // yet — the terminal task_notification reports the outcome — but it - // must not surface as an unknown-subtype warning row. - case "task_updated": + } + case "task_updated": { + // Status patch (killed/paused/backgrounded/end_time/error) — main + // previously dropped this on the floor, losing all transitions. + const patch = message.patch; + const status = + patch.status !== undefined ? CLAUDE_TASK_PATCH_STATUS[patch.status] : undefined; + const endedAt = + typeof patch.end_time === "number" && Number.isFinite(patch.end_time) + ? new Date(patch.end_time).toISOString() + : undefined; + yield* offerRuntimeEvent({ + ...base, + type: "task.updated", + payload: { + taskId: RuntimeTaskId.make(message.task_id), + ...(status ? { status } : {}), + ...(patch.description ? { description: patch.description } : {}), + ...(patch.error ? { error: patch.error } : {}), + ...(endedAt ? { endedAt } : {}), + ...(patch.is_backgrounded !== undefined + ? { isBackgrounded: patch.is_backgrounded } + : {}), + ...taskLinkageFor(context.taskAgents, message.task_id), + }, + }); return; - case "task_notification": + } + case "task_notification": { yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2724,6 +3134,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( rawPayload: message, }, ); + const typedUsage = normalizeTaskUsage(message.usage); yield* offerRuntimeEvent({ ...base, type: "task.completed", @@ -2732,9 +3143,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( status: message.status, ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), + ...(typedUsage ? { typedUsage } : {}), + ...(message.output_file ? { outputFile: message.output_file } : {}), + ...taskLinkageFor(context.taskAgents, message.task_id), }, }); return; + } case "files_persisted": yield* offerRuntimeEvent({ ...base, @@ -2870,7 +3285,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( toolUseId: message.tool_use_id, toolName: message.tool_name, elapsedSeconds: message.elapsed_time_seconds, - ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + ...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}), + ...(message.parent_tool_use_id !== null + ? { parentToolUseId: message.parent_tool_use_id } + : {}), }, }); return; @@ -3192,6 +3610,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const pendingUserInputs = new Map(); const inFlightTools = new Map(); const claudeTasks = new Map(); + const taskAgents = new Map(); const contextRef = yield* Ref.make(undefined); @@ -3634,6 +4053,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( turns: [], inFlightTools, claudeTasks, + taskAgents, turnState: undefined, lastKnownContextWindow: initialContextWindow, lastKnownTokenUsage: undefined, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 4146121b147..eba46921c18 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -20,6 +20,8 @@ import { type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, + type RuntimeTaskUsage, ProviderApprovalDecision, ThreadId, ProviderSendTurnInput, @@ -494,10 +496,239 @@ function mapItemLifecycle( }; } +/** + * Maps the session runtime's synthetic `collabAgent/*` events (native + * multi-agent v2 child-thread signals) into the shared task.* lifecycle. + * Agent identity = child thread id; nickname is the display title, role is + * agentRole (fallback: last agentPath segment, then "general-purpose"). + * A completed child turn is idle (resumable), not terminal. timelineBypass + * keeps these rows out of the parent chat. + */ +function mapCollabAgentEvent( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ReadonlyArray { + const payload = + typeof event.payload === "object" && event.payload !== null + ? (event.payload as Record) + : undefined; + const agentThreadId = typeof payload?.agentThreadId === "string" ? payload.agentThreadId : ""; + if (!payload || agentThreadId.length === 0) { + return []; + } + const base = runtimeEventBase(event, canonicalThreadId); + const taskId = RuntimeTaskId.make(agentThreadId); + const agentPath = typeof payload.agentPath === "string" ? payload.agentPath : undefined; + const pathLeaf = agentPath?.split("/").findLast((segment) => segment.length > 0); + const nickname = typeof payload.nickname === "string" ? payload.nickname : undefined; + const role = + (typeof payload.role === "string" ? payload.role : undefined) ?? pathLeaf ?? "general-purpose"; + const title = nickname ?? pathLeaf ?? agentThreadId; + + switch (event.method) { + case "collabAgent/started": + return [ + { + ...base, + type: "task.started", + payload: { + taskId, + description: title, + title, + role, + ...(agentPath ? { agentPath } : {}), + ...(typeof payload.parentThreadId === "string" + ? { parentAgentId: payload.parentThreadId } + : {}), + timelineBypass: true, + }, + }, + ]; + case "collabAgent/activity": { + const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; + if (activityKind === "interrupted") { + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "interrupted", timelineBypass: true }, + }, + ]; + } + // started/interacted → the child is (again) actively driven. + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "running", timelineBypass: true }, + }, + ]; + } + case "collabAgent/turnStarted": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "running", timelineBypass: true }, + }, + ]; + case "collabAgent/turnCompleted": { + // Idle, not terminal: the identity is resumable via sendInput/resume. + const turn = + typeof payload.turn === "object" && payload.turn !== null + ? (payload.turn as Record) + : undefined; + const turnStatus = typeof turn?.status === "string" ? turn.status : undefined; + const status = + turnStatus === "failed" + ? ("failed" as const) + : turnStatus === "interrupted" + ? ("interrupted" as const) + : ("idle" as const); + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status, timelineBypass: true }, + }, + ]; + } + case "collabAgent/statusChanged": { + const status = + typeof payload.status === "object" && payload.status !== null + ? (payload.status as Record) + : undefined; + const statusType = typeof status?.type === "string" ? status.type : undefined; + if (statusType === "systemError") { + // Silently dropping this once left children stuck running forever. + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "failed", timelineBypass: true }, + }, + ]; + } + if (statusType === "active") { + const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : []; + const waiting = flags.some( + (flag) => flag === "waitingOnApproval" || flag === "waitingOnUserInput", + ); + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: waiting ? "waiting" : "running", timelineBypass: true }, + }, + ]; + } + if (statusType === "idle") { + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "idle", timelineBypass: true }, + }, + ]; + } + return []; + } + case "collabAgent/tokenUsage": { + // Cumulative per child thread: always the `total` breakdown, never + // `last` (which shrinks on follow-ups). Client folds max-merge. + const tokenUsage = + typeof payload.tokenUsage === "object" && payload.tokenUsage !== null + ? (payload.tokenUsage as Record) + : undefined; + const total = + typeof tokenUsage?.total === "object" && tokenUsage.total !== null + ? (tokenUsage.total as Record) + : undefined; + const totalTokens = typeof total?.totalTokens === "number" ? total.totalTokens : undefined; + if (totalTokens === undefined) { + return []; + } + const count = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + const typedUsage: RuntimeTaskUsage = { + totalTokens, + ...(count(total?.inputTokens) !== undefined + ? { inputTokens: count(total?.inputTokens) } + : {}), + ...(count(total?.cachedInputTokens) !== undefined + ? { cachedInputTokens: count(total?.cachedInputTokens) } + : {}), + ...(count(total?.outputTokens) !== undefined + ? { outputTokens: count(total?.outputTokens) } + : {}), + ...(count(total?.reasoningOutputTokens) !== undefined + ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) } + : {}), + }; + return [ + { + ...base, + type: "task.progress", + payload: { + taskId, + description: title, + typedUsage, + timelineBypass: true, + }, + }, + ]; + } + case "collabAgent/item": { + const item = + typeof payload.item === "object" && payload.item !== null + ? (payload.item as Record) + : undefined; + const itemTypeRaw = typeof item?.type === "string" ? item.type : undefined; + if (!itemTypeRaw) { + return []; + } + // A loose summary from the raw item: the child stream is untyped at + // this boundary (synthetic event payload), so read best-effort fields + // rather than force a schema decode. + const looseSummary = + (typeof item?.command === "string" ? item.command : undefined) ?? + (typeof item?.title === "string" ? item.title : undefined) ?? + (typeof item?.query === "string" ? item.query : undefined); + const canonical = toCanonicalItemType(itemTypeRaw); + const summary = looseSummary ?? canonical.replaceAll("_", " "); + return [ + { + ...base, + type: "task.progress", + payload: { + taskId, + description: title, + summary, + timelineBypass: true, + }, + }, + ]; + } + case "collabAgent/closed": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, status: "interrupted", timelineBypass: true }, + }, + ]; + default: + return []; + } +} + function mapToRuntimeEvents( event: ProviderEvent, canonicalThreadId: ThreadId, ): ReadonlyArray { + if (event.kind === "notification" && event.method.startsWith("collabAgent/")) { + return mapCollabAgentEvent(event, canonicalThreadId); + } if (event.kind === "error") { if (!event.message) { return []; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 67108dd4dbb..b628ced3c6c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -600,6 +600,64 @@ function readRouteFields(notification: CodexServerNotification): { } } +/** + * Native collab child-agent tracking (multi-agent v2). Under v2 subagents are + * full app-server threads: identity arrives on `thread/started` with + * source.subAgent.thread_spawn, lifecycle on `subAgentActivity` items and the + * child thread's own turn/status/tokenUsage notifications. The runtime + * registers children from those explicit signals, intercepts their + * notifications before parent-timeline mapping, and re-emits them as + * synthetic `collabAgent/*` provider events the adapter turns into task.* + * runtime events (timelineBypass keeps them out of the parent chat). + * + * WIP, probe-gated: registration is deliberately explicit-signals-only. The + * spec's "provisionally treat unknown foreign thread ids as v2 children" rule + * needs a live wire capture of the packaged binary before it lands — blind + * capture risks eating unrelated traffic. Until then a child whose first + * notification precedes registration passes through as today (no regression + * vs main, which passes everything through). + */ +interface CollabChildAgentState { + readonly agentThreadId: string; + readonly nickname: string | undefined; + readonly role: string | undefined; + readonly agentPath: string | undefined; + readonly depth: number | undefined; + readonly parentThreadId: string | undefined; +} + +function readThreadSpawnSource(thread: { readonly source: unknown }): + | { + nickname: string | undefined; + role: string | undefined; + agentPath: string | undefined; + depth: number | undefined; + parentThreadId: string | undefined; + } + | undefined { + const source = thread.source; + if (typeof source !== "object" || source === null || !("subAgent" in source)) { + return undefined; + } + const subAgent = (source as { subAgent: unknown }).subAgent; + if (typeof subAgent !== "object" || subAgent === null || !("thread_spawn" in subAgent)) { + return undefined; + } + const spawn = (subAgent as { thread_spawn: unknown }).thread_spawn; + if (typeof spawn !== "object" || spawn === null) { + return undefined; + } + const record = spawn as Record; + return { + nickname: typeof record.agent_nickname === "string" ? record.agent_nickname : undefined, + role: typeof record.agent_role === "string" ? record.agent_role : undefined, + agentPath: typeof record.agent_path === "string" ? record.agent_path : undefined, + depth: typeof record.depth === "number" ? record.depth : undefined, + parentThreadId: + typeof record.parent_thread_id === "string" ? record.parent_thread_id : undefined, + }; +} + function rememberCollabReceiverTurns( collabReceiverTurns: Map, notification: CodexServerNotification, @@ -723,6 +781,7 @@ export const makeCodexSessionRuntime = ( const approvalCorrelationsRef = yield* Ref.make(new Map()); const pendingUserInputsRef = yield* Ref.make(new Map()); const collabReceiverTurnsRef = yield* Ref.make(new Map()); + const collabChildAgentsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); // `~` is not shell-expanded when env vars are set via @@ -839,6 +898,166 @@ export const makeCodexSessionRuntime = ( ), ); + /** + * Registers v2 collab children and re-emits their notifications as + * synthetic `collabAgent/*` events for the adapter's task.* synthesis. + * Returns true when the notification was fully handled (must not reach + * parent-timeline mapping). + */ + const interceptCollabChildNotification = (notification: CodexServerNotification) => + Effect.gen(function* () { + // Registration path 1: child thread announces itself with a + // subAgent thread_spawn source. + if (notification.method === "thread/started") { + const thread = notification.params.thread; + const spawn = readThreadSpawnSource(thread); + if (!spawn) { + return false; + } + const state: CollabChildAgentState = { + agentThreadId: thread.id, + nickname: spawn.nickname ?? thread.agentNickname ?? undefined, + role: spawn.role ?? thread.agentRole ?? undefined, + agentPath: spawn.agentPath, + depth: spawn.depth, + parentThreadId: spawn.parentThreadId ?? thread.parentThreadId ?? undefined, + }; + yield* Ref.update(collabChildAgentsRef, (current) => { + const next = new Map(current); + next.set(thread.id, state); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/started", + payload: { + agentThreadId: state.agentThreadId, + ...(state.nickname ? { nickname: state.nickname } : {}), + ...(state.role ? { role: state.role } : {}), + ...(state.agentPath ? { agentPath: state.agentPath } : {}), + ...(state.depth !== undefined ? { depth: state.depth } : {}), + ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), + }, + }); + return true; + } + + // Registration path 2: parent-side subAgentActivity item names the + // child thread (may arrive before or after thread/started). + if ( + (notification.method === "item/started" || notification.method === "item/completed") && + notification.params.item.type === "subAgentActivity" + ) { + const item = notification.params.item; + yield* Ref.update(collabChildAgentsRef, (current) => { + if (current.has(item.agentThreadId)) { + return current; + } + const next = new Map(current); + next.set(item.agentThreadId, { + agentThreadId: item.agentThreadId, + nickname: undefined, + role: undefined, + agentPath: item.agentPath, + depth: undefined, + parentThreadId: undefined, + }); + return next; + }); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/activity", + payload: { + agentThreadId: item.agentThreadId, + agentPath: item.agentPath, + activityKind: item.kind, + }, + }); + return true; + } + + // Interception: notifications addressed to a registered child thread + // become agent-scoped synthetic events instead of parent chatter. + const providerConversationId = readNotificationThreadId(notification); + if (!providerConversationId) { + return false; + } + const children = yield* Ref.get(collabChildAgentsRef); + const child = children.get(providerConversationId); + if (!child) { + return false; + } + switch (notification.method) { + case "turn/started": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/turnStarted", + payload: { agentThreadId: child.agentThreadId }, + }); + return true; + case "turn/completed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/turnCompleted", + payload: { + agentThreadId: child.agentThreadId, + turn: notification.params.turn, + }, + }); + return true; + case "thread/status/changed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/statusChanged", + payload: { + agentThreadId: child.agentThreadId, + status: notification.params.status, + }, + }); + return true; + case "thread/tokenUsage/updated": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/tokenUsage", + payload: { + agentThreadId: child.agentThreadId, + tokenUsage: notification.params.tokenUsage, + }, + }); + return true; + case "item/started": + case "item/completed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/item", + payload: { + agentThreadId: child.agentThreadId, + item: notification.params.item, + }, + }); + return true; + case "thread/closed": + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "collabAgent/closed", + payload: { agentThreadId: child.agentThreadId }, + }); + return true; + default: + // Remaining child chatter (name updates, deltas, plan updates) + // stays out of the parent timeline and has no agent mapping yet. + return true; + } + }); + const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { const payload = notification.params; @@ -857,6 +1076,11 @@ export const makeCodexSessionRuntime = ( return; } + if (yield* interceptCollabChildNotification(notification)) { + yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); + return; + } + let requestId: ApprovalRequestId | undefined; let requestKind: ProviderRequestKind | undefined; let turnId = childParentTurnId ?? route.turnId; diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx new file mode 100644 index 00000000000..f0099bbc4b5 --- /dev/null +++ b/apps/web/src/components/AgentsPanel.tsx @@ -0,0 +1,315 @@ +/** + * Agents right-panel surface: the fleet view over the native subagent fold. + * + * Grouping: one section per workflow (phase headers with active/settled + * counts), then a Direct spawns section. Rows expand in place to the + * recent-activity ring. All status dots are static (no continuous + * animation); elapsed time uses the WorkingTimer DOM-write pattern. + */ +import type { + AgentPanelModel, + AgentPanelWorkflowGroup, + RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import { formatSubagentTokenCount } from "@t3tools/client-runtime/state/subagentRuntime"; +import { Bot, ChevronDown, ChevronRight, Check } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +const STATUS_VISUALS: Record = { + pending: { dotClass: "bg-muted-foreground/40", label: "Queued" }, + running: { dotClass: "bg-info", label: "Running" }, + waiting: { dotClass: "bg-warning", label: "Waiting" }, + idle: { dotClass: "bg-info/50", label: "Idle · resumable" }, + completed: { dotClass: "bg-success", label: "Completed" }, + failed: { dotClass: "bg-destructive", label: "Failed" }, + cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, + interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, +}; + +function StatusDot({ status }: { status: RuntimeSubagent["status"] }) { + return ( + + ); +} + +function formatElapsedSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(seconds / 60); + if (minutes === 0) { + return `${seconds}s`; + } + const hours = Math.floor(minutes / 60); + if (hours === 0) { + return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; + } + return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; +} + +function elapsedBetween(startedAt: string, endIso: string | null): string { + const start = Date.parse(startedAt); + const end = endIso ? Date.parse(endIso) : Date.now(); + if (Number.isNaN(start) || Number.isNaN(end)) { + return ""; + } + return formatElapsedSeconds((end - start) / 1000); +} + +/** + * Elapsed time for the current activation. Live agents self-tick via DOM + * writes (zero React commits per tick); settled agents freeze at completedAt. + */ +function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { + const textRef = useRef(null); + const live = agent.status === "running" || agent.status === "waiting"; + const startedAt = agent.startedAt; + + useEffect(() => { + if (!live || !startedAt) { + return; + } + const update = () => { + if (textRef.current) { + textRef.current.textContent = elapsedBetween(startedAt, null); + } + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [live, startedAt]); + + if (!startedAt) { + return null; + } + return ( + + {elapsedBetween(startedAt, live ? null : agent.completedAt)} + + ); +} + +/** + * Status-dependent activity line. Live cards lead with what is happening now; + * settled cards lead with the outcome. Errors are the only inline previews on + * failed rows because they explain a red row at a glance. + */ +function agentActivityText(agent: RuntimeSubagent): string | null { + const live = agent.status === "running" || agent.status === "pending"; + if (agent.status === "waiting") { + return "Waiting on approval or input"; + } + if (live) { + return ( + agent.progress ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? + agent.result ?? + agent.error + ); + } + return ( + agent.error ?? + agent.result ?? + agent.progress ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) + ); +} + +function AgentRow({ agent }: { agent: RuntimeSubagent }) { + const [expanded, setExpanded] = useState(false); + const visuals = STATUS_VISUALS[agent.status]; + const activity = agentActivityText(agent); + const hasFeed = agent.recentActivity.length > 0; + + return ( +
+ + {expanded && hasFeed ? ( +
+ {agent.recentActivity.toReversed().map((entry) => ( +
+ + {entry.at.slice(11, 19)} + + {entry.summary} +
+ ))} +
+ ) : null} +
+ ); +} + +function PhaseHeader({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { + return ( +
+ {phase.state === "done" ? : null} + {phase.title} + + {phase.state === "pending" && phase.members.length === 0 + ? "pending" + : phase.state === "done" + ? "" + : `${phase.activeCount} active · ${phase.settledCount} done`} + +
+ ); +} + +function WorkflowGroupSection({ group }: { group: AgentPanelWorkflowGroup }) { + return ( +
+
+ Workflow · {group.workflow.workflowName ?? group.workflow.title} + {group.workflow.runHandles?.scriptPath ? ( + + {"{}"} script + + ) : null} +
+ {group.phases.map((phase) => ( +
+ + {phase.members.map((member) => ( + + ))} +
+ ))} + {group.unphasedMembers.map((member) => ( + + ))} + {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( + + ) : null} +
+ ); +} + +export function AgentsPanel({ model }: { model: AgentPanelModel }) { + if (!model.hasAgents) { + return ( +
+ +

No agents yet

+

+ When this thread spawns subagents or runs a workflow, they show up here with live status, + activity, and token usage. +

+
+ ); + } + + return ( +
+ +
+ {model.workflows.map((group) => ( + + ))} + {model.directAgents.length > 0 ? ( +
+
+ Direct spawns +
+ {model.directAgents.map((agent) => ( + + ))} +
+ ) : null} +
+
+
+ + {model.runningCount > 0 ? ( + ● {model.runningCount} running + ) : null} + {model.waitingCount > 0 ? ( + {model.waitingCount} waiting + ) : null} + {model.idleCount > 0 ? {model.idleCount} idle : null} + {model.settledCount > 0 ? {model.settledCount} settled : null} + + Σ {formatSubagentTokenCount(model.totalTokens)} tok +
+
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..cf8dab82270 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -139,6 +139,13 @@ import { usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; +import { AgentsPanel } from "./AgentsPanel"; +import { AgentsLiveStrip } from "./chat/AgentsLiveStrip"; +import { WorkflowRunCard } from "./chat/WorkflowRunCard"; +import { + deriveAgentPanelModel, + foldSubagentActivities, +} from "@t3tools/client-runtime/state/subagentRuntime"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -2050,6 +2057,13 @@ function ChatViewContent(props: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + // Native subagent fold: memoized by activity-list identity, shared by the + // Agents surface, live strip, and workflow cards. v2Projection is null + // until orchestration-v2 lands (source precedence lives in the derive). + const agentPanelModel = useMemo( + () => deriveAgentPanelModel({ agents: foldSubagentActivities(threadActivities) }), + [threadActivities], + ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -3132,6 +3146,10 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); }, [activeProject, activeThreadRef]); + const addAgentsSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().open(activeThreadRef, "agents"); + }, [activeThreadRef]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -5703,6 +5721,8 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} mode="embedded" /> + ) : activeRightPanelSurface?.kind === "agents" ? ( + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( @@ -5893,6 +5913,37 @@ function ChatViewContent(props: ChatViewProps) { {threadSyncPhase && !activeEnvironmentUnavailable ? ( ) : null} + {agentPanelModel.workflows.some( + (group) => + group.workflow.status === "running" || + group.workflow.status === "pending" || + group.workflow.status === "waiting", + ) ? ( + // Interim mount: workflow run cards live above the composer + // until the virtualized timeline gains a card row kind + // (converges with orchestration-v2's V2LifecycleRow). +
+ {agentPanelModel.workflows + .filter( + (group) => + group.workflow.status === "running" || + group.workflow.status === "pending" || + group.workflow.status === "waiting", + ) + .map((group) => ( + + ))} +
+ ) : null} + {agentPanelModel.liveCount > 0 ? ( +
+ +
+ ) : null}
void; onAddDiff: () => void; onAddFiles: () => void; + onAddAgents: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -91,6 +92,7 @@ function RightPanelEmptyState(props: { onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; + onAddAgents: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -128,6 +130,14 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, + { + label: "Agents", + description: "Watch subagents and workflows run.", + icon: Bot, + available: true, + disabledReason: null, + onClick: props.onAddAgents, + }, ] as const; return ( @@ -205,6 +215,8 @@ function surfaceTitle( ); case "plan": return "Plan"; + case "agents": + return "Agents"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -266,6 +278,8 @@ function SurfaceIcon({ return ; case "plan": return ; + case "agents": + return ; } } @@ -471,6 +485,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { Diff + + + Agents + ) : null} @@ -485,6 +503,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} + onAddAgents={props.onAddAgents} browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} diff --git a/apps/web/src/components/chat/AgentsLiveStrip.tsx b/apps/web/src/components/chat/AgentsLiveStrip.tsx new file mode 100644 index 00000000000..24286950d14 --- /dev/null +++ b/apps/web/src/components/chat/AgentsLiveStrip.tsx @@ -0,0 +1,47 @@ +/** + * One-line ambient agent awareness above the composer, rendered only while at + * least one agent is pending/running/waiting. Awareness, not alarm: muted + * treatment, the waiting count is the only amber emphasis, and clicking opens + * the Agents panel. Static dot per the no-continuous-animation rule. + */ +import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; +import { formatSubagentTokenCount } from "@t3tools/client-runtime/state/subagentRuntime"; +import { Bot, ChevronDown } from "lucide-react"; + +export function AgentsLiveStrip({ model, onOpen }: { model: AgentPanelModel; onOpen: () => void }) { + if (model.liveCount === 0) { + return null; + } + + const runningPhase = model.workflows + .flatMap((group) => group.phases) + .find((phase) => phase.state === "running"); + const totalAgents = + model.runningCount + model.waitingCount + model.idleCount + model.settledCount; + + return ( + + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf..f4089e3fe4f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1897,8 +1897,14 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { case "mcp_tool_call": return "wrench"; case "dynamic_tool_call": - case "collab_agent_tool_call": return "hammer"; + case "collab_agent_tool_call": + return "bot"; + } + + // Subagent lifecycle rows (grouped by taskId) get agent identity chrome. + if (workEntry.taskId) { + return "bot"; } return workToneIcon(workEntry.tone).iconName; diff --git a/apps/web/src/components/chat/WorkflowRunCard.tsx b/apps/web/src/components/chat/WorkflowRunCard.tsx new file mode 100644 index 00000000000..aeb48cc8e45 --- /dev/null +++ b/apps/web/src/components/chat/WorkflowRunCard.tsx @@ -0,0 +1,137 @@ +/** + * Inline workflow run card for the chat timeline: one card per coordinator, + * replacing its generic task rows. Capped at eight member rows ordered by + * urgency (failed and running first); overflow routes to the Agents panel. + * The card is a derived, bounded view of the fold — the panel is the + * complete one. Static status visuals only. + */ +import type { AgentPanelWorkflowGroup } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + formatSubagentTokenCount, + workflowCardMembers, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import { Bot, ExternalLink } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +const INLINE_MEMBER_LIMIT = 8; + +const MEMBER_DOT: Record = { + pending: "bg-muted-foreground/40", + running: "bg-info", + waiting: "bg-warning", + idle: "bg-info/50", + completed: "bg-success", + failed: "bg-destructive", + cancelled: "bg-muted-foreground/60", + interrupted: "bg-muted-foreground/60", +}; + +function workflowStatusChip(group: AgentPanelWorkflowGroup): { + label: string; + className: string; +} { + const status = group.workflow.status; + if (status === "failed") { + return { label: "Failed", className: "text-destructive-foreground border-destructive/40" }; + } + if (status === "completed") { + return { label: "Completed", className: "text-success-foreground border-success/40" }; + } + if (status === "cancelled" || status === "interrupted") { + return { label: "Stopped", className: "text-muted-foreground border-border" }; + } + return { label: "Running", className: "text-info-foreground border-info/40" }; +} + +export function WorkflowRunCard({ + group, + onOpenAgents, +}: { + group: AgentPanelWorkflowGroup; + onOpenAgents: () => void; +}) { + const { visible, overflow } = workflowCardMembers(group, INLINE_MEMBER_LIMIT); + const chip = workflowStatusChip(group); + const allMembers = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; + const settled = allMembers.filter( + (member) => + member.status === "completed" || + member.status === "failed" || + member.status === "cancelled" || + member.status === "interrupted", + ).length; + const totalTokens = allMembers.reduce( + (sum, member) => sum + (member.usage?.totalTokens ?? 0), + group.workflow.usage?.totalTokens ?? 0, + ); + const sessionUrl = group.workflow.runHandles?.sessionUrl; + + return ( +
+
+ + + {group.workflow.workflowName ?? group.workflow.title} + + + {chip.label} + + + {settled}/{allMembers.length} agents · {formatSubagentTokenCount(totalTokens)} tok + +
+ + {sessionUrl ? ( + + + Running in the cloud — open session + + ) : ( +
+ {visible.map((member) => ( +
+ + + + + + {member.title} + + {member.status === "failed" && member.error ? ( + {member.error} + ) : null} + + + {member.phaseTitle ? `${member.phaseTitle} · ` : ""} + {member.usage ? `${formatSubagentTokenCount(member.usage.totalTokens)} tok` : ""} + +
+ ))} + {overflow > 0 ? ( + + ) : null} +
+ )} +
+ ); +} diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 70d163306cc..cccb7238ca8 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -14,7 +14,15 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; +export const RIGHT_PANEL_KINDS = [ + "plan", + "diff", + "files", + "file", + "preview", + "terminal", + "agents", +] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -37,10 +45,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" }; + | { id: "plan"; kind: "plan" } + | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 7; +const RIGHT_PANEL_STORAGE_VERSION = 8; export interface ThreadRightPanelState { isOpen: boolean; @@ -92,6 +101,8 @@ const singletonSurface = ( return { id: "files", kind }; case "plan": return { id: "plan", kind }; + case "agents": + return { id: "agents", kind }; } }; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..728eebc57d6 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1686,3 +1686,93 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:11:00.000Z"); }); }); + +describe("deriveWorkLogEntries quiet-timeline guarantee", () => { + it("N concurrent subagents produce exactly N lifecycle rows, zero attributed tool rows", () => { + const activities: OrchestrationThreadActivity[] = []; + for (let agent = 0; agent < 5; agent += 1) { + const taskId = `task-${agent}`; + // Progress ticks (several per agent) + attributed tool rows. + for (let tick = 0; tick < 4; tick += 1) { + activities.push( + makeActivity({ + kind: "task.progress", + summary: `agent ${agent} tick ${tick}`, + tone: "info", + payload: { taskId, summary: `working ${tick}`, role: "explorer" }, + sequence: agent * 20 + tick, + }), + ); + activities.push( + makeActivity({ + kind: "tool.completed", + summary: "Read", + payload: { itemType: "dynamic_tool_call", agentId: taskId }, + sequence: agent * 20 + 10 + tick, + }), + ); + } + activities.push( + makeActivity({ + kind: "task.completed", + summary: "Task completed", + tone: "info", + payload: { + taskId, + status: "completed", + summary: `agent ${agent} done`, + role: "explorer", + }, + sequence: agent * 20 + 19, + }), + ); + } + + const entries = deriveWorkLogEntries(activities); + const taskRows = entries.filter((entry) => entry.taskId !== undefined); + expect(taskRows).toHaveLength(5); + // No agent-attributed tool rows leak into the main log. + expect(entries.some((entry) => entry.sourceActivityKind?.startsWith("tool."))).toBe(false); + }); + + it("keeps unattributed tool rows (over-hiding loses the only signal)", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "tool.completed", + summary: "Bash", + payload: { itemType: "command_execution", command: "ls" }, + }), + ]); + expect(entries).toHaveLength(1); + }); + + it("suppresses timelineBypass rows (Codex children, workflow members)", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "task.progress", + summary: "child work", + tone: "info", + payload: { taskId: "child-1", timelineBypass: true }, + }), + ]); + expect(entries).toHaveLength(0); + }); + + it("drops task.updated and tool.progress from the work log (fold input only)", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "task.updated", + summary: "Task running", + tone: "info", + payload: { taskId: "task-1", status: "running" }, + }), + makeActivity({ + kind: "tool.progress", + summary: "Read", + tone: "info", + payload: { taskId: "task-1", toolName: "Read" }, + }), + ]); + expect(entries).toHaveLength(0); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..49dbb4166a5 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -78,6 +78,10 @@ export interface WorkLogEntry { toolLifecycleStatus?: WorkLogToolLifecycleStatus; /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */ sourceActivityKind?: OrchestrationThreadActivity["kind"]; + /** Grouping key for subagent lifecycle rows (one row per agent). */ + taskId?: string; + /** Agent role (subagent_type) for labeled timeline rows. */ + agentRole?: string; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -624,6 +628,30 @@ export function hasActionableProposedPlan( return proposedPlan !== null && proposedPlan.implementedAt === null; } +/** + * Quiet-timeline guarantee: the work log carries the parent's narrative plus + * at most one row per agent. Everything an agent does internally lives in the + * Agents surface: + * - timelineBypass rows (Codex children, workflow members) never render here; + * - tool rows attributed to an owning agent (payload.agentId) are re-homed; + * - task.progress ticks collapse into one row per taskId; + * - task.updated is fold input only (status patches are not narrative). + * Unattributed rows always stay: over-hiding loses the only terminal signal. + */ +function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + if (!payload) { + return false; + } + if (payload.timelineBypass === true) { + return true; + } + return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; +} + export function deriveWorkLogEntries( activities: ReadonlyArray, ): WorkLogEntry[] { @@ -632,9 +660,12 @@ export function deriveWorkLogEntries( for (const activity of ordered) { if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; + if (activity.kind === "task.updated") continue; + if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; + if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } return collapseDerivedWorkLogEntries(entries).map((entry) => { @@ -756,6 +787,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolLifecycleStatus) { entry.toolLifecycleStatus = toolLifecycleStatus; } + if (isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0) { + entry.taskId = payload.taskId; + } + if (isTaskActivity && typeof payload?.role === "string" && payload.role.length > 0) { + entry.agentRole = payload.role; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -767,7 +804,24 @@ function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; + // Subagent rows collapse by identity, not adjacency: with concurrent + // agents, one agent's progress rows interleave with another's, and each + // agent still gets exactly one row (quiet-timeline guarantee). + const taskRowIndex = new Map(); for (const entry of entries) { + const isTaskRow = + entry.taskId !== undefined && + (entry.activityKind === "task.progress" || entry.activityKind === "task.completed"); + if (isTaskRow) { + const existingIndex = taskRowIndex.get(entry.taskId!); + if (existingIndex !== undefined) { + collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + continue; + } + taskRowIndex.set(entry.taskId!, collapsed.length); + collapsed.push(entry); + continue; + } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); @@ -847,6 +901,14 @@ function mergeChangedFiles( } function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { + // Subagent lifecycle rows collapse by agent identity: one row per agent, + // progress ticks fold into it, the terminal row wins the label. + if ( + entry.taskId && + (entry.activityKind === "task.progress" || entry.activityKind === "task.completed") + ) { + return `task${entry.taskId}`; + } if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 0b7b078a522..d1daa871652 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -123,6 +123,10 @@ "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" }, + "./state/subagentRuntime": { + "types": "./src/state/subagentRuntime.ts", + "default": "./src/state/subagentRuntime.ts" + }, "./state/thread-sort": { "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts new file mode 100644 index 00000000000..89c77506ae7 --- /dev/null +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from "vitest"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { + deriveAgentPanelModel, + foldSubagentActivities, + formatSubagentTokenCount, + isAgentAttributedToolActivity, + isSubagentActivityKind, + isTimelineBypassActivity, + workflowCardMembers, +} from "./subagentRuntime.ts"; + +let sequence = 0; +function activity( + kind: string, + payload: Record, + at = `2026-08-01T10:00:${String(sequence).padStart(2, "0")}.000Z`, +): OrchestrationThreadActivity { + sequence += 1; + return { + id: `activity-${sequence}`, + tone: "info", + kind, + summary: kind, + payload, + turnId: null, + createdAt: at, + } as unknown as OrchestrationThreadActivity; +} + +function fold(rows: ReadonlyArray) { + return foldSubagentActivities(rows); +} + +describe("foldSubagentActivities", () => { + it("builds an agent from start → progress → completion", () => { + const agents = fold([ + activity("task.started", { + taskId: "task-1", + title: "Audit auth flow", + role: "explorer", + }), + activity("task.progress", { + taskId: "task-1", + lastToolName: "Read", + typedUsage: { totalTokens: 1200, toolUses: 3 }, + }), + activity("task.completed", { + taskId: "task-1", + status: "completed", + summary: "Found 2 issues", + typedUsage: { totalTokens: 5000, toolUses: 9 }, + }), + ]); + expect(agents).toHaveLength(1); + const agent = agents[0]!; + expect(agent.title).toBe("Audit auth flow"); + expect(agent.role).toBe("explorer"); + expect(agent.status).toBe("completed"); + expect(agent.result).toBe("Found 2 issues"); + expect(agent.usage?.totalTokens).toBe(5000); + expect(agent.activationCount).toBe(1); + expect(agent.completedAt).not.toBeNull(); + }); + + it("progress can create an agent when its start row aged out of retention", () => { + const agents = fold([ + activity("task.progress", { + taskId: "task-orphan", + title: "Recovered agent", + role: "verifier", + typedUsage: { totalTokens: 100 }, + }), + ]); + expect(agents).toHaveLength(1); + expect(agents[0]!.title).toBe("Recovered agent"); + expect(agents[0]!.status).toBe("running"); + }); + + it("completion before start stays terminal; a late start only fills metadata", () => { + const agents = fold([ + activity("task.completed", { taskId: "task-2", status: "failed", summary: "boom" }), + activity("task.started", { taskId: "task-2", title: "Late metadata", role: "fixer" }), + ]); + expect(agents).toHaveLength(1); + const agent = agents[0]!; + expect(agent.title).toBe("Late metadata"); + expect(agent.role).toBe("fixer"); + // The late start must NOT reopen the terminal activation as a new run. + expect(agent.status).toBe("failed"); + expect(agent.error).toBe("boom"); + }); + + it("duplicate terminal events are idempotent (timestamps do not slide)", () => { + const agents = fold([ + activity("task.started", { taskId: "task-3" }), + activity( + "task.completed", + { taskId: "task-3", status: "completed" }, + "2026-08-01T11:00:00.000Z", + ), + activity( + "task.completed", + { taskId: "task-3", status: "completed" }, + "2026-08-01T12:00:00.000Z", + ), + ]); + expect(agents[0]!.completedAt).toBe("2026-08-01T11:00:00.000Z"); + }); + + it("reactivation increments the run count and clears result/error", () => { + const agents = fold([ + activity("task.started", { taskId: "task-4" }), + activity("task.completed", { taskId: "task-4", status: "completed", summary: "run 1 done" }), + activity("task.updated", { taskId: "task-4", status: "running" }), + ]); + const agent = agents[0]!; + expect(agent.activationCount).toBe(2); + expect(agent.result).toBeNull(); + expect(agent.completedAt).toBeNull(); + expect(agent.status).toBe("running"); + }); + + it("idle is nonterminal: an idle agent resumes without losing identity", () => { + const agents = fold([ + activity("task.started", { taskId: "codex-child-1", title: "Marlow", role: "explorer" }), + activity("task.updated", { taskId: "codex-child-1", status: "idle" }), + activity("task.updated", { taskId: "codex-child-1", status: "running" }), + ]); + expect(agents).toHaveLength(1); + expect(agents[0]!.activationCount).toBe(2); + expect(agents[0]!.status).toBe("running"); + }); + + it("cumulative usage max-merges: duplicate and late frames never shrink or double-count", () => { + const agents = fold([ + activity("task.started", { taskId: "task-5" }), + activity("task.progress", { + taskId: "task-5", + typedUsage: { totalTokens: 900, inputTokens: 700 }, + }), + activity("task.progress", { + taskId: "task-5", + typedUsage: { totalTokens: 900, inputTokens: 700 }, + }), + activity("task.progress", { taskId: "task-5", typedUsage: { totalTokens: 500 } }), + ]); + expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 }); + }); + + it("partial terminal usage preserves known breakdown fields", () => { + const agents = fold([ + activity("task.started", { taskId: "task-6" }), + activity("task.progress", { + taskId: "task-6", + typedUsage: { totalTokens: 800, inputTokens: 600, outputTokens: 150 }, + }), + activity("task.completed", { + taskId: "task-6", + status: "completed", + typedUsage: { totalTokens: 1000 }, + }), + ]); + expect(agents[0]!.usage).toEqual({ totalTokens: 1000, inputTokens: 600, outputTokens: 150 }); + }); + + it("skips malformed rows individually without failing the fold", () => { + const agents = fold([ + activity("task.started", { taskId: "task-7", title: "Good" }), + activity("task.progress", { bogus: true }), + activity("task.progress", { taskId: 42 }), + ]); + expect(agents).toHaveLength(1); + expect(agents[0]!.title).toBe("Good"); + }); + + it("bounds repeated strings at 180 chars and the activity ring at 6 deduped entries", () => { + const long = "x".repeat(500); + const rows = [activity("task.started", { taskId: "task-8" })]; + for (let i = 0; i < 10; i += 1) { + rows.push(activity("task.progress", { taskId: "task-8", summary: `${long}-${i}` })); + } + rows.push(activity("task.progress", { taskId: "task-8", summary: `${long}-9` })); + const agents = fold(rows); + const agent = agents[0]!; + expect(agent.recentActivity.length).toBeLessThanOrEqual(6); + for (const entry of agent.recentActivity) { + expect(entry.summary.length).toBeLessThanOrEqual(180); + } + // Consecutive identical summaries dedupe (truncation makes them equal). + const summaries = agent.recentActivity.map((entry) => entry.summary); + expect(new Set(summaries).size).toBe(summaries.length); + }); + + it("plan tasks are not agents", () => { + const agents = fold([activity("task.started", { taskId: "plan-1", taskType: "plan" })]); + expect(agents).toHaveLength(0); + }); + + it("workflow members key by stable slot and attach to their coordinator", () => { + const agents = fold([ + activity("task.started", { + taskId: "wf-1", + taskType: "local_workflow", + title: "audit-auth-flow", + workflowName: "audit-auth-flow", + }), + activity("task.progress", { + taskId: "wf-1", + phases: [ + { index: 0, title: "Audit" }, + { index: 1, title: "Verify" }, + ], + }), + activity("task.progress", { + taskId: "wf-1:wf:0", + title: "audit:entrypoints", + status: "running", + parentAgentId: "wf-1", + agentIndex: 0, + phaseIndex: 0, + phaseTitle: "Audit", + timelineBypass: true, + }), + ]); + const workflow = agents.find((agent) => agent.id === "wf-1"); + const member = agents.find((agent) => agent.id === "wf-1:wf:0"); + expect(workflow?.kind).toBe("workflow"); + expect(workflow?.phases).toEqual([ + { index: 0, title: "Audit" }, + { index: 1, title: "Verify" }, + ]); + expect(member?.kind).toBe("workflow_agent"); + expect(member?.parentAgentId).toBe("wf-1"); + }); + + it("a workflow member retry (attempt bump) is a reactivation of the same slot", () => { + const agents = fold([ + activity("task.progress", { + taskId: "wf-2:wf:1", + title: "verify:refresh", + status: "failed", + error: "attempt 1 died", + parentAgentId: "wf-2", + attempt: 1, + }), + activity("task.progress", { + taskId: "wf-2:wf:1", + title: "verify:refresh", + status: "running", + parentAgentId: "wf-2", + attempt: 2, + }), + ]); + expect(agents).toHaveLength(1); + const member = agents[0]!; + expect(member.activationCount).toBeGreaterThanOrEqual(2); + expect(member.error).toBeNull(); + expect(member.status).toBe("running"); + }); + + it("drops non-http(s) session urls at the fold boundary", () => { + const agents = fold([ + activity("task.started", { + taskId: "wf-3", + taskType: "local_workflow", + runHandles: { sessionUrl: "javascript:alert(1)", runId: "run-1" }, + }), + ]); + expect(agents[0]!.runHandles?.sessionUrl).toBeUndefined(); + expect(agents[0]!.runHandles?.runId).toBe("run-1"); + }); +}); + +describe("deriveAgentPanelModel", () => { + const roster = fold([ + activity("task.started", { taskId: "wf-1", taskType: "local_workflow", title: "audit" }), + activity("task.progress", { + taskId: "wf-1", + phases: [ + { index: 0, title: "Audit" }, + { index: 1, title: "Verify" }, + ], + }), + activity("task.progress", { + taskId: "wf-1:wf:0", + title: "audit:a", + status: "completed", + parentAgentId: "wf-1", + agentIndex: 0, + phaseIndex: 0, + }), + activity("task.completed", { taskId: "wf-1:wf:0", status: "completed", parentAgentId: "wf-1" }), + activity("task.progress", { + taskId: "wf-1:wf:1", + title: "verify:b", + status: "running", + parentAgentId: "wf-1", + agentIndex: 1, + phaseIndex: 1, + typedUsage: { totalTokens: 4000 }, + }), + activity("task.started", { taskId: "direct-1", title: "Marlow", role: "explorer" }), + activity("task.updated", { taskId: "direct-1", status: "idle" }), + ]); + + it("groups workflow members by phase and separates direct spawns", () => { + const model = deriveAgentPanelModel({ agents: roster }); + expect(model.workflows).toHaveLength(1); + const group = model.workflows[0]!; + expect(group.phases).toHaveLength(2); + expect(group.phases[0]!.state).toBe("done"); + expect(group.phases[1]!.state).toBe("running"); + expect(model.directAgents.map((agent) => agent.id)).toEqual(["direct-1"]); + }); + + it("counts idle deliberately and waiting as active", () => { + const model = deriveAgentPanelModel({ agents: roster }); + expect(model.idleCount).toBe(1); + // wf-1 coordinator + member 1 running. + expect(model.runningCount).toBeGreaterThanOrEqual(1); + expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe( + roster.length, + ); + }); + + it("a phase with only pending members never reads as running", () => { + const pendingRoster = fold([ + activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }), + activity("task.progress", { + taskId: "wf-9", + phases: [{ index: 0, title: "Fix" }], + }), + activity("task.progress", { + taskId: "wf-9:wf:0", + title: "fixer", + status: "pending", + parentAgentId: "wf-9", + agentIndex: 0, + phaseIndex: 0, + }), + ]); + const model = deriveAgentPanelModel({ agents: pendingRoster }); + // "pending" counts as active liveness (queued work), so the phase reads + // running only if a member is genuinely pending/running — this asserts + // the settled-count rule: no member settled, phase not done. + expect(model.workflows[0]!.phases[0]!.state).not.toBe("done"); + }); + + it("v2 projection wins outright and sources are never merged", () => { + const v2Agent = { ...roster[0]!, id: "v2-only", title: "From v2" }; + const model = deriveAgentPanelModel({ agents: roster, v2Projection: [v2Agent] }); + const allIds = [ + ...model.workflows.map((group) => group.workflow.id), + ...model.directAgents.map((agent) => agent.id), + ]; + expect(allIds).toContain("v2-only"); + expect(allIds).not.toContain("direct-1"); + }); + + it("orphaned members fall back to the direct list", () => { + const orphans = fold([ + activity("task.progress", { + taskId: "gone:wf:0", + title: "orphan", + status: "running", + parentAgentId: "gone", + }), + ]); + const model = deriveAgentPanelModel({ agents: orphans }); + expect(model.workflows).toHaveLength(0); + expect(model.directAgents.map((agent) => agent.id)).toEqual(["gone:wf:0"]); + }); +}); + +describe("workflowCardMembers", () => { + it("orders by urgency (failed, running, waiting) and reports overflow", () => { + const roster = fold([ + activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), + ...[..."abcdefghij"].map((letter, index) => + activity("task.progress", { + taskId: `wf-1:wf:${index}`, + title: `agent-${letter}`, + status: index === 3 ? "failed" : index < 3 ? "completed" : "running", + ...(index === 3 ? { error: "died" } : {}), + parentAgentId: "wf-1", + agentIndex: index, + phaseIndex: 0, + phaseTitle: "Work", + }), + ), + ]); + const model = deriveAgentPanelModel({ agents: roster }); + const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8); + expect(visible).toHaveLength(8); + expect(overflow).toBe(2); + expect(visible[0]!.status).toBe("failed"); + expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2); + }); +}); + +describe("timeline predicates", () => { + it("recognizes subagent activity kinds as fold input", () => { + for (const kind of [ + "task.started", + "task.progress", + "task.updated", + "task.completed", + "tool.progress", + ]) { + expect(isSubagentActivityKind(kind)).toBe(true); + } + expect(isSubagentActivityKind("tool.completed")).toBe(false); + }); + + it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => { + expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe( + true, + ); + expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false); + expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe( + false, + ); + }); + + it("timelineBypass rows never render in the parent chat", () => { + expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe( + true, + ); + expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false); + }); +}); + +describe("formatSubagentTokenCount", () => { + it("formats plain counters", () => { + expect(formatSubagentTokenCount(950)).toBe("950"); + expect(formatSubagentTokenCount(41200)).toBe("41.2k"); + expect(formatSubagentTokenCount(247000)).toBe("247k"); + expect(formatSubagentTokenCount(1_400_000)).toBe("1.4M"); + }); +}); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts new file mode 100644 index 00000000000..8562e73c90a --- /dev/null +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -0,0 +1,793 @@ +/** + * Native-provider subagent observability: a tolerant fold over persisted + * task.* / tool.* thread activities into orchestration-v2-shaped subagent + * state, plus the source-neutral panel model every client renders. + * + * This module is deliberately legacy-bridge code. When orchestration-v2's + * subagent projection is available for a thread, deriveAgentPanelModel + * prefers it (see the v2Projection parameter) and the fold is skipped; when + * the v1 orchestrator is retired this file is deleted. Field names and + * transition semantics copy the v2 stack (#4779) exactly so that swap is + * mechanical. + * + * Invariants encoded here trace to shipped bugs in the prior PRs (#4220, + * #3650, #4662): reusable identity vs one-shot activations, idle as a real + * nonterminal state, provider-specific usage merges, first-write terminal + * timestamps, reactivation clearing terminal detail, and order-robust + * folding (completion can create an agent; a late start only fills + * metadata). + */ +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; + +export type RuntimeSubagentStatus = + | "pending" + | "running" + | "waiting" + | "idle" + | "completed" + | "failed" + | "cancelled" + | "interrupted"; + +export interface SubagentUsage { + readonly totalTokens: number; + readonly inputTokens?: number; + readonly cachedInputTokens?: number; + readonly outputTokens?: number; + readonly reasoningOutputTokens?: number; + readonly toolUses?: number; + readonly durationMs?: number; +} + +export interface SubagentActivityEntry { + readonly at: string; + readonly summary: string; +} + +export interface SubagentWorkflowPhase { + readonly index: number; + readonly title: string; +} + +export interface SubagentRunHandles { + readonly runId?: string; + readonly scriptPath?: string; + readonly transcriptDir?: string; + readonly sessionUrl?: string; +} + +export interface RuntimeSubagent { + readonly id: string; + readonly kind: "subagent" | "workflow" | "workflow_agent"; + readonly title: string; + readonly role: string | null; + readonly model: string | null; + readonly status: RuntimeSubagentStatus; + readonly activationCount: number; + readonly usage: SubagentUsage | null; + readonly progress: string | null; + readonly lastToolName: string | null; + readonly result: string | null; + readonly error: string | null; + readonly outputFile: string | null; + readonly parentAgentId: string | null; + readonly agentIndex: number | null; + readonly phaseIndex: number | null; + readonly phaseTitle: string | null; + readonly attempt: number | null; + readonly workflowName: string | null; + readonly phases: ReadonlyArray; + readonly runHandles: SubagentRunHandles | null; + readonly recentActivity: ReadonlyArray; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly updatedAt: string; +} + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", +]); + +export function isTerminalSubagentStatus(status: RuntimeSubagentStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +/** Active = the user may still need to care while it runs. Idle is settled-ish + * but resumable; waiting counts as active because it needs the user. */ +export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { + return status === "pending" || status === "running" || status === "waiting"; +} + +const RECENT_ACTIVITY_LIMIT = 6; +const SUMMARY_CHAR_LIMIT = 180; +const ROSTER_LIMIT = 100; + +function bounded(value: string): string { + return value.length <= SUMMARY_CHAR_LIMIT ? value : `${value.slice(0, SUMMARY_CHAR_LIMIT - 1)}…`; +} + +/** Appends to the ring buffer, deduping consecutive identical summaries. */ +function appendActivity( + entries: ReadonlyArray, + at: string, + summary: string, +): ReadonlyArray { + const boundedSummary = bounded(summary); + if (entries.length > 0 && entries[entries.length - 1]?.summary === boundedSummary) { + return entries; + } + const next = [...entries, { at, summary: boundedSummary }]; + return next.length > RECENT_ACTIVITY_LIMIT ? next.slice(-RECENT_ACTIVITY_LIMIT) : next; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asCount(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function asUsage(value: unknown): SubagentUsage | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + const totalTokens = asCount(record.totalTokens); + if (totalTokens === undefined) { + return undefined; + } + const usage: { + totalTokens: number; + inputTokens?: number; + cachedInputTokens?: number; + outputTokens?: number; + reasoningOutputTokens?: number; + toolUses?: number; + durationMs?: number; + } = { totalTokens }; + const inputTokens = asCount(record.inputTokens); + if (inputTokens !== undefined) usage.inputTokens = inputTokens; + const cachedInputTokens = asCount(record.cachedInputTokens); + if (cachedInputTokens !== undefined) usage.cachedInputTokens = cachedInputTokens; + const outputTokens = asCount(record.outputTokens); + if (outputTokens !== undefined) usage.outputTokens = outputTokens; + const reasoningOutputTokens = asCount(record.reasoningOutputTokens); + if (reasoningOutputTokens !== undefined) usage.reasoningOutputTokens = reasoningOutputTokens; + const toolUses = asCount(record.toolUses); + if (toolUses !== undefined) usage.toolUses = toolUses; + const durationMs = asCount(record.durationMs); + if (durationMs !== undefined) usage.durationMs = durationMs; + return usage; +} + +/** + * Provider-specific usage merge (#4779 semantics, verbatim): + * - max-merge (Codex-style cumulative frames): field-wise maximum, idempotent + * under duplicate or late frames. Cumulative totals never shrink. + * - accumulate (Claude-style activation deltas): not needed at this layer — + * Claude's task_progress usage is itself cumulative per task, so the fold + * also max-merges. The distinction matters when v2 sums activations. + * Field-wise: a terminal payload carrying only totalTokens must not wipe a + * known breakdown. + */ +function mergeUsageMax( + current: SubagentUsage | null, + incoming: SubagentUsage | undefined, +): SubagentUsage | null { + if (!incoming) { + return current; + } + if (!current) { + return incoming; + } + const pick = (a: number | undefined, b: number | undefined): number | undefined => + a === undefined ? b : b === undefined ? a : Math.max(a, b); + const merged: { + totalTokens: number; + inputTokens?: number; + cachedInputTokens?: number; + outputTokens?: number; + reasoningOutputTokens?: number; + toolUses?: number; + durationMs?: number; + } = { totalTokens: Math.max(current.totalTokens, incoming.totalTokens) }; + const inputTokens = pick(current.inputTokens, incoming.inputTokens); + if (inputTokens !== undefined) merged.inputTokens = inputTokens; + const cachedInputTokens = pick(current.cachedInputTokens, incoming.cachedInputTokens); + if (cachedInputTokens !== undefined) merged.cachedInputTokens = cachedInputTokens; + const outputTokens = pick(current.outputTokens, incoming.outputTokens); + if (outputTokens !== undefined) merged.outputTokens = outputTokens; + const reasoningOutputTokens = pick(current.reasoningOutputTokens, incoming.reasoningOutputTokens); + if (reasoningOutputTokens !== undefined) merged.reasoningOutputTokens = reasoningOutputTokens; + const toolUses = pick(current.toolUses, incoming.toolUses); + if (toolUses !== undefined) merged.toolUses = toolUses; + const durationMs = pick(current.durationMs, incoming.durationMs); + if (durationMs !== undefined) merged.durationMs = durationMs; + return merged; +} + +interface MutableAgent { + id: string; + kind: RuntimeSubagent["kind"]; + title: string; + role: string | null; + model: string | null; + status: RuntimeSubagentStatus; + activationCount: number; + usage: SubagentUsage | null; + progress: string | null; + lastToolName: string | null; + result: string | null; + error: string | null; + outputFile: string | null; + parentAgentId: string | null; + agentIndex: number | null; + phaseIndex: number | null; + phaseTitle: string | null; + attempt: number | null; + workflowName: string | null; + phases: ReadonlyArray; + runHandles: SubagentRunHandles | null; + recentActivity: ReadonlyArray; + startedAt: string | null; + completedAt: string | null; + updatedAt: string; +} + +function kindFromPayload( + payload: Record, + agentId: string, +): RuntimeSubagent["kind"] { + if (asString(payload.taskType) === "local_workflow") { + return "workflow"; + } + if (payload.parentAgentId !== undefined || agentId.includes(":wf:")) { + return "workflow_agent"; + } + return "subagent"; +} + +/** Completion can create an agent (its start may have aged out of retention). */ +function getOrCreate( + agents: Map, + id: string, + payload: Record, + at: string, +): MutableAgent { + const existing = agents.get(id); + if (existing) { + return existing; + } + const created: MutableAgent = { + id, + kind: kindFromPayload(payload, id), + title: asString(payload.title) ?? asString(payload.detail) ?? id, + role: asString(payload.role) ?? null, + model: asString(payload.model) ?? null, + status: "pending", + activationCount: 0, + usage: null, + progress: null, + lastToolName: null, + result: null, + error: null, + outputFile: null, + parentAgentId: asString(payload.parentAgentId) ?? null, + agentIndex: asCount(payload.agentIndex) ?? null, + phaseIndex: asCount(payload.phaseIndex) ?? null, + phaseTitle: asString(payload.phaseTitle) ?? null, + attempt: asCount(payload.attempt) ?? null, + workflowName: asString(payload.workflowName) ?? null, + phases: [], + runHandles: null, + recentActivity: [], + startedAt: null, + completedAt: null, + updatedAt: at, + }; + agents.set(id, created); + return created; +} + +/** Metadata fill from any payload: never downgrades known values to null. */ +function fillMetadata(agent: MutableAgent, payload: Record): void { + const title = asString(payload.title); + if (title) agent.title = title; + const role = asString(payload.role); + if (role) agent.role = role; + const model = asString(payload.model); + if (model) agent.model = model; + const parentAgentId = asString(payload.parentAgentId); + if (parentAgentId) { + agent.parentAgentId = parentAgentId; + if (agent.kind === "subagent") agent.kind = "workflow_agent"; + } + const workflowName = asString(payload.workflowName); + if (workflowName) agent.workflowName = workflowName; + if (asString(payload.taskType) === "local_workflow") agent.kind = "workflow"; + const agentIndex = asCount(payload.agentIndex); + if (agentIndex !== undefined) agent.agentIndex = agentIndex; + const phaseIndex = asCount(payload.phaseIndex); + if (phaseIndex !== undefined) agent.phaseIndex = phaseIndex; + const phaseTitle = asString(payload.phaseTitle); + if (phaseTitle) agent.phaseTitle = phaseTitle; + const attempt = asCount(payload.attempt); + if (attempt !== undefined) { + // A new attempt on a workflow slot is a reactivation of the same identity: + // bump the run count and clear the previous attempt's terminal detail. + if (agent.attempt !== null && attempt > agent.attempt) { + agent.activationCount += 1; + agent.result = null; + agent.error = null; + agent.completedAt = null; + } + agent.attempt = attempt; + } + const outputFile = asString(payload.outputFile); + if (outputFile) agent.outputFile = outputFile; + if (Array.isArray(payload.phases)) { + const phases: SubagentWorkflowPhase[] = []; + for (const entry of payload.phases) { + if (typeof entry !== "object" || entry === null) continue; + const record = entry as Record; + const index = asCount(record.index); + const phaseName = asString(record.title); + if (index !== undefined && phaseName) { + phases.push({ index, title: phaseName }); + } + } + if (phases.length > 0) { + agent.phases = phases.toSorted((a, b) => a.index - b.index); + } + } + if (typeof payload.runHandles === "object" && payload.runHandles !== null) { + const record = payload.runHandles as Record; + const runHandles: { + runId?: string; + scriptPath?: string; + transcriptDir?: string; + sessionUrl?: string; + } = {}; + const runId = asString(record.runId); + if (runId) runHandles.runId = runId; + const scriptPath = asString(record.scriptPath); + if (scriptPath) runHandles.scriptPath = scriptPath; + const transcriptDir = asString(record.transcriptDir); + if (transcriptDir) runHandles.transcriptDir = transcriptDir; + // Defense-in-depth: the adapter already sanitizes, but payloads are not + // schema-validated on the read path (shipped XSS lesson). + const sessionUrl = asString(record.sessionUrl); + if (sessionUrl && /^https?:\/\//i.test(sessionUrl)) runHandles.sessionUrl = sessionUrl; + if (Object.keys(runHandles).length > 0) { + agent.runHandles = { ...agent.runHandles, ...runHandles }; + } + } +} + +function applyStatus(agent: MutableAgent, status: RuntimeSubagentStatus, at: string): void { + const wasTerminal = isTerminalSubagentStatus(agent.status); + const isTerminal = isTerminalSubagentStatus(status); + if (wasTerminal && isTerminal) { + // Duplicate terminal events are idempotent: first write wins, timestamps + // don't slide. + return; + } + if ((wasTerminal || agent.status === "idle") && (status === "running" || status === "pending")) { + // Reactivation: same identity, new run. Clear the previous run's terminal + // detail so a live card never shows the prior run's output. + agent.activationCount += 1; + agent.result = null; + agent.error = null; + agent.completedAt = null; + if (status === "running") { + agent.startedAt = at; + } + } + if (status === "running" && agent.startedAt === null) { + agent.startedAt = at; + } + if (isTerminal && agent.completedAt === null) { + agent.completedAt = at; + } + agent.status = status; +} + +const TASK_COMPLETED_STATUS: Record = { + completed: "completed", + failed: "failed", + stopped: "interrupted", +}; + +const KNOWN_STATUSES: ReadonlySet = new Set([ + "pending", + "running", + "waiting", + "idle", + "completed", + "failed", + "cancelled", + "interrupted", +]); + +function asRuntimeStatus(value: unknown): RuntimeSubagentStatus | undefined { + return typeof value === "string" && KNOWN_STATUSES.has(value) + ? (value as RuntimeSubagentStatus) + : undefined; +} + +/** + * Folds a thread's persisted activities into subagent state. Tolerant by + * construction: malformed rows are skipped individually; unknown kinds are + * ignored. Pure — memoize by activity-list identity at the atom layer. + */ +export function foldSubagentActivities( + activities: ReadonlyArray, +): ReadonlyArray { + const agents = new Map(); + + for (const activity of activities) { + if (typeof activity.payload !== "object" || activity.payload === null) { + continue; + } + const payload = activity.payload as Record; + const at = activity.createdAt; + + switch (activity.kind) { + case "task.started": { + const taskId = asString(payload.taskId); + if (!taskId) break; + // Plan-mode "tasks" and other ambient work are not agents. + const taskType = asString(payload.taskType); + if (taskType === "plan") break; + const agent = getOrCreate(agents, taskId, payload, at); + fillMetadata(agent, payload); + // Order-robustness: a start row arriving after a terminal state is a + // late/out-of-order delivery and only fills metadata — it must not + // reopen the run. Reactivation comes exclusively from explicit + // status transitions (task.updated / progress status). + if (agent.activationCount === 0) { + agent.activationCount = 1; + agent.startedAt = agent.startedAt ?? at; + agent.status = "running"; + } else if (agent.status === "idle") { + applyStatus(agent, "running", at); + } + const detail = asString(payload.detail); + if (detail && agent.title === agent.id) agent.title = detail; + agent.updatedAt = at; + break; + } + case "task.progress": { + const taskId = asString(payload.taskId); + if (!taskId) break; + const agent = getOrCreate(agents, taskId, payload, at); + fillMetadata(agent, payload); + if (agent.activationCount === 0) agent.activationCount = 1; + const explicitStatus = asRuntimeStatus(payload.status); + if (explicitStatus) { + applyStatus(agent, explicitStatus, at); + } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") { + applyStatus(agent, "running", at); + } + const summary = asString(payload.summary); + if (summary) { + agent.progress = bounded(summary); + agent.recentActivity = appendActivity(agent.recentActivity, at, summary); + } + const lastToolName = asString(payload.lastToolName); + if (lastToolName) { + agent.lastToolName = lastToolName; + if (!summary) { + agent.recentActivity = appendActivity(agent.recentActivity, at, `▸ ${lastToolName}`); + } + } + const error = asString(payload.error); + if (error) agent.error = bounded(error); + agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); + agent.updatedAt = at; + break; + } + case "task.updated": { + const taskId = asString(payload.taskId); + if (!taskId) break; + const agent = getOrCreate(agents, taskId, payload, at); + fillMetadata(agent, payload); + const status = asRuntimeStatus(payload.status); + if (status) applyStatus(agent, status, at); + const error = asString(payload.error); + if (error) agent.error = bounded(error); + const endedAt = asString(payload.endedAt); + if (endedAt && isTerminalSubagentStatus(agent.status) && agent.completedAt === null) { + agent.completedAt = endedAt; + } + agent.updatedAt = at; + break; + } + case "task.completed": { + const taskId = asString(payload.taskId); + if (!taskId) break; + const agent = getOrCreate(agents, taskId, payload, at); + fillMetadata(agent, payload); + if (agent.activationCount === 0) agent.activationCount = 1; + const status = TASK_COMPLETED_STATUS[asString(payload.status) ?? ""] ?? "completed"; + applyStatus(agent, status, at); + const summary = asString(payload.summary) ?? asString(payload.detail); + if (summary) { + if (status === "failed") { + agent.error = agent.error ?? bounded(summary); + } else { + agent.result = bounded(summary); + } + } + agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); + agent.updatedAt = at; + break; + } + case "tool.progress": { + // Agent-owned heartbeat: "what it's doing right now". + const taskId = asString(payload.taskId); + if (!taskId) break; + const agent = agents.get(taskId); + if (!agent) break; + const toolName = asString(payload.toolName); + if (toolName) { + agent.lastToolName = toolName; + agent.recentActivity = appendActivity(agent.recentActivity, at, `▸ ${toolName}`); + } + agent.updatedAt = at; + break; + } + default: + break; + } + } + + let roster = Array.from(agents.values()); + if (roster.length > ROSTER_LIMIT) { + // Prefer live, then waiting/idle, then newest settled. + const rank = (agent: MutableAgent): number => + isActiveSubagentStatus(agent.status) ? 0 : agent.status === "idle" ? 1 : 2; + roster = roster + .toSorted((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt)) + .slice(0, ROSTER_LIMIT); + } + + return roster.map((agent) => ({ ...agent })); +} + +export interface AgentPanelWorkflowGroup { + readonly workflow: RuntimeSubagent; + readonly phases: ReadonlyArray<{ + readonly index: number; + readonly title: string; + readonly members: ReadonlyArray; + /** done = every member settled (success or error); running = any active. */ + readonly state: "pending" | "running" | "done"; + readonly activeCount: number; + readonly settledCount: number; + }>; + /** Members with no resolvable phase (orphans render under the workflow). */ + readonly unphasedMembers: ReadonlyArray; +} + +export interface AgentPanelModel { + readonly workflows: ReadonlyArray; + readonly directAgents: ReadonlyArray; + readonly runningCount: number; + readonly waitingCount: number; + readonly idleCount: number; + readonly settledCount: number; + readonly totalTokens: number; + readonly hasAgents: boolean; + readonly liveCount: number; +} + +const EMPTY_PANEL_MODEL: AgentPanelModel = { + workflows: [], + directAgents: [], + runningCount: 0, + waitingCount: 0, + idleCount: 0, + settledCount: 0, + totalTokens: 0, + hasAgents: false, + liveCount: 0, +}; + +export function emptyAgentPanelModel(): AgentPanelModel { + return EMPTY_PANEL_MODEL; +} + +/** + * Source-neutral view model. When the orchestration-v2 subagent projection + * exists for the thread, pass it as v2Projection and it wins outright — the + * two sources are never merged (duplicate-agents failure mode). Until v2 + * lands, callers pass null and the native fold output is used. + */ +export function deriveAgentPanelModel({ + agents, + v2Projection, +}: { + readonly agents: ReadonlyArray; + readonly v2Projection?: ReadonlyArray | null; +}): AgentPanelModel { + const source = v2Projection ?? agents; + if (source.length === 0) { + return EMPTY_PANEL_MODEL; + } + + const workflows = source.filter((agent) => agent.kind === "workflow"); + const workflowIds = new Set(workflows.map((workflow) => workflow.id)); + const members = new Map(); + const direct: RuntimeSubagent[] = []; + + for (const agent of source) { + if (agent.kind === "workflow") { + continue; + } + if (agent.parentAgentId !== null && workflowIds.has(agent.parentAgentId)) { + const list = members.get(agent.parentAgentId) ?? []; + list.push(agent); + members.set(agent.parentAgentId, list); + } else { + // Orphaned members (coordinator aged out) fall back to the direct list. + direct.push(agent); + } + } + + const workflowGroups: AgentPanelWorkflowGroup[] = workflows.map((workflow) => { + const workflowMembers = members.get(workflow.id) ?? []; + const knownPhases = + workflow.phases.length > 0 + ? workflow.phases + : (() => { + const derived = new Map(); + for (const member of workflowMembers) { + if (member.phaseIndex !== null && !derived.has(member.phaseIndex)) { + derived.set( + member.phaseIndex, + member.phaseTitle ?? `Phase ${member.phaseIndex + 1}`, + ); + } + } + return Array.from(derived.entries()) + .map(([index, title]) => ({ index, title })) + .toSorted((a, b) => a.index - b.index); + })(); + + const phases = knownPhases.map((phase) => { + const phaseMembers = workflowMembers + .filter((member) => member.phaseIndex === phase.index) + .toSorted((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); + const activeCount = phaseMembers.filter((member) => + // Idle members count as active for phase-liveness: a resumable Codex + // member has not finished the phase. + isActiveSubagentStatus(member.status), + ).length; + const settledCount = phaseMembers.filter((member) => + isTerminalSubagentStatus(member.status), + ).length; + const state: "pending" | "running" | "done" = + phaseMembers.length === 0 + ? "pending" + : activeCount > 0 + ? "running" + : settledCount === phaseMembers.length + ? "done" + : "pending"; + return { + index: phase.index, + title: phase.title, + members: phaseMembers, + state, + activeCount, + settledCount, + }; + }); + + const unphasedMembers = workflowMembers + .filter((member) => member.phaseIndex === null) + .toSorted((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); + + return { workflow, phases, unphasedMembers }; + }); + + let runningCount = 0; + let waitingCount = 0; + let idleCount = 0; + let settledCount = 0; + let totalTokens = 0; + for (const agent of source) { + if (agent.status === "running" || agent.status === "pending") runningCount += 1; + else if (agent.status === "waiting") waitingCount += 1; + else if (agent.status === "idle") idleCount += 1; + else settledCount += 1; + // Workflow coordinators aggregate member usage upstream in some providers; + // avoid double counting by only summing leaf agents when members exist. + if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) { + totalTokens += agent.usage?.totalTokens ?? 0; + } + } + + return { + workflows: workflowGroups, + directAgents: direct.toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)), + runningCount, + waitingCount, + idleCount, + settledCount, + totalTokens, + hasAgents: true, + liveCount: runningCount + waitingCount, + }; +} + +/** + * Members ordered by urgency for the capped inline workflow card: running and + * failed first, then waiting, then most recently updated. + */ +export function workflowCardMembers( + group: AgentPanelWorkflowGroup, + limit: number, +): { readonly visible: ReadonlyArray; readonly overflow: number } { + const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; + const urgency = (agent: RuntimeSubagent): number => { + if (agent.status === "failed") return 0; + if (agent.status === "running") return 1; + if (agent.status === "waiting") return 2; + return 3; + }; + const ordered = all.toSorted( + (a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt), + ); + return { + visible: ordered.slice(0, limit), + overflow: Math.max(0, ordered.length - limit), + }; +} + +/** Kinds the timeline should not render as generic rows (fold input only). */ +export function isSubagentActivityKind(kind: string): boolean { + return ( + kind === "task.started" || + kind === "task.progress" || + kind === "task.updated" || + kind === "task.completed" || + kind === "tool.progress" + ); +} + +/** + * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in + * the Agents surface, not the parent chat. Unattributed rows must stay. + */ +export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean { + if (typeof activity.payload !== "object" || activity.payload === null) { + return false; + } + const payload = activity.payload as Record; + return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; +} + +/** Timeline-bypassing synthesized rows (Codex children, workflow members). */ +export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean { + if (typeof activity.payload !== "object" || activity.payload === null) { + return false; + } + return (activity.payload as Record).timelineBypass === true; +} + +export function formatSubagentTokenCount(totalTokens: number): string { + if (totalTokens < 1000) { + return `${totalTokens}`; + } + if (totalTokens < 1_000_000) { + const value = totalTokens / 1000; + return `${value >= 100 ? Math.round(value) : value.toFixed(1)}k`; + } + return `${(totalTokens / 1_000_000).toFixed(1)}M`; +} diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..2790ab3f605 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -176,6 +176,7 @@ const ProviderRuntimeEventType = Schema.Literals([ "user-input.resolved", "task.started", "task.progress", + "task.updated", "task.completed", "hook.started", "hook.progress", @@ -226,6 +227,7 @@ const UserInputRequestedType = Schema.Literal("user-input.requested"); const UserInputResolvedType = Schema.Literal("user-input.resolved"); const TaskStartedType = Schema.Literal("task.started"); const TaskProgressType = Schema.Literal("task.progress"); +const TaskUpdatedType = Schema.Literal("task.updated"); const TaskCompletedType = Schema.Literal("task.completed"); const HookStartedType = Schema.Literal("hook.started"); const HookProgressType = Schema.Literal("hook.progress"); @@ -407,6 +409,13 @@ export const ItemLifecyclePayload = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyStringSchema), detail: Schema.optional(TrimmedNonEmptyStringSchema), data: Schema.optional(Schema.Unknown), + /** + * Owning agent when this item ran inside a subagent (resolved from the + * SDK's parent_tool_use_id). Clients re-home attributed items out of the + * main timeline and into the owning agent's Agents-surface row. + */ + agentId: Schema.optional(TrimmedNonEmptyStringSchema), + parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type; @@ -459,27 +468,127 @@ const UserInputResolvedPayload = Schema.Struct({ }); export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; +/** + * Typed per-task usage rollup. Field names match the orchestration-v2 subagent + * usage vocabulary (#4779) so the eventual migration is a rename, not a remap. + * Claude reports per-activation deltas; Codex reports cumulative totals — the + * merge strategy is provider-specific and lives in client-runtime. + */ +export const RuntimeTaskUsage = Schema.Struct({ + totalTokens: NonNegativeInt, + inputTokens: Schema.optional(NonNegativeInt), + cachedInputTokens: Schema.optional(NonNegativeInt), + outputTokens: Schema.optional(NonNegativeInt), + reasoningOutputTokens: Schema.optional(NonNegativeInt), + toolUses: Schema.optional(NonNegativeInt), + durationMs: Schema.optional(NonNegativeInt), +}); +export type RuntimeTaskUsage = typeof RuntimeTaskUsage.Type; + +export const TaskWorkflowPhase = Schema.Struct({ + index: NonNegativeInt, + title: TrimmedNonEmptyStringSchema, +}); +export type TaskWorkflowPhase = typeof TaskWorkflowPhase.Type; + +export const TaskRunHandles = Schema.Struct({ + runId: Schema.optional(TrimmedNonEmptyStringSchema), + scriptPath: Schema.optional(TrimmedNonEmptyStringSchema), + transcriptDir: Schema.optional(TrimmedNonEmptyStringSchema), + /** Only http/https URLs may be stored here — sanitized at the adapter. */ + sessionUrl: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type TaskRunHandles = typeof TaskRunHandles.Type; + +/** + * Optional agent-identity linkage carried on every task lifecycle payload. + * Repeated on progress and terminal rows (not just start) so client folds can + * reconstruct an agent even when its start row aged out of activity retention. + * All fields optional: old emitters and old rows decode unchanged. + */ +const taskAgentLinkageFields = { + title: Schema.optional(TrimmedNonEmptyStringSchema), + role: Schema.optional(TrimmedNonEmptyStringSchema), + model: Schema.optional(TrimmedNonEmptyStringSchema), + toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), + parentAgentId: Schema.optional(TrimmedNonEmptyStringSchema), + workflowName: Schema.optional(TrimmedNonEmptyStringSchema), + agentIndex: Schema.optional(NonNegativeInt), + phaseIndex: Schema.optional(NonNegativeInt), + phaseTitle: Schema.optional(TrimmedNonEmptyStringSchema), + phases: Schema.optional(Schema.Array(TaskWorkflowPhase)), + attempt: Schema.optional(NonNegativeInt), + runHandles: Schema.optional(TaskRunHandles), + outputFile: Schema.optional(TrimmedNonEmptyStringSchema), + /** Codex agent hierarchy path, e.g. "/root/marlow". */ + agentPath: Schema.optional(TrimmedNonEmptyStringSchema), + /** + * Set on provider-synthesized child-agent events (Codex) whose activity + * belongs in the Agents surface, never the parent timeline. + */ + timelineBypass: Schema.optional(Schema.Boolean), +} as const; + +export const TaskAgentLinkage = Schema.Struct(taskAgentLinkageFields); +export type TaskAgentLinkage = typeof TaskAgentLinkage.Type; + const TaskStartedPayload = Schema.Struct({ taskId: RuntimeTaskId, description: Schema.optional(TrimmedNonEmptyStringSchema), taskType: Schema.optional(TrimmedNonEmptyStringSchema), + ...taskAgentLinkageFields, }); export type TaskStartedPayload = typeof TaskStartedPayload.Type; +export const RuntimeTaskStatus = Schema.Literals([ + "pending", + "running", + "waiting", + "idle", + "completed", + "failed", + "cancelled", + "interrupted", +]); +export type RuntimeTaskStatus = typeof RuntimeTaskStatus.Type; + const TaskProgressPayload = Schema.Struct({ taskId: RuntimeTaskId, description: TrimmedNonEmptyStringSchema, summary: Schema.optional(TrimmedNonEmptyStringSchema), usage: Schema.optional(Schema.Unknown), + typedUsage: Schema.optional(RuntimeTaskUsage), lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), + /** Present on synthesized member/child progress rows that carry state. */ + status: Schema.optional(RuntimeTaskStatus), + error: Schema.optional(TrimmedNonEmptyStringSchema), + ...taskAgentLinkageFields, }); export type TaskProgressPayload = typeof TaskProgressPayload.Type; +/** + * Non-terminal status patch (from the Claude SDK's task_updated, which main + * previously dropped). killed→cancelled and paused→idle are mapped at the + * adapter so the wire only carries the shared vocabulary. + */ +const TaskUpdatedPayload = Schema.Struct({ + taskId: RuntimeTaskId, + status: Schema.optional(RuntimeTaskStatus), + description: Schema.optional(TrimmedNonEmptyStringSchema), + error: Schema.optional(TrimmedNonEmptyStringSchema), + endedAt: Schema.optional(IsoDateTime), + isBackgrounded: Schema.optional(Schema.Boolean), + ...taskAgentLinkageFields, +}); +export type TaskUpdatedPayload = typeof TaskUpdatedPayload.Type; + const TaskCompletedPayload = Schema.Struct({ taskId: RuntimeTaskId, status: Schema.Literals(["completed", "failed", "stopped"]), summary: Schema.optional(TrimmedNonEmptyStringSchema), usage: Schema.optional(Schema.Unknown), + typedUsage: Schema.optional(RuntimeTaskUsage), + ...taskAgentLinkageFields, }); export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; @@ -513,6 +622,9 @@ const ToolProgressPayload = Schema.Struct({ toolName: Schema.optional(TrimmedNonEmptyStringSchema), summary: Schema.optional(TrimmedNonEmptyStringSchema), elapsedSeconds: Schema.optional(Schema.Number), + /** Owning task/agent when the tool ran inside a subagent. */ + taskId: Schema.optional(RuntimeTaskId), + parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ToolProgressPayload = typeof ToolProgressPayload.Type; @@ -835,6 +947,13 @@ const ProviderRuntimeTaskProgressEvent = Schema.Struct({ }); export type ProviderRuntimeTaskProgressEvent = typeof ProviderRuntimeTaskProgressEvent.Type; +const ProviderRuntimeTaskUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskUpdatedType, + payload: TaskUpdatedPayload, +}); +export type ProviderRuntimeTaskUpdatedEvent = typeof ProviderRuntimeTaskUpdatedEvent.Type; + const ProviderRuntimeTaskCompletedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: TaskCompletedType, @@ -995,6 +1114,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeUserInputResolvedEvent, ProviderRuntimeTaskStartedEvent, ProviderRuntimeTaskProgressEvent, + ProviderRuntimeTaskUpdatedEvent, ProviderRuntimeTaskCompletedEvent, ProviderRuntimeHookStartedEvent, ProviderRuntimeHookProgressEvent, From 9be170a88333cafd34b39c1b9e2a168f79745008 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 2 Aug 2026 00:58:34 -0700 Subject: [PATCH 02/26] feat(web): agent spawn CTA row replaces live strip and inline workflow card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per live-test feedback the roster rendered three times at once (panel, card, strip). New rule: the Agents panel is the only roster. The chat gets one anchored CTA row per spawn batch (workflow run, or a turn's direct spawns): 'Kicked off N subagents · · N active · Σ tok — Open Agents'. Live status derives from the shared panel model at render time; the row freezes to past tense on settle. Strip and card components deleted. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 35 +---- .../src/components/chat/AgentsLiveStrip.tsx | 47 ------ .../src/components/chat/MessagesTimeline.tsx | 115 +++++++++++++++ .../src/components/chat/WorkflowRunCard.tsx | 137 ------------------ apps/web/src/session-logic.test.ts | 40 ++++- apps/web/src/session-logic.ts | 74 ++++++++-- 6 files changed, 220 insertions(+), 228 deletions(-) delete mode 100644 apps/web/src/components/chat/AgentsLiveStrip.tsx delete mode 100644 apps/web/src/components/chat/WorkflowRunCard.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf8dab82270..e54f4bdb824 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -140,8 +140,6 @@ import { } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; -import { AgentsLiveStrip } from "./chat/AgentsLiveStrip"; -import { WorkflowRunCard } from "./chat/WorkflowRunCard"; import { deriveAgentPanelModel, foldSubagentActivities, @@ -5818,6 +5816,8 @@ function ChatViewContent(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */} ) : null} - {agentPanelModel.workflows.some( - (group) => - group.workflow.status === "running" || - group.workflow.status === "pending" || - group.workflow.status === "waiting", - ) ? ( - // Interim mount: workflow run cards live above the composer - // until the virtualized timeline gains a card row kind - // (converges with orchestration-v2's V2LifecycleRow). -
- {agentPanelModel.workflows - .filter( - (group) => - group.workflow.status === "running" || - group.workflow.status === "pending" || - group.workflow.status === "waiting", - ) - .map((group) => ( - - ))} -
- ) : null} - {agentPanelModel.liveCount > 0 ? ( -
- -
- ) : null}
void }) { - if (model.liveCount === 0) { - return null; - } - - const runningPhase = model.workflows - .flatMap((group) => group.phases) - .find((phase) => phase.state === "running"); - const totalAgents = - model.runningCount + model.waitingCount + model.idleCount + model.settledCount; - - return ( - - ); -} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f4089e3fe4f..a78f642a5c4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -6,6 +6,14 @@ import { type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; +import { + emptyAgentPanelModel, + formatSubagentTokenCount, +} from "@t3tools/client-runtime/state/subagentRuntime"; + +const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); +const NOOP_OPEN_AGENTS = () => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, @@ -135,6 +143,8 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; + agentPanelModel: AgentPanelModel; + onOpenAgents: () => void; } interface TimelineRowActivityState { @@ -156,6 +166,8 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray void; isWorking: boolean; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; @@ -194,6 +206,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnStartedAt, + agentPanelModel = EMPTY_AGENT_PANEL_MODEL, + onOpenAgents = NOOP_OPEN_AGENTS, listRef, timelineEntries, latestTurn, @@ -430,6 +444,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + agentPanelModel, + onOpenAgents, }), [ timestampFormat, @@ -444,6 +460,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + agentPanelModel, + onOpenAgents, ], ); const activityState = useMemo( @@ -1927,9 +1945,106 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); +/** + * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn + * batch). Live status is derived from the shared agent panel model at render + * time — the row itself never re-renders a roster; the Agents panel is the + * only roster. Freezes to past tense when every member settles. Static dot, + * no animation. + */ +const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) { + const { workEntry } = props; + const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx); + const spawn = workEntry.agentSpawn; + if (!spawn) { + return null; + } + + const memberIds = new Set(spawn.agentTaskIds); + const workflowGroup = spawn.workflowId + ? agentPanelModel.workflows.find((group) => group.workflow.id === spawn.workflowId) + : undefined; + const agents = workflowGroup + ? [...workflowGroup.phases.flatMap((phase) => phase.members), ...workflowGroup.unphasedMembers] + : agentPanelModel.directAgents.filter((agent) => memberIds.has(agent.id)); + const agentCount = Math.max( + agents.length, + Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), + ); + + const running = agents.filter( + (agent) => agent.status === "running" || agent.status === "pending", + ).length; + const waiting = agents.filter((agent) => agent.status === "waiting").length; + const failed = agents.filter((agent) => agent.status === "failed").length; + const live = running + waiting > 0; + const totalTokens = agents.reduce( + (sum, agent) => sum + (agent.usage?.totalTokens ?? 0), + spawn.workflowId ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0, + ); + + const livePhase = workflowGroup?.phases.find((phase) => phase.state === "running"); + const workflowName = + workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; + + const dotClass = live + ? waiting > 0 + ? "bg-warning" + : "bg-info" + : failed > 0 + ? "bg-destructive" + : "bg-success"; + const lead = live + ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}` + : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`; + const status = live + ? livePhase + ? `${livePhase.title} · ${livePhase.activeCount} active` + : waiting > 0 + ? `${running} running · ${waiting} waiting` + : `${running} running` + : failed > 0 + ? `${failed} failed` + : "✓ completed"; + + return ( + + ); +}); + const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; +}) { + const { workEntry, workspaceRoot } = props; + // Before any hooks: spawn CTA rows render their own component. + if (workEntry.agentSpawn) { + return ; + } + return ; +}); + +const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { + workEntry: TimelineWorkEntry; + workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; const activity = use(TimelineRowActivityCtx); diff --git a/apps/web/src/components/chat/WorkflowRunCard.tsx b/apps/web/src/components/chat/WorkflowRunCard.tsx deleted file mode 100644 index aeb48cc8e45..00000000000 --- a/apps/web/src/components/chat/WorkflowRunCard.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Inline workflow run card for the chat timeline: one card per coordinator, - * replacing its generic task rows. Capped at eight member rows ordered by - * urgency (failed and running first); overflow routes to the Agents panel. - * The card is a derived, bounded view of the fold — the panel is the - * complete one. Static status visuals only. - */ -import type { AgentPanelWorkflowGroup } from "@t3tools/client-runtime/state/subagentRuntime"; -import { - formatSubagentTokenCount, - workflowCardMembers, -} from "@t3tools/client-runtime/state/subagentRuntime"; -import { Bot, ExternalLink } from "lucide-react"; - -import { cn } from "~/lib/utils"; - -const INLINE_MEMBER_LIMIT = 8; - -const MEMBER_DOT: Record = { - pending: "bg-muted-foreground/40", - running: "bg-info", - waiting: "bg-warning", - idle: "bg-info/50", - completed: "bg-success", - failed: "bg-destructive", - cancelled: "bg-muted-foreground/60", - interrupted: "bg-muted-foreground/60", -}; - -function workflowStatusChip(group: AgentPanelWorkflowGroup): { - label: string; - className: string; -} { - const status = group.workflow.status; - if (status === "failed") { - return { label: "Failed", className: "text-destructive-foreground border-destructive/40" }; - } - if (status === "completed") { - return { label: "Completed", className: "text-success-foreground border-success/40" }; - } - if (status === "cancelled" || status === "interrupted") { - return { label: "Stopped", className: "text-muted-foreground border-border" }; - } - return { label: "Running", className: "text-info-foreground border-info/40" }; -} - -export function WorkflowRunCard({ - group, - onOpenAgents, -}: { - group: AgentPanelWorkflowGroup; - onOpenAgents: () => void; -}) { - const { visible, overflow } = workflowCardMembers(group, INLINE_MEMBER_LIMIT); - const chip = workflowStatusChip(group); - const allMembers = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; - const settled = allMembers.filter( - (member) => - member.status === "completed" || - member.status === "failed" || - member.status === "cancelled" || - member.status === "interrupted", - ).length; - const totalTokens = allMembers.reduce( - (sum, member) => sum + (member.usage?.totalTokens ?? 0), - group.workflow.usage?.totalTokens ?? 0, - ); - const sessionUrl = group.workflow.runHandles?.sessionUrl; - - return ( -
-
- - - {group.workflow.workflowName ?? group.workflow.title} - - - {chip.label} - - - {settled}/{allMembers.length} agents · {formatSubagentTokenCount(totalTokens)} tok - -
- - {sessionUrl ? ( - - - Running in the cloud — open session - - ) : ( -
- {visible.map((member) => ( -
- - - - - - {member.title} - - {member.status === "failed" && member.error ? ( - {member.error} - ) : null} - - - {member.phaseTitle ? `${member.phaseTitle} · ` : ""} - {member.usage ? `${formatSubagentTokenCount(member.usage.totalTokens)} tok` : ""} - -
- ))} - {overflow > 0 ? ( - - ) : null} -
- )} -
- ); -} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 728eebc57d6..b77e17b4811 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1729,12 +1729,48 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { } const entries = deriveWorkLogEntries(activities); - const taskRows = entries.filter((entry) => entry.taskId !== undefined); - expect(taskRows).toHaveLength(5); + // A1 CTA design: all direct spawns in one turn collapse into ONE + // call-to-action row carrying the batch's agent ids. + const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined); + expect(spawnRows).toHaveLength(1); + expect(spawnRows[0]!.agentSpawn!.agentTaskIds).toHaveLength(5); + expect(spawnRows[0]!.agentSpawn!.workflowId).toBeNull(); // No agent-attributed tool rows leak into the main log. expect(entries.some((entry) => entry.sourceActivityKind?.startsWith("tool."))).toBe(false); }); + it("a workflow run and its members collapse into one CTA row keyed to the coordinator", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "task.progress", + summary: "coordinator", + tone: "info", + payload: { taskId: "wf-1", taskType: "local_workflow", workflowName: "math-check" }, + sequence: 1, + }), + makeActivity({ + kind: "task.progress", + summary: "member", + tone: "info", + payload: { taskId: "wf-1:wf:0", status: "running", parentAgentId: "wf-1" }, + sequence: 2, + }), + makeActivity({ + kind: "task.completed", + summary: "member done", + tone: "info", + payload: { taskId: "wf-1:wf:1", status: "completed", parentAgentId: "wf-1" }, + sequence: 3, + }), + ]); + const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined); + expect(spawnRows).toHaveLength(1); + expect(spawnRows[0]!.agentSpawn!.workflowId).toBe("wf-1"); + expect(spawnRows[0]!.agentSpawn!.agentTaskIds).toEqual( + expect.arrayContaining(["wf-1", "wf-1:wf:0", "wf-1:wf:1"]), + ); + }); + it("keeps unattributed tool rows (over-hiding loses the only signal)", () => { const entries = deriveWorkLogEntries([ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 49dbb4166a5..36fc58ac25f 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -82,12 +82,24 @@ export interface WorkLogEntry { taskId?: string; /** Agent role (subagent_type) for labeled timeline rows. */ agentRole?: string; + /** + * Present on agent-spawn CTA rows: one per workflow run or per-turn batch + * of direct spawns. The row renders as a call-to-action ("Kicked off N + * subagents") whose live status is derived from the agent panel model at + * render time; clicking opens the Agents panel. + */ + agentSpawn?: { + /** Workflow coordinator taskId, or null for a direct-spawn batch. */ + workflowId: string | null; + agentTaskIds: ReadonlyArray; + }; } interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; toolCallId?: string; + isWorkflowCoordinator?: boolean; } export interface PendingApproval { @@ -793,6 +805,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (isTaskActivity && typeof payload?.role === "string" && payload.role.length > 0) { entry.agentRole = payload.role; } + if ( + isTaskActivity && + (payload?.taskType === "local_workflow" || + (typeof payload?.workflowName === "string" && payload.workflowName.length > 0)) + ) { + entry.isWorkflowCoordinator = true; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -800,26 +819,63 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo return entry; } +/** + * Spawn-group key for a subagent lifecycle row. Workflow members and their + * coordinator share the coordinator's group; direct spawns batch per turn. + * One CTA row per group (A1 design): "Kicked off N subagents". + */ +function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { + const taskId = entry.taskId ?? ""; + const workflowSlot = taskId.indexOf(":wf:"); + if (workflowSlot !== -1) { + return `wf:${taskId.slice(0, workflowSlot)}`; + } + if (entry.agentSpawn?.workflowId) { + return `wf:${entry.agentSpawn.workflowId}`; + } + if (entry.isWorkflowCoordinator) { + return `wf:${taskId}`; + } + return `direct:${entry.turnId ?? "no-turn"}`; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; - // Subagent rows collapse by identity, not adjacency: with concurrent - // agents, one agent's progress rows interleave with another's, and each - // agent still gets exactly one row (quiet-timeline guarantee). - const taskRowIndex = new Map(); + // Subagent rows collapse by spawn group, not adjacency: a workflow run (or + // a turn's batch of direct spawns) is ONE narrative event in the chat — a + // CTA row that opens the Agents panel — no matter how many agents it + // contains or how their progress rows interleave (quiet-timeline + // guarantee). + const spawnRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && (entry.activityKind === "task.progress" || entry.activityKind === "task.completed"); - if (isTaskRow) { - const existingIndex = taskRowIndex.get(entry.taskId!); + if (isTaskRow && entry.taskId !== undefined) { + const groupKey = agentSpawnGroupKey(entry); + const workflowId = groupKey.startsWith("wf:") ? groupKey.slice(3) : null; + const existingIndex = spawnRowIndex.get(groupKey); if (existingIndex !== undefined) { - collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + const existing = collapsed[existingIndex]!; + const agentTaskIds = existing.agentSpawn?.agentTaskIds.includes(entry.taskId) + ? existing.agentSpawn.agentTaskIds + : [...(existing.agentSpawn?.agentTaskIds ?? []), entry.taskId]; + collapsed[existingIndex] = { + ...mergeDerivedWorkLogEntries(existing, entry), + // The CTA row keeps the group identity, not the last agent's. + ...(existing.taskId !== undefined ? { taskId: existing.taskId } : {}), + label: existing.label, + agentSpawn: { workflowId, agentTaskIds }, + }; continue; } - taskRowIndex.set(entry.taskId!, collapsed.length); - collapsed.push(entry); + spawnRowIndex.set(groupKey, collapsed.length); + collapsed.push({ + ...entry, + agentSpawn: { workflowId, agentTaskIds: [entry.taskId] }, + }); continue; } const previous = collapsed.at(-1); From 16cdc33f7c5108b4efea8eaa8b0937bd413be61a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 2 Aug 2026 01:08:48 -0700 Subject: [PATCH 03/26] fix(web): honest workflow status, shell exclusion, flat panel rows Live-test round 2 fixes: - Shells/monitors/plan tasks no longer masquerade as subagents: taskType rides on every task payload (adapter linkage + ingestion allowlist) and the fold excludes non-agent task types from the roster. 'Run 12s stall' background shells stay in the ordinary work log. - The spawn CTA no longer says completed while a workflow is mid-flight: for workflow batches the coordinator's own terminal state is authoritative (dynamic spawns can make the known-member list momentarily all-settled). - Agents panel rows are flat status lines: the per-agent unfold (recent tool-call feed) is gone. The only expansion is run-granularity: settled workflow runs collapse to one summary line under 'Earlier', click to list members. Live workflows and direct spawns sort first in bordered sections with settled/total counts. Co-Authored-By: Claude Fable 5 --- .../ActivityPayloadProjection.test.ts | 2 +- .../Layers/ProviderRuntimeIngestion.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 1 + apps/web/src/components/AgentsPanel.tsx | 199 ++++++++++++------ .../src/components/chat/MessagesTimeline.tsx | 12 +- apps/web/src/session-logic.ts | 7 + .../src/state/subagentRuntime.test.ts | 21 +- .../src/state/subagentRuntime.ts | 29 ++- packages/contracts/src/providerRuntime.ts | 4 +- 9 files changed, 209 insertions(+), 67 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index cf498124ea4..b0f74d66851 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import type { OrchestrationThreadActivity } from "@t3tools/contracts"; import { projectActivityPayload } from "./ActivityPayloadProjection.ts"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a58e0312c56..78f55e84b0d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -315,6 +315,7 @@ function requestKindFromCanonicalRequestType( function taskLinkageActivityFields(payload: Record): Record { const fields: Record = {}; for (const key of [ + "taskType", "title", "role", "model", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5a337cd1fe2..27db66936f9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -950,6 +950,7 @@ function taskLinkageFor( return {}; } return { + ...(agent.taskType ? { taskType: agent.taskType } : {}), ...(agent.description ? { title: agent.description } : {}), ...(agent.subagentType ? { role: agent.subagentType } : {}), ...(agent.toolUseId ? { toolUseId: agent.toolUseId } : {}), diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index f0099bbc4b5..833f7416622 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -1,10 +1,17 @@ /** - * Agents right-panel surface: the fleet view over the native subagent fold. + * Agents right-panel surface: the fleet view over the native subagent fold, + * and the ONLY place the roster renders (the chat carries one CTA row per + * spawn batch). * - * Grouping: one section per workflow (phase headers with active/settled - * counts), then a Direct spawns section. Rows expand in place to the - * recent-activity ring. All status dots are static (no continuous - * animation); elapsed time uses the WorkingTimer DOM-write pattern. + * Visualization rules (from live-test feedback): + * - Live work first: running workflows and direct spawns sort above settled. + * - Rows are flat status lines — no expansion, no per-agent tool feeds. The + * row answers "who / what phase / how much"; anything deeper is a future + * drill-in, not an unfold. + * - A settled workflow run collapses to a single summary line; click it to + * show its member list inline (the one allowed toggle — run granularity, + * not agent granularity). + * - Static status dots, DOM-write elapsed timers, plain token counters. */ import type { AgentPanelModel, @@ -12,7 +19,7 @@ import type { RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import { formatSubagentTokenCount } from "@t3tools/client-runtime/state/subagentRuntime"; -import { Bot, ChevronDown, ChevronRight, Check } from "lucide-react"; +import { Bot, Check, ChevronDown, ChevronRight } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; @@ -94,8 +101,8 @@ function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { } /** - * Status-dependent activity line. Live cards lead with what is happening now; - * settled cards lead with the outcome. Errors are the only inline previews on + * Status-dependent activity line. Live rows lead with what is happening now; + * settled rows lead with the outcome. Errors are the only inline previews on * failed rows because they explain a red row at a glance. */ function agentActivityText(agent: RuntimeSubagent): string | null { @@ -119,20 +126,14 @@ function agentActivityText(agent: RuntimeSubagent): string | null { ); } +/** Flat, non-interactive agent status line. No unfold. */ function AgentRow({ agent }: { agent: RuntimeSubagent }) { - const [expanded, setExpanded] = useState(false); const visuals = STATUS_VISUALS[agent.status]; const activity = agentActivityText(agent); - const hasFeed = agent.recentActivity.length > 0; return ( -
- - {expanded && hasFeed ? ( -
- {agent.recentActivity.toReversed().map((entry) => ( -
- - {entry.at.slice(11, 19)} - - {entry.summary} -
- ))} -
- ) : null} +
); } +function workflowIsLive(group: AgentPanelWorkflowGroup): boolean { + const status = group.workflow.status; + return ( + status !== "completed" && + status !== "failed" && + status !== "cancelled" && + status !== "interrupted" + ); +} + +function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray { + return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; +} + function PhaseHeader({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { return (
); } -function WorkflowGroupSection({ group }: { group: AgentPanelWorkflowGroup }) { +/** Live workflow: full phase tree. */ +function LiveWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { + const members = workflowMembers(group); + const settled = members.filter( + (member) => + member.status === "completed" || + member.status === "failed" || + member.status === "cancelled" || + member.status === "interrupted", + ).length; return ( -
-
- Workflow · {group.workflow.workflowName ?? group.workflow.title} - {group.workflow.runHandles?.scriptPath ? ( - - {"{}"} script - - ) : null} +
+
+ + {group.workflow.workflowName ?? group.workflow.title} + + {settled}/{members.length} settled +
{group.phases.map((phase) => (
@@ -264,6 +260,57 @@ function WorkflowGroupSection({ group }: { group: AgentPanelWorkflowGroup }) { ); } +/** + * Settled workflow: one summary line. Click toggles the member list — the + * only expansion in the panel, at run granularity. + */ +function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { + const [open, setOpen] = useState(false); + const members = workflowMembers(group); + const failed = members.filter((member) => member.status === "failed").length; + const totalTokens = members.reduce( + (sum, member) => sum + (member.usage?.totalTokens ?? 0), + group.workflow.usage?.totalTokens ?? 0, + ); + const elapsed = + group.workflow.startedAt && group.workflow.completedAt + ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) + : null; + return ( +
+ + {open ? ( +
+ {members.map((member) => ( + + ))} +
+ ) : null} +
+ ); +} + export function AgentsPanel({ model }: { model: AgentPanelModel }) { if (!model.hasAgents) { return ( @@ -278,19 +325,49 @@ export function AgentsPanel({ model }: { model: AgentPanelModel }) { ); } + const liveWorkflows = model.workflows.filter(workflowIsLive); + const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group)); + const liveDirect = model.directAgents.filter( + (agent) => + agent.status === "running" || + agent.status === "pending" || + agent.status === "waiting" || + agent.status === "idle", + ); + const settledDirect = model.directAgents.filter( + (agent) => + agent.status !== "running" && + agent.status !== "pending" && + agent.status !== "waiting" && + agent.status !== "idle", + ); + return (
-
- {model.workflows.map((group) => ( - +
+ {liveWorkflows.map((group) => ( + ))} - {model.directAgents.length > 0 ? ( + {liveDirect.length > 0 ? (
-
+
Direct spawns
- {model.directAgents.map((agent) => ( + {liveDirect.map((agent) => ( + + ))} +
+ ) : null} + {settledWorkflows.length > 0 || settledDirect.length > 0 ? ( +
+
+ Earlier +
+ {settledWorkflows.map((group) => ( + + ))} + {settledDirect.map((agent) => ( ))}
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a78f642a5c4..9e72fb9f6a9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1977,7 +1977,17 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time ).length; const waiting = agents.filter((agent) => agent.status === "waiting").length; const failed = agents.filter((agent) => agent.status === "failed").length; - const live = running + waiting > 0; + // The coordinator's own status is authoritative for workflows: dynamic + // spawns mean the member list can be momentarily all-settled while the + // run is still mid-flight (the "completed" lie from live testing). A + // workflow is live until the coordinator itself reaches a terminal state. + const coordinatorStatus = workflowGroup?.workflow.status; + const coordinatorSettled = + coordinatorStatus === "completed" || + coordinatorStatus === "failed" || + coordinatorStatus === "cancelled" || + coordinatorStatus === "interrupted"; + const live = workflowGroup !== undefined ? !coordinatorSettled : running + waiting > 0; const totalTokens = agents.reduce( (sum, agent) => sum + (agent.usage?.totalTokens ?? 0), spawn.workflowId ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 36fc58ac25f..067e3b9f5cb 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,5 +1,6 @@ import * as Option from "effect/Option"; import * as Arr from "effect/Array"; +import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; import { ApprovalRequestId, isToolLifecycleItemType, @@ -100,6 +101,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry { collapseKey?: string; toolCallId?: string; isWorkflowCoordinator?: boolean; + /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn CTAs. */ + isBackgroundTask?: boolean; } export interface PendingApproval { @@ -812,6 +815,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ) { entry.isWorkflowCoordinator = true; } + if (isTaskActivity && payload && isBackgroundTaskActivity(payload)) { + entry.isBackgroundTask = true; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -852,6 +858,7 @@ function collapseDerivedWorkLogEntries( for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && + !entry.isBackgroundTask && (entry.activityKind === "task.progress" || entry.activityKind === "task.completed"); if (isTaskRow && entry.taskId !== undefined) { const groupKey = agentSpawnGroupKey(entry); diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index 89c77506ae7..4dbd760abef 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import type { OrchestrationThreadActivity } from "@t3tools/contracts"; import { deriveAgentPanelModel, @@ -439,3 +439,22 @@ describe("formatSubagentTokenCount", () => { expect(formatSubagentTokenCount(1_400_000)).toBe("1.4M"); }); }); + +describe("background task exclusion", () => { + it("shells and monitors never join the roster (from any lifecycle row)", () => { + const agents = fold([ + activity("task.started", { taskId: "shell-1", taskType: "shell", title: "Run 12s stall" }), + activity("task.progress", { taskId: "shell-2", taskType: "shell", title: "Run stall" }), + activity("task.completed", { taskId: "mon-1", taskType: "monitor", status: "completed" }), + activity("task.started", { taskId: "agent-1", taskType: "subagent", title: "Real agent" }), + ]); + expect(agents.map((agent) => agent.id)).toEqual(["agent-1"]); + }); + + it("rows without a taskType stay in the roster (workflow members, Codex children)", () => { + const agents = fold([ + activity("task.progress", { taskId: "wf-1:wf:0", status: "running", parentAgentId: "wf-1" }), + ]); + expect(agents).toHaveLength(1); + }); +}); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 8562e73c90a..803a615af6b 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -105,6 +105,26 @@ const RECENT_ACTIVITY_LIMIT = 6; const SUMMARY_CHAR_LIMIT = 180; const ROSTER_LIMIT = 100; +/** + * SDK task types that are agents. Everything else (shell, monitor, plan, + * unknown-but-typed) is background work and stays out of the roster. Rows + * with no taskType at all are kept: workflow members and Codex children are + * synthesized without one, and excluding them would empty the panel. + */ +const AGENT_TASK_TYPES: ReadonlySet = new Set([ + "subagent", + "agent", + "local_workflow", + "remote_agent", + "workflow", +]); + +/** True when this activity's payload describes a non-agent background task. */ +export function isBackgroundTaskActivity(payload: Record): boolean { + const taskType = typeof payload.taskType === "string" ? payload.taskType : undefined; + return taskType !== undefined && !AGENT_TASK_TYPES.has(taskType); +} + function bounded(value: string): string { return value.length <= SUMMARY_CHAR_LIMIT ? value : `${value.slice(0, SUMMARY_CHAR_LIMIT - 1)}…`; } @@ -440,9 +460,11 @@ export function foldSubagentActivities( case "task.started": { const taskId = asString(payload.taskId); if (!taskId) break; - // Plan-mode "tasks" and other ambient work are not agents. + // Only real agents join the roster. Shells, monitors, and plan-mode + // tasks are background work — they render in the ordinary work log, + // not the Agents surface (a "Run 12s stall" shell is not a subagent). const taskType = asString(payload.taskType); - if (taskType === "plan") break; + if (taskType !== undefined && !AGENT_TASK_TYPES.has(taskType)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); // Order-robustness: a start row arriving after a terminal state is a @@ -464,6 +486,7 @@ export function foldSubagentActivities( case "task.progress": { const taskId = asString(payload.taskId); if (!taskId) break; + if (isBackgroundTaskActivity(payload)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); if (agent.activationCount === 0) agent.activationCount = 1; @@ -494,6 +517,7 @@ export function foldSubagentActivities( case "task.updated": { const taskId = asString(payload.taskId); if (!taskId) break; + if (isBackgroundTaskActivity(payload)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); const status = asRuntimeStatus(payload.status); @@ -510,6 +534,7 @@ export function foldSubagentActivities( case "task.completed": { const taskId = asString(payload.taskId); if (!taskId) break; + if (isBackgroundTaskActivity(payload)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); if (agent.activationCount === 0) agent.activationCount = 1; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 2790ab3f605..d70e8d68d50 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -507,6 +507,9 @@ export type TaskRunHandles = typeof TaskRunHandles.Type; * All fields optional: old emitters and old rows decode unchanged. */ const taskAgentLinkageFields = { + /** SDK task_type (subagent/shell/monitor/local_workflow/…), repeated on + * every row so folds can classify without the start row. */ + taskType: Schema.optional(TrimmedNonEmptyStringSchema), title: Schema.optional(TrimmedNonEmptyStringSchema), role: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), @@ -535,7 +538,6 @@ export type TaskAgentLinkage = typeof TaskAgentLinkage.Type; const TaskStartedPayload = Schema.Struct({ taskId: RuntimeTaskId, description: Schema.optional(TrimmedNonEmptyStringSchema), - taskType: Schema.optional(TrimmedNonEmptyStringSchema), ...taskAgentLinkageFields, }); export type TaskStartedPayload = typeof TaskStartedPayload.Type; From 4622c3ab354f9db2f6879c5e0824df014420887c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 2 Aug 2026 01:17:28 -0700 Subject: [PATCH 04/26] fix(web): spawn CTA rows survive turn folding and work-group overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rerun workflows looked invisible: the launching turn settles in seconds ('Worked for 8.9s') and turn-folding collapsed all its work entries — including the spawn CTA — while the fleet runs on in the background. CTA rows are now exempt from turn folds and pinned outside the '+N tool calls' overflow toggle, so a live run is always visible at its spawn point. Each rerun gets its own CTA row (grouping keys on the coordinator id, which is unique per run). Co-Authored-By: Claude Fable 5 --- .../components/chat/MessagesTimeline.logic.ts | 48 ++++++++++++++----- apps/web/src/session-logic.test.ts | 34 +++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3227bac2413..55c09cc1ab3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -352,9 +352,16 @@ function deriveTurnFolds(input: { } const hiddenEntryIds = new Set(); for (const entry of group.entries) { - if (entry.id !== group.terminalEntry?.id) { - hiddenEntryIds.add(entry.id); + if (entry.id === group.terminalEntry?.id) { + continue; } + // Agent-spawn CTA rows never fold: workflows outlive their launching + // turn (dynamic spawns, background execution), and folding the CTA + // when the turn settles makes a still-running fleet invisible. + if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) { + continue; + } + hiddenEntryIds.add(entry.id); } if (hiddenEntryIds.size === 0) { continue; @@ -489,8 +496,19 @@ export function deriveMessagesTimelineRows(input: { } else { const groupId = `work-group:${timelineEntry.id}`; const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; - const hiddenEntries = visibleGroupedEntries.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); - const visibleEntries = visibleGroupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES); + // Agent-spawn CTA rows are always visible: a running fleet must + // never hide behind a "+N tool calls" toggle. + const overflowCandidates = visibleGroupedEntries.filter( + (entry) => entry.agentSpawn === undefined, + ); + const pinnedSpawnEntries = visibleGroupedEntries.filter( + (entry) => entry.agentSpawn !== undefined, + ); + const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); + const visibleEntries = [ + ...pinnedSpawnEntries, + ...overflowCandidates.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES), + ]; const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries; for (const workEntry of renderedEntries) { @@ -502,15 +520,19 @@ export function deriveMessagesTimelineRows(input: { }); } - nextRows.push({ - kind: "work-toggle", - id: `work-toggle:${timelineEntry.id}`, - createdAt: timelineEntry.createdAt, - groupId, - hiddenCount: hiddenEntries.length, - expanded, - onlyToolEntries: visibleGroupedEntries.every((entry) => workLogEntryIsToolLike(entry)), - }); + if (hiddenEntries.length > 0) { + nextRows.push({ + kind: "work-toggle", + id: `work-toggle:${timelineEntry.id}`, + createdAt: timelineEntry.createdAt, + groupId, + hiddenCount: hiddenEntries.length, + expanded, + onlyToolEntries: visibleGroupedEntries.every((entry) => + workLogEntryIsToolLike(entry), + ), + }); + } } } index = cursor - 1; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index b77e17b4811..8f5b72e86ad 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1812,3 +1812,37 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { expect(entries).toHaveLength(0); }); }); + +describe("rerun workflows", () => { + it("each workflow run gets its own CTA row (distinct coordinator ids)", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "task.progress", + summary: "run 1", + tone: "info", + payload: { taskId: "wf-run1", taskType: "local_workflow", workflowName: "math-check" }, + turnId: "turn-1", + sequence: 1, + }), + makeActivity({ + kind: "task.completed", + summary: "run 1 done", + tone: "info", + payload: { taskId: "wf-run1", status: "completed", taskType: "local_workflow" }, + turnId: "turn-1", + sequence: 2, + }), + makeActivity({ + kind: "task.progress", + summary: "run 2", + tone: "info", + payload: { taskId: "wf-run2", taskType: "local_workflow", workflowName: "math-check" }, + turnId: "turn-2", + sequence: 3, + }), + ]); + const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined); + expect(spawnRows.map((row) => row.agentSpawn!.workflowId)).toEqual(["wf-run1", "wf-run2"]); + expect(spawnRows.map((row) => row.turnId)).toEqual(["turn-1", "turn-2"]); + }); +}); From 3ee805444ba4afdf68f1c261646ee69e44251e83 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 2 Aug 2026 02:04:05 -0700 Subject: [PATCH 05/26] fix(web): all in-flight subagent states present as Working Live-test finding: statuses drifted (waiting/stalled agents alarming or reading wrong) while fleets ran. Adopts the monitoring-pill rule from the PR-monitoring design: one steady in-flight presentation. - Panel and CTA: pending/running/waiting all render as Working (sky, no amber); detail stays in the activity sub-line; footer shows one working count. Only settled states differentiate (completed/failed/stopped). - Fold: when a workflow coordinator settles, members that never received their own terminal row cascade to the coordinator's outcome (completed, or interrupted on failure) instead of reading as working forever. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/AgentsPanel.tsx | 33 +++++++++-------- .../src/components/chat/MessagesTimeline.tsx | 21 +++++------ .../src/state/subagentRuntime.test.ts | 36 +++++++++++++++++++ .../src/state/subagentRuntime.ts | 22 ++++++++++++ 4 files changed, 83 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 833f7416622..2e3e357cf48 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -25,10 +25,16 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { ScrollArea } from "~/components/ui/scroll-area"; +/** + * In-flight states all present as Working (one steady state, per the + * monitoring-pill design: detail belongs in the activity sub-line, and a + * stalled/waiting/queued subagent is still the fleet doing its job, not a + * user problem). Only settled states differentiate. + */ const STATUS_VISUALS: Record = { - pending: { dotClass: "bg-muted-foreground/40", label: "Queued" }, - running: { dotClass: "bg-info", label: "Running" }, - waiting: { dotClass: "bg-warning", label: "Waiting" }, + pending: { dotClass: "bg-info", label: "Working" }, + running: { dotClass: "bg-info", label: "Working" }, + waiting: { dotClass: "bg-info", label: "Working" }, idle: { dotClass: "bg-info/50", label: "Idle · resumable" }, completed: { dotClass: "bg-success", label: "Completed" }, failed: { dotClass: "bg-destructive", label: "Failed" }, @@ -106,10 +112,8 @@ function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { * failed rows because they explain a red row at a glance. */ function agentActivityText(agent: RuntimeSubagent): string | null { - const live = agent.status === "running" || agent.status === "pending"; - if (agent.status === "waiting") { - return "Waiting on approval or input"; - } + const live = + agent.status === "running" || agent.status === "pending" || agent.status === "waiting"; if (live) { return ( agent.progress ?? @@ -156,11 +160,7 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { {activity} @@ -376,11 +376,10 @@ export function AgentsPanel({ model }: { model: AgentPanelModel }) {