From f49c1a97cadb00290e31062a802691e9efe187ef Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 20:22:15 -0700 Subject: [PATCH 01/17] feat(contracts): thread agent snapshot schemas for subagent observability ThreadAgentSnapshot + agent.snapshot activity payload, typed task usage, task.updated runtime event, and agent-linkage fields on task payloads. Includes the reviewed HTML plans. Co-Authored-By: Claude Fable 5 --- .../plans/subagent-observability-spec.html | 1235 ++++++++++++++ .../plans/subagent-observability-ui.html | 1424 +++++++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/providerRuntime.ts | 78 +- packages/contracts/src/threadAgents.ts | 138 ++ 5 files changed, 2874 insertions(+), 2 deletions(-) create mode 100644 docs/project/plans/subagent-observability-spec.html create mode 100644 docs/project/plans/subagent-observability-ui.html create mode 100644 packages/contracts/src/threadAgents.ts diff --git a/docs/project/plans/subagent-observability-spec.html b/docs/project/plans/subagent-observability-spec.html new file mode 100644 index 00000000000..650c54223f5 --- /dev/null +++ b/docs/project/plans/subagent-observability-spec.html @@ -0,0 +1,1235 @@ + + + + + + Subagent Observability — Audit & Tech Spec + + + +
+

+ T3 Code · Plan 1 of 2 · 2026-07-20 · rev 3 (minimal transport) · companion: + UI mockups +

+

Subagent Observability: Audit & Tech Spec

+

+ Expose Codex v2 subagents and Claude Code subagents + workflows in a live T3 panel: who + is running, what they're doing, token burn, elapsed time, and a drill-in. One generic + interface; both providers plug into it. +

+ +
+ Rev 2 — adversarially reviewed by a Fable subagent and gpt-5.6-sol (codex), + both verifying claims against source. Material corrections: (1) Codex + v2 does not emit the rich collabAgentToolCall spawn items — + that's v1; the v2 tracker is now keyed on subAgentActivity + child + thread/started metadata, and T3's child-thread suppression turns out not to + engage for v2 at all. (2) Codex agents are reusable identities, so the + snapshot now separates identity from activations and adds an idle state. + (3) Persistence is now material-transitions-only (1s ticks stay in + memory) because orchestration events are append-only and replay reads are global. Full + review notes: /tmp/subagent-audit/review/.

+ Rev 3 — minimal transport. The bespoke stream event, subscriber gating, + projection table, migration, and capability flag are all deleted: the roster now ships as + one agent.snapshot activity kind through the existing + append/persist/replay channel, latest-wins on the client — the exact pattern the + context-window meter already uses. Server delta shrinks to: contract types, two adapters, + one reducer in ingestion. Rev 2's bespoke design remains in this doc's git history as the + graduation path if fleets outgrow the activity channel. +
+ + + +

1 · Audit: Codex v2

+

+ Sources: codex-rs @ bb947e8e (2026-07-13); T3's generated schema pin 678157ac. Initial audit + corrected by adversarial review — v1/v2 item emission re-verified handler by handler. +

+ +

+ The core fact: every v2 subagent is a full app-server thread. There are no + dedicated subagent RPCs — spawn/send/wait are model tools, not client methods. The + client observes subagents as ordinary threads plus parent-side thread items. +

+ +
    +
  • + v1 vs v2. features.multi_agent (v1, stable) vs + features.multi_agent_v2 (under development, default off) — but the + model catalog forces v2 for gpt-5.6-sol and gpt-5.6-terra (models.json: multi_agent_version: "v2"), so on the models we care about it's on with zero config. v2 has no depth limit; + concurrency defaults to 4 (max_concurrent_threads_per_session). +
  • +
  • + Identity & lineage arrive on the standard + thread/started notification. The Thread object carries + parentThreadId, shared sessionId, + agentNickname (random, e.g. "Marlow"), agentRole (agent_type: + explorer/awaiter/custom), and + source.subAgent.threadSpawn.{depth, agentPath} with hierarchical paths like + /root/researcher. + Clients are auto-subscribed to child-thread events — no subscribe call needed. +
  • +
  • + Parent-side items — v1 and v2 emit differently (this is where the first + draft was wrong): +
      +
    • + v1 emits rich collabAgentToolCall items for + spawn/send/resume/close/wait, carrying receiverThreadIds, + prompt, model, reasoningEffort, and + agentsStates (child thread id → {status, message}). +
    • +
    • + v2 emits + subAgentActivity {kind: started|interacted|interrupted, agentThreadId, + agentPath} + on spawn/message/interrupt (multi_agents_v2/spawn.rs:142 emits + only this — verified, no CollabAgentToolCall anywhere in the v2 + spawn path). The only v2 collabAgentToolCall emitter is + wait, and it ships with empty + receiverThreadIds / + agentsStates (multi_agents_v2/wait.rs:85,90). + Model/prompt/agentsStates are not on v2 parent items. +
    • +
    + Consequence: under v2, per-agent status and model must come from the + child thread's own stream (thread/status/changed, + turn/started|completed, thread/settings/updated), not from + parent items. +
  • +
  • + Per-agent token burn is free: + thread/tokenUsage/updated {threadId, turnId, tokenUsage: {total, last, + modelContextWindow}} + fires per child thread with input/cached/output/reasoning breakdowns. No server-side tree + rollup — client sums. +
  • +
  • + Agents are reusable identities, not one-shot tasks. + send_message/followup_task re-target an existing child thread + and trigger new turns on it (interaction events at event_mapping.rs:133-178). + A child turn/completed means "this run finished", not "this agent is done" — + the agent sits idle and resumable. +
  • +
  • + Reference consumer: the Codex TUI builds its /agent UI from + notifications alone — it caches child threads from + subAgentActivity/thread/started, buffers the last ~6 item + summaries per child, labels agents "{nickname} [{role}]". +
  • +
  • + Not exposed: the live AgentRegistry (model-facing + list_agents only), tree token rollups, per-agent "current activity" endpoint. + thread/list?parentThreadId/ancestorThreadId subtree filters are + experimental-gated and newer than T3's schema pin. +
  • +
+ +
+ T3 schema verdict: the vendored effect-codex-app-server pin + already has everything needed — subAgentActivity, + collabAgentToolCall, + Thread.parentThreadId/agentNickname/agentRole + + source.subAgent.threadSpawn, thread/tokenUsage/updated. No + regeneration required to ship. +
+ +
+ T3's child-thread suppression does not engage for v2 — child chatter behavior today is + unverified. + CodexSessionRuntime.rememberCollabReceiverTurns learns child threads only from + collabAgentToolCall.receiverThreadIds; under v2 those are empty, so the + collabReceiverTurns map never populates and the suppression list is inert. What + v2 child-thread notifications actually do to the parent timeline today (leak in? get dropped + elsewhere?) must be established with a live v2 wire probe — this is the + entry gate for the Codex slice (§8). The suppression narrative in the first draft applied + only to v1 models (e.g. gpt-5.6-luna). +
+ +

2 · Audit: Claude Agent SDK

+

+ Sources: @anthropic-ai/claude-agent-sdk 0.3.170 type declarations, CLI 2.1.214 binary, and a + live stream-json wire probe (real subagent spawn captured). All SDK-type claims + below re-verified by both reviewers. +

+ +

+ The core fact: a complete task lifecycle already streams over stdio, keyed + by task_id + tool_use_id, covering subagents, background shells, + monitors, and workflows uniformly: +

+ +
task_started      {task_id, tool_use_id, description, subagent_type?, task_type?, workflow_name?, prompt?}
+task_progress     {task_id, description, usage:{total_tokens, tool_uses, duration_ms}, last_tool_name?, summary?}
+task_updated      {task_id, patch:{status: pending|running|completed|failed|killed|paused, end_time?, is_backgrounded?, error?}}
+task_notification {task_id, status: completed|failed|stopped, summary, usage?, output_file}
+ +
    +
  • + Subagent messages arrive on the main stream tagged + parent_tool_use_id + subagent_type + + task_description. By default only tool_use/tool_result blocks are forwarded; + forwardSubagentText: true forwards the full nested transcript. +
  • +
  • + "What it's doing right now": last_tool_name on every + progress tick and tool_progress heartbeats (tool_name, elapsed_time_seconds, parent_tool_use_id, task_id) are free. agentProgressSummaries: true additionally forks each running + subagent every ~30s for an AI-written present-tense summary in + task_progress.summaryreal recurring model calls (SDK default is + off), so this is a per-instance setting, not a default (§8). +
  • +
  • + Workflows: task_started carries + task_type:'local_workflow' + workflow_name; + task_progress carries an undeclared-but-real + workflow_progress array (wire-probe confirmed) with + workflow_phase {index, title} and + workflow_agent {label, phaseIndex, phaseTitle, agentId, model, state, startedAt, + attempt, lastToolName, promptPreview, resultPreview, tokens, toolCalls, error} + entries. Not in sdk.d.ts; consume defensively behind a schema guard. + Per-agent full results are files-only (journal.jsonl, + agent-<id>.jsonl). +
  • +
  • + Usage granularity: per task (total_tokens only, no USD), per + message (if forwarded), per model + costUSD on the turn result.modelUsage, + session-wide via an explicitly-unstable control request. +
  • +
  • + Rosters: the typed background_tasks control request returns + the live task list (BackgroundTaskSummary[]) — this is the stable + roster/reconciliation source. A background_tasks_changed stream event was + also observed on the wire but is undeclared and its literal doesn't appear in the CLI + binary strings — treat as bonus signal only, never a dependency. +
  • +
  • + Gaps needing upstream: no model field on task events, no start_time on + task_started (use receive time), no per-subagent USD, + workflow_progress untyped. +
  • +
+ +
+ Today T3's ClaudeAdapter maps the lifecycle but strips the linkage. + task.started/progress/completed runtime events exist and are persisted, but + subagent_type, tool_use_id, workflow_name, + prompt, output_file are dropped; task_updated falls + into the unknown-subtype warning path (status transitions lost); + parent_tool_use_id traffic is filtered out of usage accounting. Raw payloads + survive in raw.payload on the ephemeral runtime events (NDJSON logs) — + recoverable at the adapter, but not from the durable store, so pre-feature + history cannot be backfilled (§5.5). +
+ +

3 · Audit: T3's existing pipeline

+ +
+ + + + + + + + + Codex app-server + Claude SDK + (stdio) + + + Adapters + normalize + + + ProviderRuntimeEventV2 + + task.* · item.* · turn.* + + + PubSub (in-memory) + + + + Ingestion + + lossy → activities + + + SQLite + projections + + + + WS subscribe + + snapshot + deltas + + + → atoms → React + + + + + + + + + + the gap: rich runtime events are compressed into + + + append-only activity rows; linkage fields dropped + + + + +
+ Two-tier pipeline: ephemeral runtime events → durable orchestration events → websocket. +
+
+ +
    +
  • + The right abstractions already exist. + ProviderRuntimeEventV2 already has + task.started/progress/completed (Claude emits them today, persisted and + streamed to clients as thread.activity-appended), a + collab_agent_tool_call canonical item type, and typed + ThreadTokenUsageSnapshot. +
  • +
  • + Snapshot+delta is a solved pattern: subscribeShell/subscribeThread + do live-buffer-first attach, snapshot-or-replay with afterSequence, + sequence-deduped deltas. projection_thread_sessions is the precedent for an + upserted per-thread mutable row. +
  • +
  • + Two cost facts that constrain the design (surfaced in review): +
      +
    • + Every accepted orchestration command appends an immutable event before + updating any projection — "upsert" describes the projection, not the event store. + High-frequency dispatches are permanent rows. +
    • +
    • + subscribeThread's afterSequence catch-up reads the + global event range after the cursor and filters per-thread afterward + (ws.ts:1385-1395), and shell resume falls back to full snapshots beyond + SHELL_RESUME_MAX_GAP = 1000. Fleet-wide event volume taxes every + reconnect. +
    • +
    + → persistence must be material transitions only (§5.3). +
  • +
  • + Compat asymmetry that shaped the transport: + OrchestrationEvent is a closed Schema.Union decoded by the RPC + client — a new event type breaks old clients (fails decode) and would need + per-subscriber gating. But OrchestrationThreadActivity.kind is an open string + with payload: Schema.Unknown — a new activity kind flows through + every layer untouched. That's why §5.3 ships the roster as an activity kind, not an event + type. +
  • +
  • + Gaps: only Claude emits task.* (Codex/OpenCode emit only + item lifecycles; ACP nothing). Task payloads lack name/model/parent-linkage and + usage is untyped. Tool activities carry no owning-agent id. Turn cost never + reaches the client. +
  • +
  • + Client: the right panel (rightPanelStore surfaces: + plan/diff/files/file/preview/terminal) is the natural mount; PlanSidebar is + the structural template; WorkingTimer (DOM-write ticking, no React commits), + Badge variants, animate-status-pulse, and + SimpleWorkEntryRow are the primitives to reuse. Mobile shares state via + client-runtime but duplicates presentation — put new derivation helpers in + client-runtime. +
  • +
+ +

4 · Unified data-availability matrix

+

+ "Wire" = the provider protocol T3 already speaks. "T3 today" = what actually reaches the + browser now. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DatumCodex v2 wireClaude wireT3 todayPlan
Agent idyes child thread id (UUIDv7)yes task_id + tool_use_idtaskId only (Claude)normalize to agentId + keep provider
Name / label + yes nickname + role + path (child + thread/started) + yes description, subagent_type, name, workflow labeldescription onlyname + agentType
Model + partial child thread settings — not on v2 + parent items + + partial workflow agents yes; plain subagents only if + text forwarded + optional model
Status / lifecycle + yes child thread/status + turn events (idle ≠ terminal) + yes task_updated + task_notificationstarted/completed only; task_updated dropped7-state enum incl. idle
Current activityyes child item streamyes last_tool_name; AI summaries opt-inlastToolName forwarded but not surfaced per-agentcurrentActivity + recent feed
Token burnyes per-thread breakdownsyes total_tokens per taskuntyped blobtyped usage
Start / end timeyes thread/turn timestampspartial end_time yes; start = receive timeactivity createdAtfirstStartedAt/lastActivityAt/endedAt
Parent linkageyes parentThreadId + depth + pathpartial tool_use_id, 1 level on streamparentAgentId
Workflow phases / retriesn/a (paths encode tree) + partial undeclared + workflow_progress (incl. attempt) + workflow agents = child agents with phaseTitle, attempt
Cost USDnopartial per-model per-turn onlyout of scope v1
+ +

5 · Design: the generic interface

+ +

+ Principle: don't invent a new pipeline — enrich the one that exists. The + task.* runtime-event family is already the provider-agnostic "unit of delegated + work". We (a) extend its payloads with the missing typed fields, (b) make the Codex adapter + synthesize the same events from v2 notifications, and (c) have ingestion fold them into one + upserted snapshot per agent that rides the existing subscribeThread stream — + with persistence limited to material transitions. +

+ +

5.1 Contract: identity + activations new

+

+ Review-driven shape change: a Codex agent is a durable, re-activatable identity while a + Claude task is a one-shot execution. The snapshot models the identity with rollups + of its activations, so a follow-up doesn't destroy history and elapsed time stays + meaningful. +

+
// packages/contracts/src/orchestration.ts (additive)
+ThreadAgentStatus = "pending" | "running" | "waiting" | "idle" | "completed" | "failed" | "stopped"
+  // waiting  = blocked on approval/user-input, or queued (workflow)
+  // idle     = finished a run but resumable (Codex send_message/followup; Claude paused)
+  // completed/failed/stopped = terminal
+
+ThreadAgentUsage = {
+  totalTokens: NonNegativeInt,
+  inputTokens?, cachedInputTokens?, outputTokens?, reasoningOutputTokens?,
+  toolUses?: NonNegativeInt,
+}
+
+ThreadAgentSnapshot = {
+  agentId: TrimmedNonEmptyString,        // stable identity: Claude task_id · Codex child thread id
+  provider: ProviderDriverKind,          // id-space discriminator; prevents cross-provider collisions
+  kind: "subagent" | "workflow" | "workflow_agent" | "shell" | "monitor" | "other",
+  name: TrimmedNonEmptyString,           // description · nickname · workflow label
+  agentType?: TrimmedNonEmptyString,     // subagent_type · agent_role
+  model?: TrimmedNonEmptyString,
+  status: ThreadAgentStatus,
+  currentActivity?: TrimmedNonEmptyString,
+  lastToolName?: TrimmedNonEmptyString,
+  usage?: ThreadAgentUsage,              // cumulative across activations
+  firstStartedAt: IsoDateTime,
+  lastActivityAt: IsoDateTime,
+  endedAt?: IsoDateTime,                 // cleared on re-activation
+  activationCount: NonNegativeInt,       // Codex follow-ups · workflow retry attempts
+  spawnTurnId?: TurnId,                  // turn that created it
+  lastTurnId?: TurnId,                   // latest turn that touched it (UI groups by this)
+  parentAgentId?: TrimmedNonEmptyString,
+  phaseIndex?: NonNegativeInt,           // workflow phase membership (index is authoritative;
+  phaseTitle?: TrimmedNonEmptyString,    //   title is display — titles can repeat across phases)
+  // kind === "workflow" only:
+  phases?: Array<{ index: NonNegativeInt, title: TrimmedNonEmptyString }>, // ordered phase list
+  scriptPath?: TrimmedNonEmptyString,    // persisted workflow script (WorkflowOutput.scriptPath)
+  runId?: TrimmedNonEmptyString,         // workflow run id (resume handle)
+  approvalRequestId?: ApprovalRequestId, // set while waiting on an approval → UI deep-link
+  outputFile?: TrimmedNonEmptyString,    // Claude transcript path — phase-2 drill-in needs it
+  resultSummary?: TrimmedNonEmptyString,
+  errorMessage?: TrimmedNonEmptyString,
+  recentActivity: Array<{ at: IsoDateTime, summary: string }>, // ring buffer, max 6
+  updatedAt: IsoDateTime,                // staleness + "while you were away" watermark
+}
+ +

5.2 Provider mapping

+
+
+

Claude → snapshot

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SDK messageEffect
task_started + upsert: id, name=description, kind from task_type, agentType=subagent_type, + firstStartedAt=now, status=running, outputFile later +
task_progress + usage, lastToolName, currentActivity=summary; + workflow_progress[] (schema-guarded): + workflow_phase entries → the workflow snapshot's ordered + phases list; workflow_agent entries → upsert child + workflow_agent snapshots (label, phaseIndex+phaseTitle, model, state, + tokens, attempt) +
Workflow tool_result (WorkflowOutput) + scriptPath + runId onto the workflow snapshot (typed on + the tool output: "Path to the persisted workflow script for this invocation") +
task_updatedstatus patch (killed→stopped, paused→idle), endedAt=end_time, errorMessage
task_notificationterminal status, resultSummary, final usage, outputFile
tool_progress (has task_id)lastToolName + recentActivity entry
can_use_tool / approval (has agent_id)status=waiting + approvalRequestId; cleared on resolution
+

+ Adapter fixes: stop dropping + subagent_type/tool_use_id/workflow_name/output_file; handle + task_updated (today: warning path). Roster reconciliation via the typed + background_tasks control request (not the undeclared stream event). + agentProgressSummaries is a per-instance setting, default off — fallback + currentActivity is last_tool_name. Not enabling + forwardSubagentText in v1. +

+
+
+

Codex v2 → snapshot (corrected)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NotificationEffect
child thread/started (source = subAgent.threadSpawn) + upsert identity: id=thread id, name=nickname, agentType=agentRole, parentAgentId, + depth/path, firstStartedAt +
subAgentActivity item (parent stream) + started → running; interacted → running + activationCount++; interrupted → stopped +
child turn/startedstatus=running (clear endedAt/resultSummary on re-activation)
child turn/completed + status=idle (resumable — not terminal), resultSummary=last + message +
child thread/status/changedactive flags waitingOnApproval/UserInput → waiting; closed → stopped
child thread/tokenUsage/updatedusage (full breakdown)
child item/started|completedcurrentActivity + recentActivity (reuse item titles)
child thread/settings/updatedmodel (v2 parent items don't carry it)
+

+ collabAgentToolCall is not used for tracking (v2 emits it only for + wait, with empty receivers) — render it as a parent activity only. Runtime + change: CodexSessionRuntime learns children from + thread/started.source + subAgentActivity, routes their + notifications to the agent tracker, and filters them out of the parent timeline. + Entry gate: a live v2 wire probe to establish what child chatter + reaches T3 today (§8). +

+
+
+ +

+ 5.3 Transport: one activity kind, zero new machinery rev 3 +

+

+ The dumb store already exists — use it. + OrchestrationThreadActivity has a free-form kind and an open + payload: Schema.Unknown, is persisted/capped/replayed, and streams over + subscribeThread today. And T3 already ships latest-wins state through it: + context-window.updated activities carry a full usage snapshot and the client + just takes the newest one (deriveLatestContextWindowSnapshot). Agents work + identically: +

+
// Ingestion keeps the per-thread agent map in memory (it must merge events anyway).
+// On a MATERIAL change — status transition · name/model/phase discovery ·
+// currentActivity change · usage crossing a 25k step · terminal fields —
+// it appends ONE activity via the EXISTING thread.activity.append command:
+{ kind: "agent.snapshot",
+  tone: "info",
+  summary: "2 agents running",               // human-readable on purpose (see compat)
+  payload: { agents: ThreadAgentSnapshot[] } } // full roster, latest-wins
+
+// Client: scan thread.activities for the newest agent.snapshot → that IS the panel state.
+// Same derivation pattern as the context-window meter. Typed decode of the payload
+// happens client-side against the contracts schema; a failed decode = ignore the row.
+

What this deletes relative to the rev-2 design — with no loss of insight:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Rev 2 (bespoke transport)Rev 3 (activity channel)
+ New thread.agent.upsert command + thread.agent-upserted event + in the closed union + Reuses thread.activity.append — no new command or event types
+ Subscriber opt-in gating + acceptAgentEvents + old/new contract tests + (closed-union decode risk) + + Not needed: kind is an open string, payload is + Unknown — old clients decode fine and render a rare benign "2 agents + running" info row (arguably a feature) +
+ projection_thread_agents table + migration + snapshot-assembly changes + + shell-refetch exclusion + + Not needed: rows land in the existing activities projection; the 500-row cap is the + retention policy +
ServerConfig.threadAgents capability flag + Not needed: presence of an agent.snapshot activity is self-describing +
Reducer hydration from the new projection + Same trick ingestion already uses for task titles: on first event for a thread, read the + latest agent.snapshot from the loaded thread detail (findTaskTitleInActivities + precedent) +
+
    +
  • + Volume: full-roster payload (≤ ~10 agents, a few KB) × material + transitions only ≈ tens of rows per agent run — same order as the + task.progress rows Claude already writes today. 1s ticks and elapsed time + stay client-derived, never persisted. +
  • +
  • + Codex-synthesized task.* events still bypass + runtimeEventToActivities + (the marker survives from rev 2) — they feed the reducer only, so slice 2 adds no timeline + spam. New clients add one skip line for agent.snapshot in + deriveWorkLogEntries (precedent: context-window.updated is + skipped the same way). +
  • +
  • + Known trade-offs, accepted: (a) the payload is untyped on the wire — the + schema lives in contracts and the client decodes tolerantly; (b) latest-wins inside a + 500-cap window means a thread with 500+ later activities loses its old roster — at that + distance the agents are ancient history, and the timeline's task.* rows + remain; (c) full-roster-per-write is O(agents) redundant — at ≤10 agents this is noise, + and it's what makes latest-wins (and therefore everything else) simple. +
  • +
  • + Escape hatch: if per-agent typed wire events or bigger fleets ever + matter, the rev-2 design (dedicated event + projection + subscriber gating, preserved in + git history of this doc) is the graduation path. Don't build it until the counter metric + (§8) says so. +
  • +
+ +

5.3b What happens to child-thread events — dropped today, distilled tomorrow

+

+ Today, T3 durably stores none of it. Precisely: Codex v1 child-thread + notifications are suppressed at the session runtime (their item events are re-attributed to + the parent turn; thread/turn/usage events dropped); Codex v2 child chatter is unaudited (the + suppression doesn't fire — probe gate in §8); Claude subagent traffic is reduced to the + task.* activity rows and its parent_tool_use_id stream deltas are + filtered out. The only place raw child events land is the rotating NDJSON observability logs + — not queryable, not replayed. +

+

This plan changes that to "distilled, not mirrored":

+
    +
  • + Durably stored (new): the per-agent snapshot — identity, lifecycle, usage + rollups, recentActivity (last 6 summaries), result summary, script/output + pointers — as agent.snapshot activity rows in the existing activities + projection + event store. This is what survives restarts and reconnects. +
  • +
  • + Still not stored: full child transcripts (every tool call, message delta, + reasoning block). Deliberately — mirroring child streams into the orchestration store + would multiply event volume by fleet size (§3's replay-cost facts). The providers already + persist them (Codex rollout files readable via thread/read; Claude + agent-<id>.jsonl / output_file), so the phase-2 drill-in + RPC reads them on demand from provider storage instead of duplicating them. +
  • +
  • + Caveat that falls out: transcript drill-in is only as durable as provider + storage — e.g. an ephemeral Codex thread or a cleaned /tmp output file means + the snapshot survives but the deep transcript doesn't. Acceptable: the snapshot is the + record; the transcript is an inspection tool. +
  • +
+ +

5.4 Recovery & staleness new (review-driven)

+
    +
  • + Reducer bootstrap: on the first event for a thread, hydrate the in-memory + agent map from the latest persisted agent.snapshot activity (same pattern + ingestion already uses to recover task titles from activities), so a mid-run server + restart doesn't orphan records when the next task_progress arrives without + its task_started. +
  • +
  • + Orphan sweep: provider sessions are child processes of the T3 server — on + session.exited (or a new session.started for the thread), all + non-terminal agents for that thread transition to stopped with + endedAt=now and an "orphaned on session exit" errorMessage. Reconciliation on + resume: Claude via the background_tasks control request; Codex via tracked + child threads. +
  • +
  • + Client staleness: updatedAt + the client's sync watermark + distinguish "running" from "stale while disconnected"; the UI's transport states + (loading/synchronizing/live/reconnecting) come from the existing subscription status + machinery, not the snapshot. +
  • +
  • + History: pre-feature threads cannot be backfilled — dropped fields only + exist in ephemeral NDJSON logs. Documented as a non-goal. +
  • +
+ +

5.5 Drill-in

+
    +
  • + Phase 1 (ships with the panel): recentActivity ring buffer + + resultSummary + prompt preview. Workflow cards get a "View script" + affordance: scriptPath opens in the right panel's existing + file: surface (read-only file preview — zero new viewer code; the path is + outside the workspace root, so the file surface needs to accept absolute session paths or + the server proxies the read). +
  • +
  • + Phase 2 (full transcript): one read RPC + orchestration.getAgentTranscript {threadId, agentId}. Codex: + thread/read on the child thread. Claude: tail outputFile / + agent-<id>.jsonl server-side (the snapshot's + outputFile field exists for exactly this), rendered with the existing + work-log row components. Avoids turning on forwardSubagentText globally. +
  • +
+ +

6 · Server changes (all additive)

+

+ Rev 3: no new command, event type, projection table, migration, subscription input, or + capability flag. The entire delta is contracts (types only), two adapters, and one reducer + inside ingestion. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#WhereChangeSize
1packages/contracts + ThreadAgentSnapshot + status/usage schemas and the + agent.snapshot payload schema (used for client-side decode — no + wire-protocol change); extend TaskStarted/Progress/Completed payloads + (optional + name/agentType/model/toolUseId/parentTaskId/workflowName/phaseIndex/phaseTitle/phases/attempt/scriptPath/runId/outputFile + + typed usage + timeline-bypass marker) + S
2ClaudeAdapter + Carry the dropped fields; handle task_updated; approval → agent attribution + via agent_id; workflow_progress schema-guarded parse into + child task.* events; background_tasks reconciliation; + agentProgressSummaries per-instance option (default off) + M
3CodexSessionRuntime / CodexAdapter + Gated on live v2 probe. Track children from + thread/started.source.subAgent + subAgentActivity; route child + notifications to synthesized timeline-bypassing task.* events per §5.2; + keep child chatter out of the parent timeline (new filtering — the existing suppression + doesn't fire for v2) + M–L
4ProviderRuntimeIngestion + Agent reducer (in-memory, hydrated from the latest persisted + agent.snapshot) → material-transition agent.snapshot activity + appends through the existing command; orphan sweep on session exit; dispatch-rate + counter metric + M
+

+ Non-goals v1: cost USD attribution, ACP (Cursor/Grok) support (no wire signal exists), + cross-thread agent trees, controlling agents (stop/steer — Codex exposes interruption only + as a model tool today), history backfill. OpenCode: falls out automatically if its adapter + ever emits task.*. +

+ +

7 · Client changes

+
    +
  • + Derivation in client-runtime (shared with mobile): + deriveLatestAgentSnapshot(activities) — newest + agent.snapshot row, tolerant payload decode (mirrors + deriveLatestContextWindowSnapshot) — plus + deriveAgentPanelState(agents, syncStatus) — group by workflow, split + running/settled (by lastTurnId), compute totals, staleness from + updatedAt + subscription state. +
  • +
  • + Web: add "agents" to + RIGHT_PANEL_KINDS (singleton surface, storage v7→8); tab + "+" add-menu entry + in RightPanelTabs (surface always addable from the menu; the tab auto-appears + once the thread has agents); an AgentsPanel branch in ChatView's + surface switch; a compact live strip near the "Working…" row; one skip line for + agent.snapshot in deriveWorkLogEntries. Reuse: + Badge, animate-status-pulse, WorkingTimer, + SimpleWorkEntryRow. +
  • +
  • + Mobile: inspector-pane tenant on tablet, sheet on phone — later slice, + shares the derivation helpers. +
  • +
  • + Visual design + feature-to-field milestones: see the + companion UI plan. +
  • +
+ +

8 · Phasing, risks, open questions

+

Phasing

+
    +
  1. + Slice 1 — contracts + Claude: #1, #2, #4, #5 → panel shows Claude + subagents + workflows end-to-end. +
  2. +
  3. + Slice 2 — Codex: entry gate: live v2 wire probe (spawn agents on + gpt-5.6-sol through T3's app-server wrapper, capture exactly which child notifications + arrive and what leaks into the timeline today). Then #3 → same panel lights up for Codex + with zero client work. +
  4. +
  5. Slice 3 — drill-in transcripts + mobile.
  6. +
+

Risks

+
    +
  • + workflow_progress is undeclared in the SDK types — schema-guarded parsing; + file upstream for typing. The background_tasks_changed stream event is + probe-only evidence — never a dependency (typed control request is the roster source). +
  • +
  • + Codex child-thread routing touches turn-attribution logic — needs tests that parent + transcript behavior is unchanged for both v1 and v2 models. +
  • +
  • + Event volume: material-transition persistence bounds it, but slice 1 ships with a counter + metric on agent-upsert dispatch rate so we see reality before slice 2 multiplies it. +
  • +
  • + Compat: one test that an old client's activity handling renders (or benignly ignores) an + agent.snapshot row — the open-string channel makes this low-risk, but verify + rather than assume. +
  • +
+

Open questions

+
    +
  • Settled-agent grouping: collapse by turn (current lean) or by workflow run?
  • +
  • + Auto-open the panel on first agent spawn? (Lean: badge on the tab + the inline strip, no + auto-open.) +
  • +
  • + agentProgressSummaries: per-instance setting shipped off — do we want a UI + toggle in thread settings, or instance config only? +
  • +
+
+ + diff --git a/docs/project/plans/subagent-observability-ui.html b/docs/project/plans/subagent-observability-ui.html new file mode 100644 index 00000000000..ce43a24bb41 --- /dev/null +++ b/docs/project/plans/subagent-observability-ui.html @@ -0,0 +1,1424 @@ + + + + + + Subagent Observability — UI Mockups + + + +
+

+ T3 Code · Plan 2 of 2 · 2026-07-20 · rev 2 (post-review) · companion: + audit & tech spec +

+

Subagent Observability: UI Mockups

+

+ Three directions for surfacing live subagents and workflows. All three consume the same + ThreadAgentSnapshot[] from the spec, use T3's existing tokens (DM Sans, the + sky/emerald/amber status language, animate-status-pulse dots, + Badge chips), and mount without new layout regions. They differ in + where attention lives. +

+

+ Rev 2 after adversarial review (Fable + gpt-5.6-sol): every promised feature is now pinned + to a contract field and milestone (table below), + transport/staleness states are specified instead of assumed free, the Stop control is + explicitly out of v1 scope, and the Agents surface stays reachable from the panel "+" menu + even before any agent has run. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
DirectionOne-linerBest when
AAgents panel (right-panel surface)A first-class "Agents" tab beside Diff/Files/Browser with one card per agentYou want to watch a fleet while reading the chat
BInline live stripA collapsed roster docked above the composer that expands in placeGlanceable awareness with zero layout cost
CMission controlTree + drill-in split view with workflow phase barHeavy workflow runs; debugging what an agent did
+ + +

+ Option A Agents panel — a right-panel surface + recommended core +

+

+ Adds kind: "agents" to the existing right-panel workspace. A tab with a live + count badge sits beside Diff/Files/Browser; the panel shows one compact card per agent, + grouped by origin (workflow vs. direct spawns), with a summary footer aggregating burn. + Cards expand in place to the recent-activity feed. +

+ +
+
+
+
+ t3code · fix-auth-flow +
+ +
+
+
+
+ Audit the auth flow and fix the session-refresh race — use a workflow. +
+
+
+
+
+ Workflow: audit-auth-flow + · +
+
+ Working for + · 4 agents running +
+
+
+ Reply… +
+
+
+
+
+ ⛬ Agents 4 + DiffFiles+ +
+
+
+ Workflow · audit-auth-flow + {} script +
+
+ ✓ Audit +
+
+
+ audit:auth-entrypoints4m 02s +
+
Found 3 candidate races · report → /tmp/audit/entry.md
+
+ 118.4k tok·41 tools +
+
+
+ Verify2 running · 1 done +
+
+
+ verify:session-refreshsonnet +
+
▸ Reproducing the race in sessionRefresh.test.ts
+
+ 41.2k tok ·12 tools +
+
+
+
+ verify:token-rotationsonnet +
+
▸ Read apps/server/src/auth/rotate.ts
+
+ 26.0k tok ·8 tools +
+
+
+ Fixpending +
+
+ Direct spawns +
+
+
+ Marlowexplorercodex +
+
+ Waiting on approval · /root/marlow +
+
+ 61.8k tok·blocked 41s +
+
+
+
+ 3 running + 1 waiting + Σ 247k tok +
+
+
+
+
+ Option A — "Agents" as a peer of Diff/Files. Workflow agents group under real phase + headers (✓ done / running counts / pending) from the snapshot's phases list; + "{} script" opens the workflow's JS file in the file surface. Token burn is a plain + counter with a live-tick ▲ — burn is unbounded, so it must not look like progress toward + completion. Amber = blocked on approval (clicking jumps to the prompt). +
+
+ +
+
+ Pros +
    +
  • Zero new layout machinery — tab strip, docking, sheet mode, resize all exist
  • +
  • Persistent: watch agents while scrolling chat; count badge when hidden
  • +
  • Cards scale from 1 subagent to a 10-agent workflow (groups + scroll)
  • +
  • Natural home for phase-2 drill-in (card → expanded feed)
  • +
+
+
+ Cons +
    +
  • Competes for the right panel with Diff/Browser (tab switching)
  • +
  • Hidden by default on <980px until the user opens the sheet
  • +
  • Discovery depends on noticing the tab badge
  • +
+
+
+ + +

+ Option B Inline live strip — agents live in the conversation +

+

+ No panel at all: a roster strip docks between the timeline and the composer whenever ≥1 + agent is live, replacing the bare "Working…" row. Collapsed, it's one line with a face-pile + and aggregate burn; expanded, one row per agent. Terminal states fold into the timeline as + ordinary work-log entries. Inspired by the existing turn-fold ("Worked for 4m 12s") + language. +

+ +
+
+
+
+ t3code · fix-auth-flow +
+ +
+
+
+
+ Audit the auth flow and fix the session-refresh race — use a workflow. +
+
+
+
+
+ audit:auth-entrypoints — 3 candidate races + +
+
+ +
+
+ + 4 agents +
+ V + V + M + A +
+ · Verify 2/3 · · + Σ 247k + +
+
+ verify:session-refreshReproducing the race in sessionRefresh.test.ts41.2k · +
+
+ verify:token-rotationRead apps/server/src/auth/rotate.ts26.0k · +
+
+ Marlow explorerWaiting on approval — Review approval request61.8k · +
+
+ audit:auth-entrypointsDone · 3 candidate races found118.4k · 4m 02s +
+
+ +
+ Reply… +
+
+
+
+
+
+ Option B — the roster is part of the conversation flow. Collapsed by default to a single + line; the ▾ expands rows in place. Rows click through to a drill-in sheet. +
+
+ +
+
+ Pros +
    +
  • Impossible to miss — agents appear exactly where you're already looking
  • +
  • Works identically on mobile/narrow (no panel dependency)
  • +
  • Cheapest build: one component + the existing work-log fold pattern
  • +
  • Keeps the right panel free for Diff/Browser
  • +
+
+
+ Cons +
    +
  • Competes with the composer for vertical space when expanded
  • +
  • No room for rich per-agent detail — drill-in must open elsewhere
  • +
  • Long fleets (10+) need truncation ("+6 more")
  • +
+
+
+ + +

Option C Mission control — tree + drill-in split

+

+ The maximal version, for when a workflow is the main event: the right panel (or maximized + panel) becomes a two-pane console. Left: the agent tree — workflow → phases → agents, Codex + agent paths shown as real hierarchy. Right: the selected agent's live dossier — status, + model, burn breakdown, and a streaming activity feed built from + recentActivity (phase 2: full transcript). A phase progress bar sits on top. +

+ +
+
+
+
+ t3code · fix-auth-flow · Agents +
+ +
+
+
+
+
+
+ audit-auth-flow + workflow + {} script + · + Σ 247k tok · 41 tools +
+
+
+
+
+
+
+ Audit ✓Verify 2/3Fix +
+
+
+
+
+ Phase: Verify +
+
+ verify:session-refresh41.2k +
+
+ verify:token-rotation26.0k +
+
+ verify:cookie-scopequeued +
+
+ Phase: Audit +
+
+ audit:auth-entrypoints118.4k +
+
+ Direct · codex +
+
+ Marlow explorer61.8k +
+
+ /marlow/grep_worker8.1k +
+
+
+
+ verify:session-refresh + sonnet + ⏹ Stop +
+
+
Elapsed
+
Tokens41.2k
+
Tools12
+
Runs1
+
+
+ Prompt: “Adversarially verify: session refresh race in refreshSession() — try + to reproduce…” +
+
+
+ 18:42:11▸ Bash · vp test run sessionRefresh.test.ts +
+
+ 18:41:56▸ Edit · sessionRefresh.test.ts (+34) +
+
+ 18:41:20▸ Read · apps/server/src/auth/refresh.ts +
+
+ 18:40:58▸ Grep · "refreshSession" — 14 matches +
+
+ 18:40:41Spawned · fork: last 2 turns +
+
+
+
+
+
+
+
+
+ Option C — tree honors real structure: workflow phases (Claude) and nested agent paths + (Codex, /marlow/grep_worker). The dossier pane streams the recent-activity + feed. Stop is v2+ only: no per-subagent interrupt exists in T3's command + set today, and Codex exposes interruption only as a model tool — it needs its own protocol + work. +
+
+ +
+
+ Pros +
    +
  • Only option that shows structure: phases, nesting, queue state
  • +
  • Drill-in is first-class, not an afterthought — best debugging surface
  • +
  • Maximized mode turns T3 into a genuine fleet console
  • +
+
+
+ Cons +
    +
  • Most surface area to build and maintain (~2–3× Option A)
  • +
  • Overkill for the common case of 1–2 subagents
  • +
  • Split panes need ≥ the maximized panel width to breathe
  • +
+
+
+ + +

Recommendation

+
+

+ Ship A, seed it with B's collapsed line, grow into C. These aren't + mutually exclusive — they're one system at three zoom levels. But (review pushback, + accepted) they are not all "the same data at more room": each step up needs + specific contract fields to exist first. The milestones below make that explicit so the UI + never promises what the wire can't deliver. +

+
    +
  1. + v1: Option A panel + Option B's + collapsed one-liner only (replacing today's bare "Working…" row; clicking it + opens the panel). Strictly limited to v1-milestone fields. +
  2. +
  3. + v1.5: in-card expansion in the panel → the recent-activity feed (C's + dossier content, without the split layout) + approval deep-links. +
  4. +
  5. + v2: when maximized, the Agents surface upgrades to C's tree + dossier + layout. Stop/steer controls land here — they need new provider-command plumbing, not + just layout. +
  6. +
+
+ +

Feature → contract field → milestone

+

+ Every visible affordance in the mocks, pinned to its data source. Nothing ships ahead of its + row. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UI affordanceContract field(s)Milestone
Name, type chip, status dot, elapsed, token barname, agentType, status, firstStartedAt/lastActivityAt, usagev1
"What it's doing" line + currentActivity (AI summary when enabled) with + lastToolName fallback + v1
Workflow grouping + phase headers (✓ Audit / Verify / Fix pending) + kind, parentAgentId, phaseIndex per agent + ordered phases on + the workflow snapshot (phase status derived: done = all members settled, running = any + running, pending = no members yet) + v1
"{} script" → workflow JS in file surface + scriptPath from WorkflowOutput (Claude-only; Codex has no + workflow concept) + v1
Amber row → jump to pending approvalapprovalRequestId (spec §5.1) + existing approval panel focusv1.5
"attempt 2 — retrying" + activationCount + errorMessage (Claude workflows only — + workflow_progress.attempt; no Codex source) + v1.5
Recent-activity feed in card/dossierrecentActivity ring bufferv1.5
Result link ("report → /tmp/…")resultSummary (path rendering is client-side sniffing)v1.5
"While you were away" divider, stale badgesupdatedAt + client sync watermark from subscription statev1.5
Full transcript dossier, per-agent tool feedoutputFile + getAgentTranscript RPC (spec §5.5)v2
Stop / steer buttonsnew provider commands — not in the observability contract at allv2+
+

+ Cut from the mocks after review: the "Isolation: worktree" stat (no wire source for either + provider) and the reasoning-effort chip (Codex v2 parent items don't carry effort; revisit + if a child-settings source is plumbed). The per-card provider chip is derived from the + thread's session, not a snapshot field. +

+ +

Shared component inventory (all options)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PieceReuses
Status dot + pulse + animate-status-pulse, resolveThreadStatusPill color language + (sky=running, emerald=done, amber=waiting, red=failed, zinc=queued) +
Elapsed timers + WorkingTimer self-ticking DOM-write pattern (no React commits per tick) +
Model/role chipsBadge variants
Token burn counter + Plain tabular-nums count + live-tick indicator. Not a bar/ring: + burn has no denominator, and a fill implies progress. (ContextWindowMeter's + ring stays where a real denominator exists — context windows.) +
Activity feed rowsSimpleWorkEntryRow / work-log derivation, filtered by agentId
Panel shell / tabs / sheetRightPanelTabs + PreviewPanelShell resize + maximize
+ +

States to design past the happy path

+
    +
  • + Empty / discovery: the tab auto-appears once the thread has any agent + (and persists with settled history), but the surface is always reachable from the + right panel's "+" add-menu with an explanatory empty state — so users can discover it + before ever triggering an agent. +
  • +
  • + Idle vs done (Codex): a Codex agent that finished a run is + idle and resumable, not completed — dimmed sky dot, "idle · resumable" label. If + the model sends it a follow-up, the card re-activates (run count increments, elapsed + resumes). Distinct from Claude's terminal green check. +
  • +
  • + Waiting on approval: amber row links straight to the pending approval via + approvalRequestId (highest-value click in the whole feature; v1.5). +
  • +
  • + Failure: red dot + errorMessage snippet on the card; Claude + workflow agents show attempt count ("attempt 2 — retrying"). Codex has no retry signal — + failed is failed. +
  • +
  • + Transport states (separate axis from agent lifecycle): loading (skeleton + cards), synchronizing, live, reconnecting (dim panel + "reconnecting…" banner, timers + freeze at lastActivityAt), and subscription-error. While disconnected, + running agents get a stale badge after 30s past updatedAt — a snapshot can't + distinguish "still running" from "died while we were away", so the UI must not pretend it + can. +
  • +
  • + Orphaned: if the provider session exits, the server sweeps non-terminal + agents to stopped ("orphaned on session exit") — the panel shows them as + stopped with that reason, no zombie spinners. +
  • +
  • + Reconnect catch-up: settled-while-away agents (settled + updatedAt newer than the client's last-sync watermark) group under a "while + you were away" divider (v1.5). +
  • +
  • + Overflow: >8 agents → group headers become sticky, settled groups + auto-collapse; the reducer caps settled agents carried in the roster at 50/thread (oldest + settled pruned first). +
  • +
+ + +
+ + diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..ebf6ffbf00c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,6 +18,7 @@ export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; export * from "./orchestration.ts"; +export * from "./threadAgents.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..e76850b9810 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"); @@ -459,10 +461,56 @@ const UserInputResolvedPayload = Schema.Struct({ }); export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; +/** + * Typed task usage rollup. `usage` on task events was historically + * `Schema.Unknown` (raw Claude SDK shape); emitters now normalize to this. + */ +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; + +/** + * Agent-linkage fields shared by the task lifecycle payloads. Optional and + * additive: pre-existing emitters that only set taskId/description remain + * valid. + * + * `timelineBypass: true` marks events synthesized purely to feed the agent + * reducer (e.g. Codex child-thread activity): ingestion must skip the + * activity-timeline projection for them, otherwise child fleets would spray + * a row per tool call into the transcript. + */ +const TaskAgentLinkage = { + name: Schema.optional(TrimmedNonEmptyStringSchema), + agentType: Schema.optional(TrimmedNonEmptyStringSchema), + model: Schema.optional(TrimmedNonEmptyStringSchema), + toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), + parentTaskId: Schema.optional(RuntimeTaskId), + workflowName: Schema.optional(TrimmedNonEmptyStringSchema), + phaseIndex: Schema.optional(NonNegativeInt), + phaseTitle: Schema.optional(TrimmedNonEmptyStringSchema), + phases: Schema.optional( + Schema.Array(Schema.Struct({ index: NonNegativeInt, title: TrimmedNonEmptyStringSchema })), + ), + attempt: Schema.optional(NonNegativeInt), + scriptPath: Schema.optional(TrimmedNonEmptyStringSchema), + runId: Schema.optional(TrimmedNonEmptyStringSchema), + outputFile: Schema.optional(TrimmedNonEmptyStringSchema), + timelineBypass: Schema.optional(Schema.Boolean), +} as const; + const TaskStartedPayload = Schema.Struct({ taskId: RuntimeTaskId, description: Schema.optional(TrimmedNonEmptyStringSchema), taskType: Schema.optional(TrimmedNonEmptyStringSchema), + prompt: Schema.optional(TrimmedNonEmptyStringSchema), + ...TaskAgentLinkage, }); export type TaskStartedPayload = typeof TaskStartedPayload.Type; @@ -470,16 +518,34 @@ const TaskProgressPayload = Schema.Struct({ taskId: RuntimeTaskId, description: TrimmedNonEmptyStringSchema, summary: Schema.optional(TrimmedNonEmptyStringSchema), - usage: Schema.optional(Schema.Unknown), + usage: Schema.optional(Schema.Union([RuntimeTaskUsage, Schema.Unknown])), lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), + ...TaskAgentLinkage, }); export type TaskProgressPayload = typeof TaskProgressPayload.Type; +/** + * Non-terminal state patch (Claude `task_updated`): running/paused flips, + * backgrounding, error text, end time. + */ +const TaskUpdatedPayload = Schema.Struct({ + taskId: RuntimeTaskId, + status: Schema.optional( + Schema.Literals(["pending", "running", "completed", "failed", "killed", "paused"]), + ), + endTime: Schema.optional(IsoDateTime), + isBackgrounded: Schema.optional(Schema.Boolean), + errorMessage: Schema.optional(TrimmedNonEmptyStringSchema), + ...TaskAgentLinkage, +}); +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), + usage: Schema.optional(Schema.Union([RuntimeTaskUsage, Schema.Unknown])), + ...TaskAgentLinkage, }); export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; @@ -835,6 +901,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 +1068,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeUserInputResolvedEvent, ProviderRuntimeTaskStartedEvent, ProviderRuntimeTaskProgressEvent, + ProviderRuntimeTaskUpdatedEvent, ProviderRuntimeTaskCompletedEvent, ProviderRuntimeHookStartedEvent, ProviderRuntimeHookProgressEvent, diff --git a/packages/contracts/src/threadAgents.ts b/packages/contracts/src/threadAgents.ts new file mode 100644 index 00000000000..ad43e918d2d --- /dev/null +++ b/packages/contracts/src/threadAgents.ts @@ -0,0 +1,138 @@ +import * as Schema from "effect/Schema"; +import { + ApprovalRequestId, + IsoDateTime, + NonNegativeInt, + TrimmedNonEmptyString, + TurnId, +} from "./baseSchemas.ts"; +import { ProviderDriverKind } from "./providerInstance.ts"; + +/** + * `ThreadAgentSnapshot` — one delegated worker (subagent, workflow, workflow + * agent, background shell, monitor) observed on a thread. + * + * The snapshot models a stable *identity* with rollups across its activations: + * a Codex collab agent is a durable, re-activatable child thread (follow-ups + * re-run it), while a Claude task is a one-shot execution. `status: "idle"` + * captures the Codex "finished a run but resumable" state, which is distinct + * from the terminal `completed`. + * + * Transport: the full per-thread roster is carried latest-wins in the payload + * of an `agent.snapshot` thread activity (see `ThreadAgentsActivityPayload`). + * There is no dedicated wire event — the activity channel's open `kind` string + * and unknown payload are the compatibility surface, mirroring how + * `context-window.updated` ships token snapshots today. + */ +export const ThreadAgentStatus = Schema.Literals([ + // Not yet running (workflow agent queued behind a phase, Codex pendingInit). + "pending", + "running", + // Blocked on an approval or user-input request. + "waiting", + // Finished a run but resumable (Codex agents between activations; Claude paused). + "idle", + "completed", + "failed", + "stopped", +]); +export type ThreadAgentStatus = typeof ThreadAgentStatus.Type; + +export const THREAD_AGENT_TERMINAL_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "stopped", +]); + +export const ThreadAgentKind = Schema.Literals([ + "subagent", + "workflow", + "workflow_agent", + "shell", + "monitor", + "other", +]); +export type ThreadAgentKind = typeof ThreadAgentKind.Type; + +/** Cumulative usage across all of an agent's activations. */ +export const ThreadAgentUsage = Schema.Struct({ + totalTokens: NonNegativeInt, + inputTokens: Schema.optional(NonNegativeInt), + cachedInputTokens: Schema.optional(NonNegativeInt), + outputTokens: Schema.optional(NonNegativeInt), + reasoningOutputTokens: Schema.optional(NonNegativeInt), + toolUses: Schema.optional(NonNegativeInt), +}); +export type ThreadAgentUsage = typeof ThreadAgentUsage.Type; + +export const ThreadAgentActivityEntry = Schema.Struct({ + at: IsoDateTime, + summary: TrimmedNonEmptyString, +}); +export type ThreadAgentActivityEntry = typeof ThreadAgentActivityEntry.Type; + +/** Ordered workflow phase, present only on `kind: "workflow"` snapshots. */ +export const ThreadAgentWorkflowPhase = Schema.Struct({ + index: NonNegativeInt, + title: TrimmedNonEmptyString, +}); +export type ThreadAgentWorkflowPhase = typeof ThreadAgentWorkflowPhase.Type; + +export const THREAD_AGENT_RECENT_ACTIVITY_LIMIT = 6; + +export const ThreadAgentSnapshot = Schema.Struct({ + // Stable identity: Claude task_id, Codex child thread id. Provider + // discriminates the id space so cross-provider collisions are impossible. + agentId: TrimmedNonEmptyString, + provider: ProviderDriverKind, + kind: ThreadAgentKind, + name: TrimmedNonEmptyString, + // Provider agent type: Claude subagent_type ("Explore"), Codex agent_role + // ("explorer"). + agentType: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), + status: ThreadAgentStatus, + currentActivity: Schema.optional(TrimmedNonEmptyString), + lastToolName: Schema.optional(TrimmedNonEmptyString), + usage: Schema.optional(ThreadAgentUsage), + firstStartedAt: IsoDateTime, + lastActivityAt: IsoDateTime, + // Cleared when a re-activation (Codex follow-up) starts a new run. + endedAt: Schema.optional(IsoDateTime), + // Codex follow-ups and Claude workflow retry attempts. + activationCount: NonNegativeInt, + spawnTurnId: Schema.optional(TurnId), + lastTurnId: Schema.optional(TurnId), + parentAgentId: Schema.optional(TrimmedNonEmptyString), + // Workflow phase membership. Index is authoritative; title is display-only + // (titles can repeat across phases). + phaseIndex: Schema.optional(NonNegativeInt), + phaseTitle: Schema.optional(TrimmedNonEmptyString), + // kind === "workflow" only. + phases: Schema.optional(Schema.Array(ThreadAgentWorkflowPhase)), + scriptPath: Schema.optional(TrimmedNonEmptyString), + runId: Schema.optional(TrimmedNonEmptyString), + // Set while waiting on an approval so the UI can deep-link to the request. + approvalRequestId: Schema.optional(ApprovalRequestId), + // Claude transcript/output path; the drill-in RPC reads it on demand. + outputFile: Schema.optional(TrimmedNonEmptyString), + resultSummary: Schema.optional(TrimmedNonEmptyString), + errorMessage: Schema.optional(TrimmedNonEmptyString), + recentActivity: Schema.Array(ThreadAgentActivityEntry), + // Staleness watermark: how fresh this record is, independent of transport + // state. Clients compare against their sync watermark. + updatedAt: IsoDateTime, +}); +export type ThreadAgentSnapshot = typeof ThreadAgentSnapshot.Type; + +export const THREAD_AGENTS_ACTIVITY_KIND = "agent.snapshot"; + +/** + * Payload of an `agent.snapshot` thread activity: the complete latest-wins + * roster for the thread. Decoded tolerantly on the client — a row that fails + * to decode is ignored, never fatal. + */ +export const ThreadAgentsActivityPayload = Schema.Struct({ + agents: Schema.Array(ThreadAgentSnapshot), +}); +export type ThreadAgentsActivityPayload = typeof ThreadAgentsActivityPayload.Type; From 45783d5fab93f701a55d9667c51d708b93f8608e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 20:29:31 -0700 Subject: [PATCH 02/17] feat(web): agents panel, live strip, and thread-agent derivation New right-panel 'agents' surface (Option A) + collapsed live strip near the composer (Option B). Roster derived latest-wins from agent.snapshot activities via shared client-runtime helpers. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/AgentsPanel.tsx | 319 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 35 ++ apps/web/src/components/RightPanelTabs.tsx | 21 +- .../src/components/chat/AgentsLiveStrip.tsx | 63 ++++ apps/web/src/rightPanelStore.ts | 17 +- apps/web/src/session-logic.ts | 1 + packages/client-runtime/package.json | 4 + .../client-runtime/src/state/threadAgents.ts | 137 ++++++++ 8 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/AgentsPanel.tsx create mode 100644 apps/web/src/components/chat/AgentsLiveStrip.tsx create mode 100644 packages/client-runtime/src/state/threadAgents.ts diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx new file mode 100644 index 00000000000..89d50e93400 --- /dev/null +++ b/apps/web/src/components/AgentsPanel.tsx @@ -0,0 +1,319 @@ +import { memo, useEffect, useRef, useState } from "react"; +import type { ThreadAgentSnapshot } from "@t3tools/contracts"; +import { + deriveAgentPanelState, + formatAgentTokenCount, + isTerminalAgentStatus, + type AgentPanelGroup, + type AgentPanelPhase, +} from "@t3tools/client-runtime/state/thread-agents"; +import { BotIcon, BracesIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { Badge } from "./ui/badge"; +import { ScrollArea } from "./ui/scroll-area"; +import { formatDuration } from "../session-logic"; + +interface AgentsPanelProps { + agents: ReadonlyArray; + onOpenScript?: (scriptPath: string) => void; + mode?: "sheet" | "sidebar" | "embedded"; +} + +const STATUS_DOT_CLASS: Record = { + pending: "bg-muted-foreground/40", + running: "bg-sky-500 animate-status-pulse", + waiting: "bg-warning animate-status-pulse", + idle: "bg-sky-500/50", + completed: "bg-success", + failed: "bg-destructive", + stopped: "bg-muted-foreground/60", +}; + +const STATUS_LABEL: Record = { + pending: "Queued", + running: "Running", + waiting: "Waiting", + idle: "Idle · resumable", + completed: "Completed", + failed: "Failed", + stopped: "Stopped", +}; + +function AgentStatusDot({ status }: { status: ThreadAgentSnapshot["status"] }) { + return ( + + ); +} + +/** + * Self-ticking elapsed label (WorkingTimer pattern): writes its own text node + * so per-second updates never cause React commits. Frozen once `endedAt` is + * set or the agent settles. + */ +function AgentElapsed({ agent }: { agent: ThreadAgentSnapshot }) { + const textRef = useRef(null); + const settled = isTerminalAgentStatus(agent.status) || agent.status === "idle"; + const startMs = Date.parse(agent.firstStartedAt); + const endMs = agent.endedAt + ? Date.parse(agent.endedAt) + : settled + ? Date.parse(agent.lastActivityAt) + : null; + const initialText = formatDuration((endMs ?? Date.now()) - startMs); + + useEffect(() => { + if (endMs !== null) return; + const update = () => { + if (textRef.current) { + textRef.current.textContent = formatDuration(Date.now() - startMs); + } + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [startMs, endMs]); + + return ( + + {initialText} + + ); +} + +function AgentCard({ agent }: { agent: ThreadAgentSnapshot }) { + const [expanded, setExpanded] = useState(false); + const settled = isTerminalAgentStatus(agent.status); + const activity = + agent.status === "waiting" + ? "Waiting on approval" + : (agent.currentActivity ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? + agent.resultSummary ?? + agent.errorMessage); + const hasFeed = agent.recentActivity.length > 0; + + return ( + + ); +} + +function PhaseHeader({ phase }: { phase: AgentPanelPhase }) { + const runningCount = phase.agents.filter((agent) => agent.status === "running").length; + const doneCount = phase.agents.filter((agent) => isTerminalAgentStatus(agent.status)).length; + return ( +
+ + {phase.status === "done" ? "✓ " : ""} + {phase.title} + + {phase.status === "running" ? ( + + {runningCount} running{doneCount > 0 ? ` · ${doneCount} done` : ""} + + ) : phase.status === "pending" ? ( + pending + ) : null} + +
+ ); +} + +function AgentGroup({ + group, + onOpenScript, +}: { + group: AgentPanelGroup; + onOpenScript?: ((scriptPath: string) => void) | undefined; +}) { + const scriptPath = group.workflow?.scriptPath; + return ( +
+
+ {group.workflow ? ( + <> + Workflow · {group.workflow.name} + {scriptPath && onOpenScript ? ( + { + event.stopPropagation(); + onOpenScript(scriptPath); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.stopPropagation(); + onOpenScript(scriptPath); + } + }} + className="inline-flex cursor-pointer items-center gap-1 font-mono text-[10px] font-medium tracking-normal text-primary normal-case hover:underline" + > + script + + ) : null} + + ) : ( + Direct spawns + )} + +
+ {group.phases.map((phase) => ( +
+ +
+ {phase.agents.map((agent) => ( + + ))} +
+
+ ))} + {group.rest.length > 0 ? ( +
0 && "pt-2")}> + {group.rest.map((agent) => ( + + ))} +
+ ) : null} +
+ ); +} + +const AgentsPanel = memo(function AgentsPanel({ agents, onOpenScript, mode }: AgentsPanelProps) { + const state = deriveAgentPanelState(agents); + + if (agents.length === 0) { + return ( +
+ +

No agents yet

+

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

+
+ ); + } + + return ( +
+ +
+ {state.groups.map((group, index) => ( + + ))} +
+
+
+ {state.runningCount > 0 ? ( + + + {state.runningCount} running + + ) : null} + {state.waitingCount > 0 ? {state.waitingCount} waiting : null} + {state.settledCount > 0 ? {state.settledCount} settled : null} + + Σ {formatAgentTokenCount(state.totalTokens)} tok + +
+
+ ); +}); + +export default AgentsPanel; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..8648ad7fe74 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,6 +85,7 @@ import { hasActionableProposedPlan, isLatestTurnSettled, } from "../session-logic"; +import { deriveLatestAgentSnapshot } from "@t3tools/client-runtime/state/thread-agents"; import { type LegendListRef } from "@legendapp/list/react"; import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; import { @@ -136,6 +137,8 @@ import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import AgentsPanel from "./AgentsPanel"; +import AgentsLiveStrip from "./chat/AgentsLiveStrip"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; @@ -1862,6 +1865,10 @@ function ChatViewContent(props: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + const threadAgents = useMemo( + () => deriveLatestAgentSnapshot(threadActivities), + [threadActivities], + ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -2926,6 +2933,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; @@ -2933,6 +2944,21 @@ function ChatViewContent(props: ChatViewProps) { }, [activeProject, activeThreadRef], ); + // Workflow scripts live in the provider's session directory, outside the + // workspace root the file surface can read. Open in-workspace paths in the + // file surface; copy the absolute path otherwise. + const openAgentScript = useCallback( + (scriptPath: string) => { + if (activeWorkspaceRoot && scriptPath.startsWith(`${activeWorkspaceRoot}/`)) { + openFileSurface(scriptPath.slice(activeWorkspaceRoot.length + 1)); + return; + } + void navigator.clipboard.writeText(scriptPath).then(() => { + toastManager.add({ title: "Script path copied", description: scriptPath }); + }); + }, + [activeWorkspaceRoot, openFileSurface], + ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -5150,6 +5176,8 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> + ) : activeRightPanelSurface?.kind === "agents" ? ( + ) : activeRightPanelSurface?.kind === "plan" ? ( )} + {threadAgents.length > 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 ; } } @@ -470,6 +484,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { Diff + + + Agents + ) : null} @@ -484,6 +502,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..0c158be4398 --- /dev/null +++ b/apps/web/src/components/chat/AgentsLiveStrip.tsx @@ -0,0 +1,63 @@ +import { memo } from "react"; +import type { ThreadAgentSnapshot } from "@t3tools/contracts"; +import { + deriveAgentPanelState, + formatAgentTokenCount, +} from "@t3tools/client-runtime/state/thread-agents"; +import { BotIcon, ChevronRightIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; + +/** + * Collapsed one-line agent roster shown near the composer while agents are + * live. Clicking opens the Agents panel — this strip is awareness only. + */ +const AgentsLiveStrip = memo(function AgentsLiveStrip({ + agents, + onOpen, +}: { + agents: ReadonlyArray; + onOpen: () => void; +}) { + const state = deriveAgentPanelState(agents); + const liveCount = state.runningCount + state.waitingCount; + if (liveCount === 0) { + return null; + } + + const runningPhase = state.groups + .flatMap((group) => group.phases) + .find((phase) => phase.status === "running"); + + return ( + + ); +}); + +export default AgentsLiveStrip; 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.ts b/apps/web/src/session-logic.ts index 5d5051f748e..8ee996c84db 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -633,6 +633,7 @@ export function deriveWorkLogEntries( if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; + if (activity.kind === "agent.snapshot") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..07707217c9e 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -119,6 +119,10 @@ "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" }, + "./state/thread-agents": { + "types": "./src/state/threadAgents.ts", + "default": "./src/state/threadAgents.ts" + }, "./state/threads": { "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" diff --git a/packages/client-runtime/src/state/threadAgents.ts b/packages/client-runtime/src/state/threadAgents.ts new file mode 100644 index 00000000000..276f3f379c2 --- /dev/null +++ b/packages/client-runtime/src/state/threadAgents.ts @@ -0,0 +1,137 @@ +/** + * Derivation helpers for the thread agent roster. + * + * The server ships the full per-thread roster latest-wins in the payload of + * `agent.snapshot` activities (see `@t3tools/contracts` ThreadAgentsActivityPayload). + * Mirrors the `context-window.updated` pattern: scan activities newest-first, + * decode tolerantly, ignore rows that fail to decode. + */ +import { + THREAD_AGENT_TERMINAL_STATUSES, + THREAD_AGENTS_ACTIVITY_KIND, + ThreadAgentsActivityPayload, + type OrchestrationThreadActivity, + type ThreadAgentSnapshot, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const decodePayload = Schema.decodeUnknownOption(ThreadAgentsActivityPayload); + +export function deriveLatestAgentSnapshot( + activities: ReadonlyArray, +): ReadonlyArray { + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity || activity.kind !== THREAD_AGENTS_ACTIVITY_KIND) { + continue; + } + const decoded = decodePayload(activity.payload); + if (decoded._tag === "Some") { + return decoded.value.agents; + } + } + return []; +} + +export function isTerminalAgentStatus(status: ThreadAgentSnapshot["status"]): boolean { + return THREAD_AGENT_TERMINAL_STATUSES.has(status); +} + +export interface AgentPanelGroup { + /** The workflow snapshot this group belongs to, or null for direct spawns. */ + readonly workflow: ThreadAgentSnapshot | null; + /** Phase sections in declared order; agents without a phase land in `rest`. */ + readonly phases: ReadonlyArray; + readonly rest: ReadonlyArray; +} + +export interface AgentPanelPhase { + readonly index: number; + readonly title: string; + readonly status: "pending" | "running" | "done"; + readonly agents: ReadonlyArray; +} + +export interface AgentPanelState { + readonly groups: ReadonlyArray; + readonly runningCount: number; + readonly waitingCount: number; + readonly settledCount: number; + readonly totalTokens: number; +} + +function phaseStatus(agents: ReadonlyArray): "pending" | "running" | "done" { + if (agents.length === 0) return "pending"; + if (agents.every((agent) => isTerminalAgentStatus(agent.status))) return "done"; + return "running"; +} + +export function deriveAgentPanelState(agents: ReadonlyArray): AgentPanelState { + const workflows = agents.filter((agent) => agent.kind === "workflow"); + const byParent = new Map(); + const direct: ThreadAgentSnapshot[] = []; + for (const agent of agents) { + if (agent.kind === "workflow") continue; + if (agent.parentAgentId) { + const list = byParent.get(agent.parentAgentId) ?? []; + list.push(agent); + byParent.set(agent.parentAgentId, list); + } else { + direct.push(agent); + } + } + + const groups: AgentPanelGroup[] = []; + for (const workflow of workflows) { + const members = byParent.get(workflow.agentId) ?? []; + byParent.delete(workflow.agentId); + const declaredPhases = workflow.phases ?? []; + const phases: AgentPanelPhase[] = declaredPhases.map((phase) => { + const phaseAgents = members.filter((agent) => agent.phaseIndex === phase.index); + return { + index: phase.index, + title: phase.title, + status: phaseStatus(phaseAgents), + agents: phaseAgents, + }; + }); + const inDeclaredPhase = new Set( + phases.flatMap((phase) => phase.agents.map((agent) => agent.agentId)), + ); + groups.push({ + workflow, + phases, + rest: members.filter((agent) => !inDeclaredPhase.has(agent.agentId)), + }); + } + // Orphaned parent groups (parent never materialized) fold into direct spawns. + for (const list of byParent.values()) { + direct.push(...list); + } + if (direct.length > 0) { + groups.push({ workflow: null, phases: [], rest: direct }); + } + + let runningCount = 0; + let waitingCount = 0; + let settledCount = 0; + let totalTokens = 0; + for (const agent of agents) { + if (agent.status === "running" || agent.status === "pending") runningCount += 1; + else if (agent.status === "waiting") waitingCount += 1; + else if (isTerminalAgentStatus(agent.status)) settledCount += 1; + totalTokens += agent.usage?.totalTokens ?? 0; + } + + return { groups, runningCount, waitingCount, settledCount, totalTokens }; +} + +export function formatAgentTokenCount(totalTokens: number): string { + if (totalTokens >= 1_000_000) { + return `${(totalTokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (totalTokens >= 1_000) { + return `${(totalTokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return `${totalTokens}`; +} From 7e898d22df4b0b2a79f08d418a9a2c80a8e80d2c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 20:40:05 -0700 Subject: [PATCH 03/17] feat(codex): track v2 collab child threads as agent telemetry CodexSessionRuntime learns children from subAgentActivity items (probe: no child thread/started arrives) and diverts their notifications into synthetic collab/agentActivity events; CodexAdapter maps those to timeline-bypassing task.* events (turn lifecycle, waiting flags, token usage, item summaries). turn/completed maps to idle, not terminal. Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/CodexAdapter.test.ts | 123 +++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 147 ++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 70 ++++++++- packages/contracts/src/providerRuntime.ts | 13 +- 4 files changed, 346 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4ae654a5187..9a5c820b05d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -555,6 +555,129 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps collab/agentActivity turn lifecycle to timeline-bypassing task events", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-collab-turn-start"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "collab/agentActivity", + threadId: asThreadId("thread-1"), + payload: { + agentThreadId: "child-thread-1", + agentPath: "/root/marlow", + method: "turn/started", + params: { threadId: "child-thread-1", turn: { id: "child-turn-1" } }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "task.started"); + if (firstEvent.value.type !== "task.started") { + return; + } + NodeAssert.equal(firstEvent.value.payload.taskId, "child-thread-1"); + NodeAssert.equal(firstEvent.value.payload.name, "marlow"); + NodeAssert.equal(firstEvent.value.payload.timelineBypass, true); + }), + ); + + it.effect("maps collab child turn/completed to an idle task.updated", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-collab-turn-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + method: "collab/agentActivity", + threadId: asThreadId("thread-1"), + payload: { + agentThreadId: "child-thread-1", + agentPath: "/root/marlow", + method: "turn/completed", + params: { threadId: "child-thread-1", turn: { id: "child-turn-1" } }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "task.updated"); + if (firstEvent.value.type !== "task.updated") { + return; + } + NodeAssert.equal(firstEvent.value.payload.status, "idle"); + NodeAssert.equal(firstEvent.value.payload.timelineBypass, true); + }), + ); + + it.effect("maps collab child token usage to task.progress with normalized usage", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-collab-usage"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + method: "collab/agentActivity", + threadId: asThreadId("thread-1"), + payload: { + agentThreadId: "child-thread-1", + agentPath: "/root/marlow", + method: "thread/tokenUsage/updated", + params: { + threadId: "child-thread-1", + turnId: "child-turn-1", + tokenUsage: { + total: { + totalTokens: 4200, + inputTokens: 4000, + cachedInputTokens: 1000, + outputTokens: 200, + reasoningOutputTokens: 50, + }, + last: { + totalTokens: 4200, + inputTokens: 4000, + cachedInputTokens: 1000, + outputTokens: 200, + reasoningOutputTokens: 50, + }, + modelContextWindow: 272_000, + }, + }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "task.progress"); + if (firstEvent.value.type !== "task.progress") { + return; + } + const usage = firstEvent.value.payload.usage as { totalTokens?: number } | undefined; + NodeAssert.equal(usage?.totalTokens, 4200); + }), + ); + it.effect("labels MCP lifecycle entries with server and tool names", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 38a5887cdc3..8d088648d70 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -20,6 +20,7 @@ import { type ProviderUserInputAnswers, RuntimeItemId, RuntimeRequestId, + RuntimeTaskId, ProviderApprovalDecision, ThreadId, ProviderSendTurnInput, @@ -494,10 +495,156 @@ function mapItemLifecycle( }; } +/** + * Codex v2 collab child threads have no task lifecycle of their own — the + * session runtime intercepts their notifications and forwards them wrapped as + * `collab/agentActivity`. Translate into the shared `task.*` family so the + * ingestion agent reducer treats Codex children like Claude subagents. All + * synthesized events set `timelineBypass` so they never become timeline rows. + * + * Probe-verified mapping (codex-cli 0.144.1): children emit no + * `thread/started`; their first event is `thread/status/changed`. Nicknames + * arrive via the parent's `subAgentActivity.agentPath` (`/root/`). + * `turn/completed` means idle-and-resumable, not terminal. + */ +function mapCollabAgentActivity( + event: ProviderEvent, + canonicalThreadId: ThreadId, +): ReadonlyArray { + const wrapper = + event.payload !== null && typeof event.payload === "object" + ? (event.payload as { + agentThreadId?: unknown; + agentPath?: unknown; + method?: unknown; + params?: unknown; + }) + : undefined; + if (!wrapper || typeof wrapper.agentThreadId !== "string" || typeof wrapper.method !== "string") { + return []; + } + const agentThreadId = wrapper.agentThreadId; + const agentPath = typeof wrapper.agentPath === "string" ? wrapper.agentPath : undefined; + // "/root/marlow" → "marlow" + const nickname = agentPath?.split("/").filter(Boolean).at(-1); + const inner: ProviderEvent = { + ...event, + method: wrapper.method, + ...(wrapper.params !== undefined ? { payload: wrapper.params } : {}), + }; + const base = { + ...runtimeEventBase(event, canonicalThreadId), + payload: { + taskId: RuntimeTaskId.make(agentThreadId), + description: nickname ?? agentThreadId, + name: nickname ?? agentThreadId, + taskType: "local_agent", + timelineBypass: true, + }, + } as const; + + switch (wrapper.method) { + case "turn/started": + return [{ ...base, type: "task.started", payload: { ...base.payload } }]; + case "turn/completed": + return [ + { + ...base, + type: "task.updated", + payload: { taskId: base.payload.taskId, status: "idle", timelineBypass: true }, + }, + ]; + case "thread/status/changed": { + const payload = readPayload( + EffectCodexSchema.V2ThreadStatusChangedNotification, + inner.payload, + ); + const status = payload?.status; + if (!status) return []; + if (status.type === "active" && status.activeFlags.length > 0) { + return [ + { + ...base, + type: "task.updated", + payload: { taskId: base.payload.taskId, status: "waiting", timelineBypass: true }, + }, + ]; + } + return []; + } + case "thread/closed": + return [ + { + ...base, + type: "task.completed", + payload: { taskId: base.payload.taskId, status: "stopped", timelineBypass: true }, + }, + ]; + case "thread/tokenUsage/updated": { + const payload = readPayload( + EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification, + inner.payload, + ); + const usage = payload ? normalizeCodexTokenUsage(payload.tokenUsage) : undefined; + if (!usage) return []; + return [ + { + ...base, + type: "task.progress", + payload: { + ...base.payload, + usage: { + totalTokens: usage.usedTokens, + ...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}), + ...(usage.cachedInputTokens !== undefined + ? { cachedInputTokens: usage.cachedInputTokens } + : {}), + ...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens } : {}), + ...(usage.reasoningOutputTokens !== undefined + ? { reasoningOutputTokens: usage.reasoningOutputTokens } + : {}), + }, + }, + }, + ]; + } + case "item/started": + case "item/completed": { + const payload = + readPayload(EffectCodexSchema.V2ItemStartedNotification, inner.payload) ?? + readPayload(EffectCodexSchema.V2ItemCompletedNotification, inner.payload); + const item = payload?.item; + if (!item) return []; + const itemType = toCanonicalItemType(item.type); + const title = itemTitle(itemType, item); + const detail = itemDetail(itemType, item); + const summary = detail ?? title; + if (!summary) return []; + return [ + { + ...base, + type: "task.progress", + payload: { + ...base.payload, + description: summary, + summary, + ...(title ? { lastToolName: title } : {}), + }, + }, + ]; + } + default: + return []; + } +} + function mapToRuntimeEvents( event: ProviderEvent, canonicalThreadId: ThreadId, ): ReadonlyArray { + if (event.kind === "notification" && event.method === "collab/agentActivity") { + return mapCollabAgentActivity(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 5a81e915e34..18a3fba8ec1 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -585,6 +585,38 @@ function readRouteFields(notification: CodexServerNotification): { } } +interface CollabChildAgent { + readonly agentPath: string; + readonly interrupted: boolean; +} + +/** + * v2 collab children announce themselves only via `subAgentActivity` items on + * the parent stream (the probe shows no child `thread/started`, and v2 + * `collabAgentToolCall` items carry empty receiver lists). Track them so their + * notifications can be folded into agent telemetry instead of leaking into the + * parent timeline as unattributed items. + */ +function rememberSubAgentActivity( + childAgents: Map, + notification: CodexServerNotification, +): void { + if (notification.method !== "item/started" && notification.method !== "item/completed") { + return; + } + const item = notification.params.item; + if (item.type !== "subAgentActivity") { + return; + } + childAgents.set(item.agentThreadId, { + agentPath: item.agentPath, + interrupted: item.kind === "interrupted", + }); +} + +/** Synthetic method carrying an intercepted child-thread notification. */ +export const COLLAB_AGENT_ACTIVITY_METHOD = "collab/agentActivity"; + function rememberCollabReceiverTurns( collabReceiverTurns: Map, notification: CodexServerNotification, @@ -708,6 +740,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 @@ -829,14 +862,39 @@ export const makeCodexSessionRuntime = ( const payload = notification.params; const route = readRouteFields(notification); const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); - const childParentTurnId = (() => { - const providerConversationId = readNotificationThreadId(notification); - return providerConversationId - ? collabReceiverTurns.get(providerConversationId) - : undefined; - })(); + const collabChildAgents = yield* Ref.get(collabChildAgentsRef); + const notificationThreadId = readNotificationThreadId(notification); + const childParentTurnId = notificationThreadId + ? collabReceiverTurns.get(notificationThreadId) + : undefined; rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); + rememberSubAgentActivity(collabChildAgents, notification); + yield* Ref.set(collabChildAgentsRef, collabChildAgents); + + // v2 collab child threads: their notifications arrive on this + // connection keyed by the child threadId. Divert the whole stream into + // a synthetic collab/agentActivity event for the agent tracker and keep + // it out of the parent timeline. + const childAgent = notificationThreadId + ? collabChildAgents.get(notificationThreadId) + : undefined; + if (childAgent && notificationThreadId) { + yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: COLLAB_AGENT_ACTIVITY_METHOD, + payload: { + agentThreadId: notificationThreadId, + agentPath: childAgent.agentPath, + method: notification.method, + params: payload, + }, + }); + return; + } + if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) { yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); return; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index e76850b9810..2b86b18a93d 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -530,8 +530,19 @@ export type TaskProgressPayload = typeof TaskProgressPayload.Type; */ const TaskUpdatedPayload = Schema.Struct({ taskId: RuntimeTaskId, + // "idle" = finished a run but resumable (Codex agents between activations); + // "waiting" = blocked on approval/user input. status: Schema.optional( - Schema.Literals(["pending", "running", "completed", "failed", "killed", "paused"]), + Schema.Literals([ + "pending", + "running", + "completed", + "failed", + "killed", + "paused", + "idle", + "waiting", + ]), ), endTime: Schema.optional(IsoDateTime), isBackgrounded: Schema.optional(Schema.Boolean), From 5472e9dadbfc703101f42ce6a0e53969e9a02802 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 20:40:13 -0700 Subject: [PATCH 04/17] feat(server): Claude agent lifecycle fields + ingestion agent roster reducer ClaudeAdapter carries subagent linkage (agentType/toolUseId/workflowName/ prompt/outputFile), handles task_updated, parses workflow_progress into child workflow_agent task events, normalizes task usage, and captures Workflow tool results (scriptPath/runId). Ingestion folds task.* events into per-thread ThreadAgentSnapshot rosters with material-transition filtering, projection hydration, orphan sweep, and 50-cap retention, appended as agent.snapshot activities. Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 113 ++++++- .../Layers/ProviderRuntimeIngestion.ts | 287 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.test.ts | 117 +++++++ .../src/provider/Layers/ClaudeAdapter.ts | 218 ++++++++++++- 4 files changed, 725 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 20611e1ee75..d90d0f1b7c6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -9,6 +9,8 @@ import { ProviderRuntimeEvent, ProviderSession, ProviderInstanceId, + RuntimeTaskId, + type ThreadAgentSnapshot, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -43,7 +45,11 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; -import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { + foldTaskAgentEvent, + ProviderRuntimeIngestionLive, + pruneSettledAgents, +} from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -318,6 +324,111 @@ describe("ProviderRuntimeIngestion", () => { }; } + it("folds material transitions, reactivations, and settled retention", () => { + const agents = new Map(); + const started: Extract = { + type: "task.started", + eventId: asEventId("agent-started"), + provider: ProviderDriverKind.make("claudeAgent"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + payload: { + taskId: RuntimeTaskId.make("agent-1"), + description: "Reviewer", + taskType: "local_agent", + }, + }; + expect(foldTaskAgentEvent(agents, started)).toBe(true); + const usageTick: Extract = { + ...started, + type: "task.progress", + eventId: asEventId("agent-progress"), + createdAt: "2026-01-01T00:00:01.000Z", + payload: { + taskId: RuntimeTaskId.make("agent-1"), + description: "Reviewer", + usage: { totalTokens: 10 }, + }, + }; + expect(foldTaskAgentEvent(agents, usageTick)).toBe(false); + const completed: Extract = { + ...started, + type: "task.completed", + eventId: asEventId("agent-completed"), + createdAt: "2026-01-01T00:00:02.000Z", + payload: { taskId: RuntimeTaskId.make("agent-1"), status: "completed" }, + }; + expect(foldTaskAgentEvent(agents, completed)).toBe(true); + expect( + foldTaskAgentEvent(agents, { ...started, eventId: asEventId("agent-reactivated") }), + ).toBe(true); + expect(agents.get("agent-1")?.activationCount).toBe(2); + + for (let index = 0; index < 51; index += 1) { + agents.set(`settled-${index}`, { + agentId: `settled-${index}`, + provider: ProviderDriverKind.make("claudeAgent"), + kind: "subagent", + name: `Agent ${index}`, + status: "completed", + firstStartedAt: `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`, + lastActivityAt: `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`, + activationCount: 1, + recentActivity: [], + updatedAt: `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`, + }); + } + pruneSettledAgents(agents); + expect( + Array.from(agents.values()).filter((agent) => agent.status === "completed"), + ).toHaveLength(50); + expect(agents.has("settled-0")).toBe(false); + }); + + it("appends latest-wins agent rosters only on material changes and sweeps orphans", async () => { + const harness = await createHarness(); + harness.emit({ + type: "task.started", + eventId: asEventId("evt-agent-start"), + provider: ProviderDriverKind.make("claudeAgent"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:01.000Z", + payload: { taskId: "agent-1", description: "Reviewer", taskType: "local_agent" }, + }); + await harness.drain(); + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-agent-usage"), + provider: ProviderDriverKind.make("claudeAgent"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:02.000Z", + payload: { taskId: "agent-1", description: "Reviewer", usage: { totalTokens: 10 } }, + }); + await harness.drain(); + let thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + expect( + thread?.activities.filter((activity) => activity.kind === "agent.snapshot"), + ).toHaveLength(1); + + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-session-exit-agents"), + provider: ProviderDriverKind.make("claudeAgent"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:03.000Z", + payload: { reason: "process_exit" }, + }); + await harness.drain(); + thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + const snapshots = + thread?.activities.filter((activity) => activity.kind === "agent.snapshot") ?? []; + expect(snapshots).toHaveLength(2); + const latest = snapshots.at(-1)?.payload as { agents: Array } | undefined; + expect(latest?.agents).toHaveLength(1); + expect(latest?.agents[0]?.status).toBe("stopped"); + expect(latest?.agents[0]?.errorMessage).toBe("orphaned on session exit"); + }); + it("maps turn started/completed events into thread session updates", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 09566feb2b2..e7d42f03b71 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, type AssistantDeliveryMode, CommandId, + EventId, MessageId, type OrchestrationEvent, type OrchestrationMessage, @@ -9,6 +10,12 @@ import { CheckpointRef, isToolLifecycleItemType, ThreadId, + THREAD_AGENTS_ACTIVITY_KIND, + THREAD_AGENT_RECENT_ACTIVITY_LIMIT, + THREAD_AGENT_TERMINAL_STATUSES, + type ThreadAgentSnapshot, + type ThreadAgentStatus, + type ThreadAgentUsage, type ThreadTokenUsageSnapshot, TurnId, type OrchestrationCheckpointSummary, @@ -41,6 +48,198 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; +const AGENT_SETTLED_RETENTION_LIMIT = 50; +const AGENT_USAGE_MATERIAL_STEP = 25_000; + +function taskUsage(value: unknown): ThreadAgentUsage | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const usage = value as Record; + if (typeof usage.totalTokens !== "number" || usage.totalTokens < 0) return undefined; + return { + totalTokens: usage.totalTokens, + ...(typeof usage.inputTokens === "number" ? { inputTokens: usage.inputTokens } : {}), + ...(typeof usage.cachedInputTokens === "number" + ? { cachedInputTokens: usage.cachedInputTokens } + : {}), + ...(typeof usage.outputTokens === "number" ? { outputTokens: usage.outputTokens } : {}), + ...(typeof usage.reasoningOutputTokens === "number" + ? { reasoningOutputTokens: usage.reasoningOutputTokens } + : {}), + ...(typeof usage.toolUses === "number" ? { toolUses: usage.toolUses } : {}), + }; +} + +function taskStatus(event: ProviderRuntimeEvent): ThreadAgentStatus | undefined { + if (event.type === "task.started") return "running"; + if (event.type === "task.completed") return event.payload.status; + if (event.type !== "task.updated" || !event.payload.status) return undefined; + return event.payload.status === "killed" + ? "stopped" + : event.payload.status === "paused" + ? "idle" + : event.payload.status; +} + +function taskKind(taskType: string | undefined, parentTaskId: string | undefined) { + if (taskType === "local_agent") return "subagent" as const; + if (taskType === "local_workflow") return "workflow" as const; + if (taskType === "workflow_agent" || parentTaskId) return "workflow_agent" as const; + if (taskType === "local_bash" || taskType === "shell") return "shell" as const; + if (taskType === "monitor") return "monitor" as const; + return "other" as const; +} + +function isTaskAgentEvent( + event: ProviderRuntimeEvent, +): event is Extract< + ProviderRuntimeEvent, + { type: "task.started" | "task.progress" | "task.updated" | "task.completed" } +> { + return ( + event.type === "task.started" || + event.type === "task.progress" || + event.type === "task.updated" || + event.type === "task.completed" + ); +} + +function workflowPhasesEqual( + left: ThreadAgentSnapshot["phases"], + right: ThreadAgentSnapshot["phases"], +): boolean { + return ( + left === right || + (left !== undefined && + right !== undefined && + left.length === right.length && + left.every( + (phase, index) => + phase.index === right[index]?.index && phase.title === right[index]?.title, + )) + ); +} + +export function foldTaskAgentEvent( + agents: Map, + event: Extract< + ProviderRuntimeEvent, + { type: "task.started" | "task.progress" | "task.updated" | "task.completed" } + >, +): boolean { + const payload = event.payload; + const previous = agents.get(payload.taskId); + const status = taskStatus(event) ?? previous?.status ?? "running"; + const description = "description" in payload ? payload.description : undefined; + const summary = "summary" in payload ? payload.summary : undefined; + const nextUsage = "usage" in payload ? taskUsage(payload.usage) : undefined; + const lastToolName = "lastToolName" in payload ? payload.lastToolName : undefined; + const currentActivity = summary ?? lastToolName ?? previous?.currentActivity; + const activityChanged = + (summary !== undefined && summary !== previous?.currentActivity) || + (lastToolName !== undefined && lastToolName !== previous?.lastToolName); + const recentActivity = activityChanged + ? [ + ...(previous?.recentActivity ?? []), + { at: event.createdAt, summary: summary ?? lastToolName ?? "Activity updated" }, + ].slice(-THREAD_AGENT_RECENT_ACTIVITY_LIMIT) + : (previous?.recentActivity ?? []); + const wasSettled = + previous !== undefined && + (previous.status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(previous.status)); + const reactivated = wasSettled && status === "running"; + const explicitEndTime = event.type === "task.updated" ? event.payload.endTime : undefined; + const terminal = THREAD_AGENT_TERMINAL_STATUSES.has(status); + const next: ThreadAgentSnapshot = { + agentId: payload.taskId, + provider: event.provider, + kind: + previous?.kind ?? + taskKind("taskType" in payload ? payload.taskType : undefined, payload.parentTaskId), + name: payload.name ?? description ?? payload.workflowName ?? previous?.name ?? payload.taskId, + ...((payload.agentType ?? previous?.agentType) + ? { agentType: payload.agentType ?? previous?.agentType } + : {}), + ...((payload.model ?? previous?.model) ? { model: payload.model ?? previous?.model } : {}), + status, + ...(currentActivity ? { currentActivity } : {}), + ...((lastToolName ?? previous?.lastToolName) + ? { lastToolName: lastToolName ?? previous?.lastToolName } + : {}), + ...((nextUsage ?? previous?.usage) ? { usage: nextUsage ?? previous?.usage } : {}), + firstStartedAt: previous?.firstStartedAt ?? event.createdAt, + lastActivityAt: event.createdAt, + ...(!reactivated && (explicitEndTime ?? (terminal ? event.createdAt : previous?.endedAt)) + ? { endedAt: explicitEndTime ?? (terminal ? event.createdAt : previous?.endedAt) } + : {}), + activationCount: previous ? previous.activationCount + (reactivated ? 1 : 0) : 1, + ...((event.turnId ?? previous?.lastTurnId) + ? { lastTurnId: TurnId.make(String(event.turnId ?? previous?.lastTurnId)) } + : {}), + ...((payload.parentTaskId ?? previous?.parentAgentId) + ? { parentAgentId: payload.parentTaskId ?? previous?.parentAgentId } + : {}), + ...(payload.phaseIndex !== undefined || previous?.phaseIndex !== undefined + ? { phaseIndex: payload.phaseIndex ?? previous?.phaseIndex } + : {}), + ...((payload.phaseTitle ?? previous?.phaseTitle) + ? { phaseTitle: payload.phaseTitle ?? previous?.phaseTitle } + : {}), + ...((payload.phases ?? previous?.phases) ? { phases: payload.phases ?? previous?.phases } : {}), + ...((payload.scriptPath ?? previous?.scriptPath) + ? { scriptPath: payload.scriptPath ?? previous?.scriptPath } + : {}), + ...((payload.runId ?? previous?.runId) ? { runId: payload.runId ?? previous?.runId } : {}), + ...((payload.outputFile ?? previous?.outputFile) + ? { outputFile: payload.outputFile ?? previous?.outputFile } + : {}), + ...(event.type === "task.completed" && event.payload.summary + ? { resultSummary: event.payload.summary } + : previous?.resultSummary + ? { resultSummary: previous.resultSummary } + : {}), + ...(event.type === "task.updated" && event.payload.errorMessage + ? { errorMessage: event.payload.errorMessage } + : previous?.errorMessage + ? { errorMessage: previous.errorMessage } + : {}), + recentActivity, + updatedAt: event.createdAt, + }; + agents.set(payload.taskId, next); + if (!previous) return true; + const usageStepChanged = + Math.floor((previous.usage?.totalTokens ?? 0) / AGENT_USAGE_MATERIAL_STEP) !== + Math.floor((next.usage?.totalTokens ?? 0) / AGENT_USAGE_MATERIAL_STEP); + return ( + previous.status !== next.status || + previous.name !== next.name || + previous.model !== next.model || + previous.phaseIndex !== next.phaseIndex || + previous.phaseTitle !== next.phaseTitle || + !workflowPhasesEqual(previous.phases, next.phases) || + previous.currentActivity !== next.currentActivity || + activityChanged || + usageStepChanged || + previous.endedAt !== next.endedAt || + previous.resultSummary !== next.resultSummary || + previous.errorMessage !== next.errorMessage || + previous.scriptPath !== next.scriptPath || + previous.runId !== next.runId || + previous.outputFile !== next.outputFile + ); +} + +export function pruneSettledAgents(agents: Map): void { + const settled = Array.from(agents.values()) + .filter((agent) => agent.status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(agent.status)) + .sort((left, right) => left.lastActivityAt.localeCompare(right.lastActivityAt)); + for (const agent of settled.slice( + 0, + Math.max(0, settled.length - AGENT_SETTLED_RETENTION_LIMIT), + )) { + agents.delete(agent.agentId); + } +} // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier @@ -310,6 +509,12 @@ export function runtimeEventToActivities( event: ProviderRuntimeEvent, taskTitle?: string, ): ReadonlyArray { + if ( + (isTaskAgentEvent(event) && event.payload.timelineBypass === true) || + event.type === "task.updated" + ) { + return []; + } const maybeSequence = (() => { const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number }; return eventWithSequence.sessionSequence !== undefined @@ -693,6 +898,9 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; + const agentsByThread = new Map>(); + const hydratedAgentThreads = new Set(); + let agentSnapshotDispatchCount = 0; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), @@ -1305,6 +1513,85 @@ const make = Effect.gen(function* () { return loadedThreadDetail; }); + if (!hydratedAgentThreads.has(thread.id)) { + const detail = yield* getLoadedThreadDetail(); + const latestSnapshot = detail?.activities.findLast( + (activity) => activity.kind === THREAD_AGENTS_ACTIVITY_KIND, + ); + const payload = + latestSnapshot?.payload !== null && typeof latestSnapshot?.payload === "object" + ? (latestSnapshot.payload as { agents?: unknown }) + : undefined; + const agents = new Map(); + if (Array.isArray(payload?.agents)) { + for (const candidate of payload.agents) { + if ( + candidate !== null && + typeof candidate === "object" && + typeof (candidate as { agentId?: unknown }).agentId === "string" + ) { + const agent = candidate as ThreadAgentSnapshot; + agents.set(agent.agentId, agent); + } + } + } + agentsByThread.set(thread.id, agents); + hydratedAgentThreads.add(thread.id); + } + + const threadAgents = agentsByThread.get(thread.id) ?? new Map(); + agentsByThread.set(thread.id, threadAgents); + let agentRosterMaterial = false; + if (isTaskAgentEvent(event)) { + agentRosterMaterial = foldTaskAgentEvent(threadAgents, event); + } else if (event.type === "session.exited") { + for (const [agentId, agent] of threadAgents) { + if (THREAD_AGENT_TERMINAL_STATUSES.has(agent.status)) continue; + threadAgents.set(agentId, { + ...agent, + status: "stopped", + endedAt: event.createdAt, + lastActivityAt: event.createdAt, + errorMessage: "orphaned on session exit", + updatedAt: event.createdAt, + }); + agentRosterMaterial = true; + } + } + if (agentRosterMaterial) { + pruneSettledAgents(threadAgents); + const roster = Array.from(threadAgents.values()); + const running = roster.filter((agent) => agent.status === "running").length; + const settled = roster.filter( + (agent) => agent.status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(agent.status), + ).length; + const count = running > 0 ? running : settled; + const summary = `${count} ${count === 1 ? "agent" : "agents"} ${running > 0 ? "running" : "settled"}`; + const snapshotUuid = yield* crypto.randomUUIDv4; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`${event.eventId}:agent-snapshot:${snapshotUuid}`), + createdAt: event.createdAt, + tone: "info", + kind: THREAD_AGENTS_ACTIVITY_KIND, + summary, + payload: { agents: roster }, + turnId: toTurnId(event.turnId) ?? null, + }; + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* providerCommandId(event, "agent-snapshot-append"), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }); + agentSnapshotDispatchCount += 1; + yield* Effect.logDebug("provider agent snapshot appended", { + threadId: thread.id, + agentCount: roster.length, + dispatchCount: agentSnapshotDispatchCount, + }); + } + const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2735b69a187..91581f90c48 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1704,6 +1704,123 @@ describe("ClaudeAdapterLive", () => { "Code reviewer checked the migration edge cases.", ); assert.equal(progressEvent.payload.description, "Running background teammate"); + assert.deepEqual(progressEvent.payload.usage, { + totalTokens: 123, + toolUses: 4, + durationMs: 987, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("carries Claude task linkage, updates, and workflow progress", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "workflow-1", + description: "Run checks", + task_type: "local_workflow", + subagent_type: "Workflow", + tool_use_id: "tool-1", + workflow_name: "Verification", + prompt: "Check the repository", + session_id: "session", + uuid: "started", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "workflow-1", + description: "Run checks", + subagent_type: "Workflow", + tool_use_id: "tool-1", + usage: { total_tokens: 12, tool_uses: 2, duration_ms: 30 }, + workflow_progress: [ + { type: "workflow_phase", index: 0, title: "Inspect" }, + { + type: "workflow_agent", + label: "Reviewer", + phaseIndex: 0, + phaseTitle: "Inspect", + agentId: "agent-1", + model: "claude-sonnet", + state: "start", + attempt: 1, + tokens: 7, + toolCalls: 1, + lastToolName: "Read", + }, + ], + session_id: "session", + uuid: "progress", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_updated", + task_id: "workflow-1", + patch: { + status: "paused", + end_time: 1_700_000_000_000, + is_backgrounded: true, + error: "waiting", + }, + session_id: "session", + uuid: "updated", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + runtimeEventsFiber.interruptUnsafe(); + + const started = runtimeEvents.find((event) => event.type === "task.started"); + assert.equal(started?.type, "task.started"); + if (started?.type === "task.started") { + assert.equal(started.payload.name, "Run checks"); + assert.equal(started.payload.agentType, "Workflow"); + assert.equal(started.payload.toolUseId, "tool-1"); + assert.equal(started.payload.workflowName, "Verification"); + assert.equal(started.payload.prompt, "Check the repository"); + } + const phasePatch = runtimeEvents.find( + (event) => event.type === "task.updated" && event.payload.phases !== undefined, + ); + assert.equal(phasePatch?.type, "task.updated"); + if (phasePatch?.type === "task.updated") { + assert.deepEqual(phasePatch.payload.phases, [{ index: 0, title: "Inspect" }]); + } + const child = runtimeEvents.find( + (event) => event.type === "task.started" && event.payload.taskId === "agent-1", + ); + assert.equal(child?.type, "task.started"); + if (child?.type === "task.started") { + assert.equal(child.payload.parentTaskId, "workflow-1"); + assert.equal(child.payload.timelineBypass, true); + assert.equal(child.payload.taskType, "workflow_agent"); + } + const statusPatch = runtimeEvents.find( + (event) => event.type === "task.updated" && event.payload.status === "paused", + ); + assert.equal(statusPatch?.type, "task.updated"); + if (statusPatch?.type === "task.updated") { + assert.equal(statusPatch.payload.endTime, "2023-11-14T22:13:20.000Z"); + assert.equal(statusPatch.payload.isBackgrounded, true); + assert.equal(statusPatch.payload.errorMessage, "waiting"); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 757d7a00eb2..8f0febdfd87 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -704,6 +704,32 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } +function readNonNegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; +} + +function epochMillisToIso(value: number): string { + return DateTime.formatIso(DateTime.makeUnsafe(value)); +} + +function normalizeRuntimeTaskUsage(value: unknown) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const usage = value as Record; + const totalTokens = readNonNegativeInteger(usage.total_tokens); + if (totalTokens === undefined) { + return undefined; + } + const toolUses = readNonNegativeInteger(usage.tool_uses); + const durationMs = readNonNegativeInteger(usage.duration_ms); + return { + totalTokens, + ...(toolUses !== undefined ? { toolUses } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }; +} + function readStringArray(value: unknown): Array { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) @@ -2388,6 +2414,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); + if (!toolResult.isError && tool.toolName === "Workflow" && toolUseResult) { + const taskId = readString(toolUseResult.taskId); + if (taskId) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.updated", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + ...(readString(toolUseResult.scriptPath) + ? { scriptPath: readString(toolUseResult.scriptPath) } + : {}), + ...(readString(toolUseResult.runId) + ? { runId: readString(toolUseResult.runId) } + : {}), + ...(readString(toolUseResult.workflowName) + ? { workflowName: readString(toolUseResult.workflowName) } + : {}), + }, + }); + } + } + const streamKind = toolResultStreamKind(tool.itemType); if (streamKind && toolResult.text.length > 0 && context.turnState) { const deltaStamp = yield* makeEventStamp(); @@ -2676,18 +2729,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; - case "task_started": + case "task_started": { + const record = message as unknown as Record; + const agentType = readString(record.subagent_type); + const toolUseId = readString(record.tool_use_id); + const workflowName = readString(record.workflow_name); + const prompt = readString(record.prompt); yield* offerRuntimeEvent({ ...base, type: "task.started", payload: { taskId: RuntimeTaskId.make(message.task_id), description: message.description, + name: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), + ...(agentType ? { agentType } : {}), + ...(toolUseId ? { toolUseId } : {}), + ...(workflowName ? { workflowName } : {}), + ...(prompt ? { prompt } : {}), }, }); return; - case "task_progress": + } + case "task_progress": { + const record = message as unknown as Record; + const agentType = readString(record.subagent_type); + const toolUseId = readString(record.tool_use_id); + const usage = normalizeRuntimeTaskUsage(record.usage); yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2703,17 +2771,142 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.summary ? { summary: message.summary } : {}), - ...(message.usage ? { usage: message.usage } : {}), + ...(usage ? { usage } : {}), ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + ...(agentType ? { agentType } : {}), + ...(toolUseId ? { toolUseId } : {}), }, }); + const workflowProgress = record.workflow_progress; + if (Array.isArray(workflowProgress)) { + const phases = workflowProgress.flatMap((entry) => { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return []; + const item = entry as Record; + if (item.type !== "workflow_phase") return []; + const index = readNonNegativeInteger(item.index); + const title = readString(item.title); + return index !== undefined && title ? [{ index, title }] : []; + }); + if (phases.length > 0) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.updated", + payload: { + taskId: RuntimeTaskId.make(message.task_id), + phases, + timelineBypass: true, + }, + }); + } + for (const entry of workflowProgress) { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue; + const item = entry as Record; + if (item.type !== "workflow_agent") continue; + const agentId = readString(item.agentId); + const label = readString(item.label); + if (!agentId || !label) continue; + const state = readString(item.state); + const childUsage = readNonNegativeInteger(item.tokens); + const childToolUses = readNonNegativeInteger(item.toolCalls); + const childPayload = { + taskId: RuntimeTaskId.make(agentId), + description: label, + name: label, + taskType: "workflow_agent", + parentTaskId: RuntimeTaskId.make(message.task_id), + timelineBypass: true as const, + ...(readString(item.model) ? { model: readString(item.model) } : {}), + ...(readNonNegativeInteger(item.phaseIndex) !== undefined + ? { phaseIndex: readNonNegativeInteger(item.phaseIndex) } + : {}), + ...(readString(item.phaseTitle) ? { phaseTitle: readString(item.phaseTitle) } : {}), + ...(readNonNegativeInteger(item.attempt) !== undefined + ? { attempt: readNonNegativeInteger(item.attempt) } + : {}), + ...(readString(item.lastToolName) + ? { lastToolName: readString(item.lastToolName) } + : {}), + ...(childUsage !== undefined + ? { + usage: { + totalTokens: childUsage, + ...(childToolUses !== undefined ? { toolUses: childToolUses } : {}), + }, + } + : {}), + }; + const stamp = yield* makeEventStamp(); + if (state === "start") { + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.started", + payload: childPayload, + }); + } else if (state === "done" || state === "error") { + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.completed", + payload: { + ...childPayload, + status: state === "done" ? "completed" : "failed", + ...(readString(item.error) ? { summary: readString(item.error) } : {}), + }, + }); + } else { + yield* offerRuntimeEvent({ + ...base, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + type: "task.progress", + payload: childPayload, + }); + } + } + } 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": { + const record = message as unknown as Record; + // SDK shape: {task_id, patch: {status?, end_time?, is_backgrounded?, error?}} + const patch = + record.patch !== null && typeof record.patch === "object" && !Array.isArray(record.patch) + ? (record.patch as Record) + : {}; + const status = readString(patch.status); + const endTime = + typeof patch.end_time === "number" ? epochMillisToIso(patch.end_time) : undefined; + yield* offerRuntimeEvent({ + ...base, + type: "task.updated", + payload: { + taskId: RuntimeTaskId.make(String(record.task_id)), + ...(status === "pending" || + status === "running" || + status === "completed" || + status === "failed" || + status === "killed" || + status === "paused" + ? { status } + : {}), + ...(endTime ? { endTime } : {}), + ...(typeof patch.is_backgrounded === "boolean" + ? { isBackgrounded: patch.is_backgrounded } + : {}), + ...(readString(patch.error) ? { errorMessage: readString(patch.error) } : {}), + }, + }); return; - case "task_notification": + } + case "task_notification": { + const record = message as unknown as Record; + const usage = normalizeRuntimeTaskUsage(record.usage); yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2729,10 +2922,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), status: message.status, ...(message.summary ? { summary: message.summary } : {}), - ...(message.usage ? { usage: message.usage } : {}), + ...(usage ? { usage } : {}), + ...(readString(record.output_file) + ? { outputFile: readString(record.output_file) } + : {}), + ...(readString(record.tool_use_id) + ? { toolUseId: readString(record.tool_use_id) } + : {}), }, }); return; + } case "files_persisted": yield* offerRuntimeEvent({ ...base, From 877bcca6986bc8f687aa0b0852236ce88b6c4e4c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 20:56:21 -0700 Subject: [PATCH 05/17] test(client-runtime): thread agent derivation tests against persisted payload Co-Authored-By: Claude Fable 5 --- .../src/state/threadAgents.test.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 packages/client-runtime/src/state/threadAgents.test.ts diff --git a/packages/client-runtime/src/state/threadAgents.test.ts b/packages/client-runtime/src/state/threadAgents.test.ts new file mode 100644 index 00000000000..713e936207e --- /dev/null +++ b/packages/client-runtime/src/state/threadAgents.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { OrchestrationThreadActivity, ThreadAgentSnapshot } from "@t3tools/contracts"; +import { + deriveAgentPanelState, + deriveLatestAgentSnapshot, + formatAgentTokenCount, +} from "./threadAgents.ts"; + +// Shape captured from a real server run (integrated verification, 2026-07-20). +const persistedAgent = { + agentId: "a732c41b4b7ba7742", + provider: "claudeAgent", + kind: "subagent", + name: "Say hi", + agentType: "general-purpose", + status: "completed", + usage: { totalTokens: 22_798, toolUses: 0 }, + firstStartedAt: "2026-07-21T03:52:02.264Z", + lastActivityAt: "2026-07-21T03:52:03.936Z", + endedAt: "2026-07-21T03:52:03.936Z", + activationCount: 1, + lastTurnId: "45609775-4b3b-4444-879d-1d9f25a1b954", + resultSummary: "Hi! How can I help you today?", + recentActivity: [], + updatedAt: "2026-07-21T03:52:03.936Z", +}; + +function activity(kind: string, payload: unknown, sequence: number): OrchestrationThreadActivity { + return { + id: `evt-${sequence}`, + tone: "info", + kind, + summary: "agents", + payload, + turnId: null, + sequence, + createdAt: "2026-07-21T03:52:03.936Z", + } as OrchestrationThreadActivity; +} + +describe("deriveLatestAgentSnapshot", () => { + it("decodes a persisted server payload and returns the newest roster", () => { + const agents = deriveLatestAgentSnapshot([ + activity("agent.snapshot", { agents: [{ ...persistedAgent, status: "running" }] }, 1), + activity("context-window.updated", { usedTokens: 10 }, 2), + activity("agent.snapshot", { agents: [persistedAgent] }, 3), + ]); + expect(agents).toHaveLength(1); + expect(agents[0]?.status).toBe("completed"); + expect(agents[0]?.usage?.totalTokens).toBe(22_798); + expect(agents[0]?.resultSummary).toBe("Hi! How can I help you today?"); + }); + + it("skips rows whose payload fails to decode instead of failing", () => { + const agents = deriveLatestAgentSnapshot([ + activity("agent.snapshot", { agents: [persistedAgent] }, 1), + activity("agent.snapshot", { agents: [{ bogus: true }] }, 2), + ]); + expect(agents).toHaveLength(1); + expect(agents[0]?.agentId).toBe(persistedAgent.agentId); + }); + + it("returns an empty roster when no snapshot activity exists", () => { + expect(deriveLatestAgentSnapshot([activity("task.progress", {}, 1)])).toHaveLength(0); + }); +}); + +describe("deriveAgentPanelState", () => { + const base = deriveLatestAgentSnapshot([ + activity("agent.snapshot", { agents: [persistedAgent] }, 1), + ]); + + it("counts settled agents and sums tokens", () => { + const state = deriveAgentPanelState(base); + expect(state.settledCount).toBe(1); + expect(state.runningCount).toBe(0); + expect(state.totalTokens).toBe(22_798); + expect(state.groups).toHaveLength(1); + expect(state.groups[0]?.workflow).toBeNull(); + }); + + it("groups workflow members under declared phases with derived status", () => { + const workflow: ThreadAgentSnapshot = { + ...(base[0] as ThreadAgentSnapshot), + agentId: "wf-1", + kind: "workflow", + name: "audit", + status: "running", + phases: [ + { index: 0, title: "Audit" }, + { index: 1, title: "Verify" }, + ], + }; + const member: ThreadAgentSnapshot = { + ...(base[0] as ThreadAgentSnapshot), + agentId: "wa-1", + kind: "workflow_agent", + parentAgentId: "wf-1", + phaseIndex: 0, + status: "completed", + }; + const state = deriveAgentPanelState([workflow, member]); + const group = state.groups[0]; + expect(group?.workflow?.agentId).toBe("wf-1"); + expect(group?.phases[0]?.status).toBe("done"); + expect(group?.phases[1]?.status).toBe("pending"); + }); +}); + +describe("formatAgentTokenCount", () => { + it("formats counts at k/M scale", () => { + expect(formatAgentTokenCount(950)).toBe("950"); + expect(formatAgentTokenCount(22_798)).toBe("22.8k"); + expect(formatAgentTokenCount(1_200_000)).toBe("1.2M"); + }); +}); From c513a4031df5a879904e3e7a52a22f02735ec162 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 20:56:45 -0700 Subject: [PATCH 06/17] fix(web): truncate long agent-type badges in agent cards Co-Authored-By: Claude Fable 5 --- apps/web/src/components/AgentsPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 89d50e93400..2acd979bae0 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -108,9 +108,9 @@ function AgentCard({ agent }: { agent: ThreadAgentSnapshot }) { >
- {agent.name} + {agent.name} {agent.agentType ? ( - + {agent.agentType} ) : null} From f99429b371134b08962202fb3282dcb52477654d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 20:59:13 -0700 Subject: [PATCH 07/17] fix: emit running on Codex wait resolution; hide agent.snapshot in mobile work log Codex review P2: active-with-no-flags thread status now maps back to running so agents don't stay 'waiting' after an approval resolves. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/lib/threadActivity.ts | 1 + apps/server/src/provider/Layers/CodexAdapter.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..83ca2312b33 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -238,6 +238,7 @@ function deriveWorkLogEntries( if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; + if (activity.kind === "agent.snapshot") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8d088648d70..ad796bd125e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -561,12 +561,18 @@ function mapCollabAgentActivity( ); const status = payload?.status; if (!status) return []; - if (status.type === "active" && status.activeFlags.length > 0) { + if (status.type === "active") { + // Flags (waitingOnApproval/waitingOnUserInput) mean blocked; active + // with no flags means the wait resolved and the agent is running again. return [ { ...base, type: "task.updated", - payload: { taskId: base.payload.taskId, status: "waiting", timelineBypass: true }, + payload: { + taskId: base.payload.taskId, + status: status.activeFlags.length > 0 ? "waiting" : "running", + timelineBypass: true, + }, }, ]; } From 0892ce37107a82c6e2e52f27ff435926fc93a7d9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 02:01:17 -0700 Subject: [PATCH 08/17] fix: address PR review feedback; remove HTML plan docs - CI: client-runtime test imports @effect/vitest (vitest types unresolved in package context) - CodexSessionRuntime: divert child-thread events that arrive before subAgentActivity registers them (probe: child status/changed lands first); emit a terminal event on subAgent interruption - CodexAdapter: systemError -> failed; cumulative totalProcessedTokens for child usage (last-turn count can shrink on follow-ups) - Ingestion: waiting agents counted as active in roster summaries; reactivation clears stale resultSummary/errorMessage; usage fields validated as non-negative integers; failed snapshot dispatch drops the hydration marker so the roster re-syncs from persisted state - Panel/strip: idle counts as settled everywhere; phase labels show active counts (not '0 running'); parseTimestampDate for ISO parsing - Drop docs/project/plans HTML files Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.ts | 75 +- .../src/provider/Layers/CodexAdapter.ts | 27 +- .../provider/Layers/CodexSessionRuntime.ts | 63 +- apps/web/src/components/AgentsPanel.tsx | 31 +- .../src/components/chat/AgentsLiveStrip.tsx | 9 +- apps/web/src/timestampFormat.ts | 2 +- .../plans/subagent-observability-spec.html | 1235 -------------- .../plans/subagent-observability-ui.html | 1424 ----------------- .../src/state/threadAgents.test.ts | 2 +- .../client-runtime/src/state/threadAgents.ts | 44 +- 10 files changed, 189 insertions(+), 2723 deletions(-) delete mode 100644 docs/project/plans/subagent-observability-spec.html delete mode 100644 docs/project/plans/subagent-observability-ui.html diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index e7d42f03b71..6706a7da9bc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -51,21 +51,29 @@ const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${t const AGENT_SETTLED_RETENTION_LIMIT = 50; const AGENT_USAGE_MATERIAL_STEP = 25_000; +// Fields must satisfy the contract's NonNegativeInt or the persisted roster +// row becomes undecodable on the client. +function usageCount(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; +} + function taskUsage(value: unknown): ThreadAgentUsage | undefined { if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; const usage = value as Record; - if (typeof usage.totalTokens !== "number" || usage.totalTokens < 0) return undefined; + const totalTokens = usageCount(usage.totalTokens); + if (totalTokens === undefined) return undefined; + const inputTokens = usageCount(usage.inputTokens); + const cachedInputTokens = usageCount(usage.cachedInputTokens); + const outputTokens = usageCount(usage.outputTokens); + const reasoningOutputTokens = usageCount(usage.reasoningOutputTokens); + const toolUses = usageCount(usage.toolUses); return { - totalTokens: usage.totalTokens, - ...(typeof usage.inputTokens === "number" ? { inputTokens: usage.inputTokens } : {}), - ...(typeof usage.cachedInputTokens === "number" - ? { cachedInputTokens: usage.cachedInputTokens } - : {}), - ...(typeof usage.outputTokens === "number" ? { outputTokens: usage.outputTokens } : {}), - ...(typeof usage.reasoningOutputTokens === "number" - ? { reasoningOutputTokens: usage.reasoningOutputTokens } - : {}), - ...(typeof usage.toolUses === "number" ? { toolUses: usage.toolUses } : {}), + totalTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), }; } @@ -192,14 +200,16 @@ export function foldTaskAgentEvent( ...((payload.outputFile ?? previous?.outputFile) ? { outputFile: payload.outputFile ?? previous?.outputFile } : {}), + // A re-activated agent starts a fresh run: prior result/error text would + // read as the live run's output, so clear both unless this event sets them. ...(event.type === "task.completed" && event.payload.summary ? { resultSummary: event.payload.summary } - : previous?.resultSummary + : !reactivated && previous?.resultSummary ? { resultSummary: previous.resultSummary } : {}), ...(event.type === "task.updated" && event.payload.errorMessage ? { errorMessage: event.payload.errorMessage } - : previous?.errorMessage + : !reactivated && previous?.errorMessage ? { errorMessage: previous.errorMessage } : {}), recentActivity, @@ -1561,12 +1571,14 @@ const make = Effect.gen(function* () { if (agentRosterMaterial) { pruneSettledAgents(threadAgents); const roster = Array.from(threadAgents.values()); - const running = roster.filter((agent) => agent.status === "running").length; - const settled = roster.filter( - (agent) => agent.status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(agent.status), + // "active" covers everything non-settled (running/pending/waiting) so a + // roster of blocked agents doesn't read as "0 agents settled". + const active = roster.filter( + (agent) => agent.status !== "idle" && !THREAD_AGENT_TERMINAL_STATUSES.has(agent.status), ).length; - const count = running > 0 ? running : settled; - const summary = `${count} ${count === 1 ? "agent" : "agents"} ${running > 0 ? "running" : "settled"}`; + const settled = roster.length - active; + const count = active > 0 ? active : settled; + const summary = `${count} ${count === 1 ? "agent" : "agents"} ${active > 0 ? "active" : "settled"}`; const snapshotUuid = yield* crypto.randomUUIDv4; const activity: OrchestrationThreadActivity = { id: EventId.make(`${event.eventId}:agent-snapshot:${snapshotUuid}`), @@ -1577,13 +1589,26 @@ const make = Effect.gen(function* () { payload: { agents: roster }, turnId: toTurnId(event.turnId) ?? null, }; - yield* orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: yield* providerCommandId(event, "agent-snapshot-append"), - threadId: thread.id, - activity, - createdAt: activity.createdAt, - }); + // The reducer map was already mutated; if the append fails, drop the + // hydration marker so the next event re-hydrates from the last + // *persisted* snapshot and re-detects the missed material change — + // otherwise the roster silently stays stale until the next transition. + yield* orchestrationEngine + .dispatch({ + type: "thread.activity.append", + commandId: yield* providerCommandId(event, "agent-snapshot-append"), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }) + .pipe( + Effect.tapError(() => + Effect.sync(() => { + hydratedAgentThreads.delete(thread.id); + agentsByThread.delete(thread.id); + }), + ), + ); agentSnapshotDispatchCount += 1; yield* Effect.logDebug("provider agent snapshot appended", { threadId: thread.id, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index ad796bd125e..e96ad34c55f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -554,6 +554,14 @@ function mapCollabAgentActivity( payload: { taskId: base.payload.taskId, status: "idle", timelineBypass: true }, }, ]; + case "subAgent/interrupted": + return [ + { + ...base, + type: "task.completed", + payload: { taskId: base.payload.taskId, status: "stopped", timelineBypass: true }, + }, + ]; case "thread/status/changed": { const payload = readPayload( EffectCodexSchema.V2ThreadStatusChangedNotification, @@ -576,6 +584,21 @@ function mapCollabAgentActivity( }, ]; } + if (status.type === "systemError") { + return [ + { + ...base, + type: "task.completed", + payload: { + taskId: base.payload.taskId, + status: "failed", + summary: "Codex reported a system error for this agent", + timelineBypass: true, + }, + }, + ]; + } + // notLoaded/idle are covered by turn lifecycle events. return []; } case "thread/closed": @@ -600,7 +623,9 @@ function mapCollabAgentActivity( payload: { ...base.payload, usage: { - totalTokens: usage.usedTokens, + // Cumulative across the child's activations; `usedTokens` is + // only the latest context window and can shrink on follow-ups. + totalTokens: usage.totalProcessedTokens ?? usage.usedTokens, ...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}), ...(usage.cachedInputTokens !== undefined ? { cachedInputTokens: usage.cachedInputTokens } diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 18a3fba8ec1..ab21b3eaa1c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -586,7 +586,7 @@ function readRouteFields(notification: CodexServerNotification): { } interface CollabChildAgent { - readonly agentPath: string; + readonly agentPath: string | undefined; readonly interrupted: boolean; } @@ -595,23 +595,26 @@ interface CollabChildAgent { * the parent stream (the probe shows no child `thread/started`, and v2 * `collabAgentToolCall` items carry empty receiver lists). Track them so their * notifications can be folded into agent telemetry instead of leaking into the - * parent timeline as unattributed items. + * parent timeline as unattributed items. Returns the interruption transition + * when this notification carries one, so the caller can emit a terminal event. */ function rememberSubAgentActivity( childAgents: Map, notification: CodexServerNotification, -): void { +): { readonly agentThreadId: string; readonly agentPath: string } | undefined { if (notification.method !== "item/started" && notification.method !== "item/completed") { - return; + return undefined; } const item = notification.params.item; if (item.type !== "subAgentActivity") { - return; + return undefined; } - childAgents.set(item.agentThreadId, { - agentPath: item.agentPath, - interrupted: item.kind === "interrupted", - }); + const interrupted = item.kind === "interrupted"; + const previous = childAgents.get(item.agentThreadId); + childAgents.set(item.agentThreadId, { agentPath: item.agentPath, interrupted }); + return interrupted && !previous?.interrupted + ? { agentThreadId: item.agentThreadId, agentPath: item.agentPath } + : undefined; } /** Synthetic method carrying an intercepted child-thread notification. */ @@ -869,17 +872,49 @@ export const makeCodexSessionRuntime = ( : undefined; rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); - rememberSubAgentActivity(collabChildAgents, notification); + const interruption = rememberSubAgentActivity(collabChildAgents, notification); yield* Ref.set(collabChildAgentsRef, collabChildAgents); + if (interruption) { + // Surface the interruption as its own child event so the agent + // tracker can terminalize the child (the child thread itself goes + // silent when interrupted). + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: COLLAB_AGENT_ACTIVITY_METHOD, + payload: { + agentThreadId: interruption.agentThreadId, + agentPath: interruption.agentPath, + method: "subAgent/interrupted", + }, + }); + } // v2 collab child threads: their notifications arrive on this // connection keyed by the child threadId. Divert the whole stream into // a synthetic collab/agentActivity event for the agent tracker and keep - // it out of the parent timeline. - const childAgent = notificationThreadId + // it out of the parent timeline. A child's first event can arrive + // BEFORE the parent's subAgentActivity registers it (probe: child + // thread/status/changed lands first), so any thread that is neither + // the session's own provider thread nor a v1 collab receiver is + // treated as an unregistered child. + const providerThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + const knownChild = notificationThreadId ? collabChildAgents.get(notificationThreadId) : undefined; - if (childAgent && notificationThreadId) { + const isForeignThread = + notificationThreadId !== undefined && + providerThreadId !== undefined && + notificationThreadId !== providerThreadId && + !collabReceiverTurns.has(notificationThreadId); + if (notificationThreadId && (knownChild || isForeignThread)) { + if (!knownChild) { + yield* Ref.update(collabChildAgentsRef, (current) => { + const next = new Map(current); + next.set(notificationThreadId, { agentPath: undefined, interrupted: false }); + return next; + }); + } yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); yield* emitEvent({ kind: "notification", @@ -887,7 +922,7 @@ export const makeCodexSessionRuntime = ( method: COLLAB_AGENT_ACTIVITY_METHOD, payload: { agentThreadId: notificationThreadId, - agentPath: childAgent.agentPath, + ...(knownChild?.agentPath ? { agentPath: knownChild.agentPath } : {}), method: notification.method, params: payload, }, diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 2acd979bae0..1c27f0ad5ac 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -12,6 +12,7 @@ import { cn } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { ScrollArea } from "./ui/scroll-area"; import { formatDuration } from "../session-logic"; +import { parseTimestampDate } from "../timestampFormat"; interface AgentsPanelProps { agents: ReadonlyArray; @@ -57,19 +58,18 @@ function AgentStatusDot({ status }: { status: ThreadAgentSnapshot["status"] }) { function AgentElapsed({ agent }: { agent: ThreadAgentSnapshot }) { const textRef = useRef(null); const settled = isTerminalAgentStatus(agent.status) || agent.status === "idle"; - const startMs = Date.parse(agent.firstStartedAt); - const endMs = agent.endedAt - ? Date.parse(agent.endedAt) - : settled - ? Date.parse(agent.lastActivityAt) - : null; - const initialText = formatDuration((endMs ?? Date.now()) - startMs); + const startMs = parseTimestampDate(agent.firstStartedAt)?.getTime() ?? null; + const endMs = + (agent.endedAt ? parseTimestampDate(agent.endedAt)?.getTime() : null) ?? + (settled ? (parseTimestampDate(agent.lastActivityAt)?.getTime() ?? null) : null); + const initialText = + startMs === null ? null : formatDuration(Math.max(0, (endMs ?? Date.now()) - startMs)); useEffect(() => { - if (endMs !== null) return; + if (startMs === null || endMs !== null) return; const update = () => { if (textRef.current) { - textRef.current.textContent = formatDuration(Date.now() - startMs); + textRef.current.textContent = formatDuration(Math.max(0, Date.now() - startMs)); } }; update(); @@ -77,6 +77,9 @@ function AgentElapsed({ agent }: { agent: ThreadAgentSnapshot }) { return () => clearInterval(id); }, [startMs, endMs]); + if (initialText === null) { + return null; + } return ( {initialText} @@ -183,8 +186,12 @@ function AgentCard({ agent }: { agent: ThreadAgentSnapshot }) { } function PhaseHeader({ phase }: { phase: AgentPanelPhase }) { - const runningCount = phase.agents.filter((agent) => agent.status === "running").length; - const doneCount = phase.agents.filter((agent) => isTerminalAgentStatus(agent.status)).length; + // "active" (not just status==="running"): a phase whose agents are all + // pending/waiting is still in progress and must not read "0 running". + const doneCount = phase.agents.filter( + (agent) => agent.status === "idle" || isTerminalAgentStatus(agent.status), + ).length; + const activeCount = phase.agents.length - doneCount; return (
{phase.status === "running" ? ( - {runningCount} running{doneCount > 0 ? ` · ${doneCount} done` : ""} + {activeCount} active{doneCount > 0 ? ` · ${doneCount} done` : ""} ) : phase.status === "pending" ? ( pending diff --git a/apps/web/src/components/chat/AgentsLiveStrip.tsx b/apps/web/src/components/chat/AgentsLiveStrip.tsx index 0c158be4398..f85f8d90512 100644 --- a/apps/web/src/components/chat/AgentsLiveStrip.tsx +++ b/apps/web/src/components/chat/AgentsLiveStrip.tsx @@ -48,8 +48,13 @@ const AgentsLiveStrip = memo(function AgentsLiveStrip({ ) : null} {runningPhase ? ( - {runningPhase.title} · {runningPhase.agents.filter((a) => a.status === "running").length}{" "} - running + {runningPhase.title} ·{" "} + { + runningPhase.agents.filter( + (a) => a.status === "running" || a.status === "pending" || a.status === "waiting", + ).length + }{" "} + active ) : null} diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 31cc41798eb..cce5b141c63 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -40,7 +40,7 @@ function getTimestampFormatter( return formatter; } -function parseTimestampDate(isoDate: string): Date | null { +export function parseTimestampDate(isoDate: string): Date | null { const date = new Date(isoDate); return Number.isNaN(date.getTime()) ? null : date; } diff --git a/docs/project/plans/subagent-observability-spec.html b/docs/project/plans/subagent-observability-spec.html deleted file mode 100644 index 650c54223f5..00000000000 --- a/docs/project/plans/subagent-observability-spec.html +++ /dev/null @@ -1,1235 +0,0 @@ - - - - - - Subagent Observability — Audit & Tech Spec - - - -
-

- T3 Code · Plan 1 of 2 · 2026-07-20 · rev 3 (minimal transport) · companion: - UI mockups -

-

Subagent Observability: Audit & Tech Spec

-

- Expose Codex v2 subagents and Claude Code subagents + workflows in a live T3 panel: who - is running, what they're doing, token burn, elapsed time, and a drill-in. One generic - interface; both providers plug into it. -

- -
- Rev 2 — adversarially reviewed by a Fable subagent and gpt-5.6-sol (codex), - both verifying claims against source. Material corrections: (1) Codex - v2 does not emit the rich collabAgentToolCall spawn items — - that's v1; the v2 tracker is now keyed on subAgentActivity + child - thread/started metadata, and T3's child-thread suppression turns out not to - engage for v2 at all. (2) Codex agents are reusable identities, so the - snapshot now separates identity from activations and adds an idle state. - (3) Persistence is now material-transitions-only (1s ticks stay in - memory) because orchestration events are append-only and replay reads are global. Full - review notes: /tmp/subagent-audit/review/.

- Rev 3 — minimal transport. The bespoke stream event, subscriber gating, - projection table, migration, and capability flag are all deleted: the roster now ships as - one agent.snapshot activity kind through the existing - append/persist/replay channel, latest-wins on the client — the exact pattern the - context-window meter already uses. Server delta shrinks to: contract types, two adapters, - one reducer in ingestion. Rev 2's bespoke design remains in this doc's git history as the - graduation path if fleets outgrow the activity channel. -
- - - -

1 · Audit: Codex v2

-

- Sources: codex-rs @ bb947e8e (2026-07-13); T3's generated schema pin 678157ac. Initial audit - corrected by adversarial review — v1/v2 item emission re-verified handler by handler. -

- -

- The core fact: every v2 subagent is a full app-server thread. There are no - dedicated subagent RPCs — spawn/send/wait are model tools, not client methods. The - client observes subagents as ordinary threads plus parent-side thread items. -

- -
    -
  • - v1 vs v2. features.multi_agent (v1, stable) vs - features.multi_agent_v2 (under development, default off) — but the - model catalog forces v2 for gpt-5.6-sol and gpt-5.6-terra (models.json: multi_agent_version: "v2"), so on the models we care about it's on with zero config. v2 has no depth limit; - concurrency defaults to 4 (max_concurrent_threads_per_session). -
  • -
  • - Identity & lineage arrive on the standard - thread/started notification. The Thread object carries - parentThreadId, shared sessionId, - agentNickname (random, e.g. "Marlow"), agentRole (agent_type: - explorer/awaiter/custom), and - source.subAgent.threadSpawn.{depth, agentPath} with hierarchical paths like - /root/researcher. - Clients are auto-subscribed to child-thread events — no subscribe call needed. -
  • -
  • - Parent-side items — v1 and v2 emit differently (this is where the first - draft was wrong): -
      -
    • - v1 emits rich collabAgentToolCall items for - spawn/send/resume/close/wait, carrying receiverThreadIds, - prompt, model, reasoningEffort, and - agentsStates (child thread id → {status, message}). -
    • -
    • - v2 emits - subAgentActivity {kind: started|interacted|interrupted, agentThreadId, - agentPath} - on spawn/message/interrupt (multi_agents_v2/spawn.rs:142 emits - only this — verified, no CollabAgentToolCall anywhere in the v2 - spawn path). The only v2 collabAgentToolCall emitter is - wait, and it ships with empty - receiverThreadIds / - agentsStates (multi_agents_v2/wait.rs:85,90). - Model/prompt/agentsStates are not on v2 parent items. -
    • -
    - Consequence: under v2, per-agent status and model must come from the - child thread's own stream (thread/status/changed, - turn/started|completed, thread/settings/updated), not from - parent items. -
  • -
  • - Per-agent token burn is free: - thread/tokenUsage/updated {threadId, turnId, tokenUsage: {total, last, - modelContextWindow}} - fires per child thread with input/cached/output/reasoning breakdowns. No server-side tree - rollup — client sums. -
  • -
  • - Agents are reusable identities, not one-shot tasks. - send_message/followup_task re-target an existing child thread - and trigger new turns on it (interaction events at event_mapping.rs:133-178). - A child turn/completed means "this run finished", not "this agent is done" — - the agent sits idle and resumable. -
  • -
  • - Reference consumer: the Codex TUI builds its /agent UI from - notifications alone — it caches child threads from - subAgentActivity/thread/started, buffers the last ~6 item - summaries per child, labels agents "{nickname} [{role}]". -
  • -
  • - Not exposed: the live AgentRegistry (model-facing - list_agents only), tree token rollups, per-agent "current activity" endpoint. - thread/list?parentThreadId/ancestorThreadId subtree filters are - experimental-gated and newer than T3's schema pin. -
  • -
- -
- T3 schema verdict: the vendored effect-codex-app-server pin - already has everything needed — subAgentActivity, - collabAgentToolCall, - Thread.parentThreadId/agentNickname/agentRole + - source.subAgent.threadSpawn, thread/tokenUsage/updated. No - regeneration required to ship. -
- -
- T3's child-thread suppression does not engage for v2 — child chatter behavior today is - unverified. - CodexSessionRuntime.rememberCollabReceiverTurns learns child threads only from - collabAgentToolCall.receiverThreadIds; under v2 those are empty, so the - collabReceiverTurns map never populates and the suppression list is inert. What - v2 child-thread notifications actually do to the parent timeline today (leak in? get dropped - elsewhere?) must be established with a live v2 wire probe — this is the - entry gate for the Codex slice (§8). The suppression narrative in the first draft applied - only to v1 models (e.g. gpt-5.6-luna). -
- -

2 · Audit: Claude Agent SDK

-

- Sources: @anthropic-ai/claude-agent-sdk 0.3.170 type declarations, CLI 2.1.214 binary, and a - live stream-json wire probe (real subagent spawn captured). All SDK-type claims - below re-verified by both reviewers. -

- -

- The core fact: a complete task lifecycle already streams over stdio, keyed - by task_id + tool_use_id, covering subagents, background shells, - monitors, and workflows uniformly: -

- -
task_started      {task_id, tool_use_id, description, subagent_type?, task_type?, workflow_name?, prompt?}
-task_progress     {task_id, description, usage:{total_tokens, tool_uses, duration_ms}, last_tool_name?, summary?}
-task_updated      {task_id, patch:{status: pending|running|completed|failed|killed|paused, end_time?, is_backgrounded?, error?}}
-task_notification {task_id, status: completed|failed|stopped, summary, usage?, output_file}
- -
    -
  • - Subagent messages arrive on the main stream tagged - parent_tool_use_id + subagent_type + - task_description. By default only tool_use/tool_result blocks are forwarded; - forwardSubagentText: true forwards the full nested transcript. -
  • -
  • - "What it's doing right now": last_tool_name on every - progress tick and tool_progress heartbeats (tool_name, elapsed_time_seconds, parent_tool_use_id, task_id) are free. agentProgressSummaries: true additionally forks each running - subagent every ~30s for an AI-written present-tense summary in - task_progress.summaryreal recurring model calls (SDK default is - off), so this is a per-instance setting, not a default (§8). -
  • -
  • - Workflows: task_started carries - task_type:'local_workflow' + workflow_name; - task_progress carries an undeclared-but-real - workflow_progress array (wire-probe confirmed) with - workflow_phase {index, title} and - workflow_agent {label, phaseIndex, phaseTitle, agentId, model, state, startedAt, - attempt, lastToolName, promptPreview, resultPreview, tokens, toolCalls, error} - entries. Not in sdk.d.ts; consume defensively behind a schema guard. - Per-agent full results are files-only (journal.jsonl, - agent-<id>.jsonl). -
  • -
  • - Usage granularity: per task (total_tokens only, no USD), per - message (if forwarded), per model + costUSD on the turn result.modelUsage, - session-wide via an explicitly-unstable control request. -
  • -
  • - Rosters: the typed background_tasks control request returns - the live task list (BackgroundTaskSummary[]) — this is the stable - roster/reconciliation source. A background_tasks_changed stream event was - also observed on the wire but is undeclared and its literal doesn't appear in the CLI - binary strings — treat as bonus signal only, never a dependency. -
  • -
  • - Gaps needing upstream: no model field on task events, no start_time on - task_started (use receive time), no per-subagent USD, - workflow_progress untyped. -
  • -
- -
- Today T3's ClaudeAdapter maps the lifecycle but strips the linkage. - task.started/progress/completed runtime events exist and are persisted, but - subagent_type, tool_use_id, workflow_name, - prompt, output_file are dropped; task_updated falls - into the unknown-subtype warning path (status transitions lost); - parent_tool_use_id traffic is filtered out of usage accounting. Raw payloads - survive in raw.payload on the ephemeral runtime events (NDJSON logs) — - recoverable at the adapter, but not from the durable store, so pre-feature - history cannot be backfilled (§5.5). -
- -

3 · Audit: T3's existing pipeline

- -
- - - - - - - - - Codex app-server - Claude SDK - (stdio) - - - Adapters - normalize - - - ProviderRuntimeEventV2 - - task.* · item.* · turn.* - - - PubSub (in-memory) - - - - Ingestion - - lossy → activities - - - SQLite + projections - - - - WS subscribe - - snapshot + deltas - - - → atoms → React - - - - - - - - - - the gap: rich runtime events are compressed into - - - append-only activity rows; linkage fields dropped - - - - -
- Two-tier pipeline: ephemeral runtime events → durable orchestration events → websocket. -
-
- -
    -
  • - The right abstractions already exist. - ProviderRuntimeEventV2 already has - task.started/progress/completed (Claude emits them today, persisted and - streamed to clients as thread.activity-appended), a - collab_agent_tool_call canonical item type, and typed - ThreadTokenUsageSnapshot. -
  • -
  • - Snapshot+delta is a solved pattern: subscribeShell/subscribeThread - do live-buffer-first attach, snapshot-or-replay with afterSequence, - sequence-deduped deltas. projection_thread_sessions is the precedent for an - upserted per-thread mutable row. -
  • -
  • - Two cost facts that constrain the design (surfaced in review): -
      -
    • - Every accepted orchestration command appends an immutable event before - updating any projection — "upsert" describes the projection, not the event store. - High-frequency dispatches are permanent rows. -
    • -
    • - subscribeThread's afterSequence catch-up reads the - global event range after the cursor and filters per-thread afterward - (ws.ts:1385-1395), and shell resume falls back to full snapshots beyond - SHELL_RESUME_MAX_GAP = 1000. Fleet-wide event volume taxes every - reconnect. -
    • -
    - → persistence must be material transitions only (§5.3). -
  • -
  • - Compat asymmetry that shaped the transport: - OrchestrationEvent is a closed Schema.Union decoded by the RPC - client — a new event type breaks old clients (fails decode) and would need - per-subscriber gating. But OrchestrationThreadActivity.kind is an open string - with payload: Schema.Unknown — a new activity kind flows through - every layer untouched. That's why §5.3 ships the roster as an activity kind, not an event - type. -
  • -
  • - Gaps: only Claude emits task.* (Codex/OpenCode emit only - item lifecycles; ACP nothing). Task payloads lack name/model/parent-linkage and - usage is untyped. Tool activities carry no owning-agent id. Turn cost never - reaches the client. -
  • -
  • - Client: the right panel (rightPanelStore surfaces: - plan/diff/files/file/preview/terminal) is the natural mount; PlanSidebar is - the structural template; WorkingTimer (DOM-write ticking, no React commits), - Badge variants, animate-status-pulse, and - SimpleWorkEntryRow are the primitives to reuse. Mobile shares state via - client-runtime but duplicates presentation — put new derivation helpers in - client-runtime. -
  • -
- -

4 · Unified data-availability matrix

-

- "Wire" = the provider protocol T3 already speaks. "T3 today" = what actually reaches the - browser now. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
DatumCodex v2 wireClaude wireT3 todayPlan
Agent idyes child thread id (UUIDv7)yes task_id + tool_use_idtaskId only (Claude)normalize to agentId + keep provider
Name / label - yes nickname + role + path (child - thread/started) - yes description, subagent_type, name, workflow labeldescription onlyname + agentType
Model - partial child thread settings — not on v2 - parent items - - partial workflow agents yes; plain subagents only if - text forwarded - optional model
Status / lifecycle - yes child thread/status + turn events (idle ≠ terminal) - yes task_updated + task_notificationstarted/completed only; task_updated dropped7-state enum incl. idle
Current activityyes child item streamyes last_tool_name; AI summaries opt-inlastToolName forwarded but not surfaced per-agentcurrentActivity + recent feed
Token burnyes per-thread breakdownsyes total_tokens per taskuntyped blobtyped usage
Start / end timeyes thread/turn timestampspartial end_time yes; start = receive timeactivity createdAtfirstStartedAt/lastActivityAt/endedAt
Parent linkageyes parentThreadId + depth + pathpartial tool_use_id, 1 level on streamparentAgentId
Workflow phases / retriesn/a (paths encode tree) - partial undeclared - workflow_progress (incl. attempt) - workflow agents = child agents with phaseTitle, attempt
Cost USDnopartial per-model per-turn onlyout of scope v1
- -

5 · Design: the generic interface

- -

- Principle: don't invent a new pipeline — enrich the one that exists. The - task.* runtime-event family is already the provider-agnostic "unit of delegated - work". We (a) extend its payloads with the missing typed fields, (b) make the Codex adapter - synthesize the same events from v2 notifications, and (c) have ingestion fold them into one - upserted snapshot per agent that rides the existing subscribeThread stream — - with persistence limited to material transitions. -

- -

5.1 Contract: identity + activations new

-

- Review-driven shape change: a Codex agent is a durable, re-activatable identity while a - Claude task is a one-shot execution. The snapshot models the identity with rollups - of its activations, so a follow-up doesn't destroy history and elapsed time stays - meaningful. -

-
// packages/contracts/src/orchestration.ts (additive)
-ThreadAgentStatus = "pending" | "running" | "waiting" | "idle" | "completed" | "failed" | "stopped"
-  // waiting  = blocked on approval/user-input, or queued (workflow)
-  // idle     = finished a run but resumable (Codex send_message/followup; Claude paused)
-  // completed/failed/stopped = terminal
-
-ThreadAgentUsage = {
-  totalTokens: NonNegativeInt,
-  inputTokens?, cachedInputTokens?, outputTokens?, reasoningOutputTokens?,
-  toolUses?: NonNegativeInt,
-}
-
-ThreadAgentSnapshot = {
-  agentId: TrimmedNonEmptyString,        // stable identity: Claude task_id · Codex child thread id
-  provider: ProviderDriverKind,          // id-space discriminator; prevents cross-provider collisions
-  kind: "subagent" | "workflow" | "workflow_agent" | "shell" | "monitor" | "other",
-  name: TrimmedNonEmptyString,           // description · nickname · workflow label
-  agentType?: TrimmedNonEmptyString,     // subagent_type · agent_role
-  model?: TrimmedNonEmptyString,
-  status: ThreadAgentStatus,
-  currentActivity?: TrimmedNonEmptyString,
-  lastToolName?: TrimmedNonEmptyString,
-  usage?: ThreadAgentUsage,              // cumulative across activations
-  firstStartedAt: IsoDateTime,
-  lastActivityAt: IsoDateTime,
-  endedAt?: IsoDateTime,                 // cleared on re-activation
-  activationCount: NonNegativeInt,       // Codex follow-ups · workflow retry attempts
-  spawnTurnId?: TurnId,                  // turn that created it
-  lastTurnId?: TurnId,                   // latest turn that touched it (UI groups by this)
-  parentAgentId?: TrimmedNonEmptyString,
-  phaseIndex?: NonNegativeInt,           // workflow phase membership (index is authoritative;
-  phaseTitle?: TrimmedNonEmptyString,    //   title is display — titles can repeat across phases)
-  // kind === "workflow" only:
-  phases?: Array<{ index: NonNegativeInt, title: TrimmedNonEmptyString }>, // ordered phase list
-  scriptPath?: TrimmedNonEmptyString,    // persisted workflow script (WorkflowOutput.scriptPath)
-  runId?: TrimmedNonEmptyString,         // workflow run id (resume handle)
-  approvalRequestId?: ApprovalRequestId, // set while waiting on an approval → UI deep-link
-  outputFile?: TrimmedNonEmptyString,    // Claude transcript path — phase-2 drill-in needs it
-  resultSummary?: TrimmedNonEmptyString,
-  errorMessage?: TrimmedNonEmptyString,
-  recentActivity: Array<{ at: IsoDateTime, summary: string }>, // ring buffer, max 6
-  updatedAt: IsoDateTime,                // staleness + "while you were away" watermark
-}
- -

5.2 Provider mapping

-
-
-

Claude → snapshot

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SDK messageEffect
task_started - upsert: id, name=description, kind from task_type, agentType=subagent_type, - firstStartedAt=now, status=running, outputFile later -
task_progress - usage, lastToolName, currentActivity=summary; - workflow_progress[] (schema-guarded): - workflow_phase entries → the workflow snapshot's ordered - phases list; workflow_agent entries → upsert child - workflow_agent snapshots (label, phaseIndex+phaseTitle, model, state, - tokens, attempt) -
Workflow tool_result (WorkflowOutput) - scriptPath + runId onto the workflow snapshot (typed on - the tool output: "Path to the persisted workflow script for this invocation") -
task_updatedstatus patch (killed→stopped, paused→idle), endedAt=end_time, errorMessage
task_notificationterminal status, resultSummary, final usage, outputFile
tool_progress (has task_id)lastToolName + recentActivity entry
can_use_tool / approval (has agent_id)status=waiting + approvalRequestId; cleared on resolution
-

- Adapter fixes: stop dropping - subagent_type/tool_use_id/workflow_name/output_file; handle - task_updated (today: warning path). Roster reconciliation via the typed - background_tasks control request (not the undeclared stream event). - agentProgressSummaries is a per-instance setting, default off — fallback - currentActivity is last_tool_name. Not enabling - forwardSubagentText in v1. -

-
-
-

Codex v2 → snapshot (corrected)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NotificationEffect
child thread/started (source = subAgent.threadSpawn) - upsert identity: id=thread id, name=nickname, agentType=agentRole, parentAgentId, - depth/path, firstStartedAt -
subAgentActivity item (parent stream) - started → running; interacted → running + activationCount++; interrupted → stopped -
child turn/startedstatus=running (clear endedAt/resultSummary on re-activation)
child turn/completed - status=idle (resumable — not terminal), resultSummary=last - message -
child thread/status/changedactive flags waitingOnApproval/UserInput → waiting; closed → stopped
child thread/tokenUsage/updatedusage (full breakdown)
child item/started|completedcurrentActivity + recentActivity (reuse item titles)
child thread/settings/updatedmodel (v2 parent items don't carry it)
-

- collabAgentToolCall is not used for tracking (v2 emits it only for - wait, with empty receivers) — render it as a parent activity only. Runtime - change: CodexSessionRuntime learns children from - thread/started.source + subAgentActivity, routes their - notifications to the agent tracker, and filters them out of the parent timeline. - Entry gate: a live v2 wire probe to establish what child chatter - reaches T3 today (§8). -

-
-
- -

- 5.3 Transport: one activity kind, zero new machinery rev 3 -

-

- The dumb store already exists — use it. - OrchestrationThreadActivity has a free-form kind and an open - payload: Schema.Unknown, is persisted/capped/replayed, and streams over - subscribeThread today. And T3 already ships latest-wins state through it: - context-window.updated activities carry a full usage snapshot and the client - just takes the newest one (deriveLatestContextWindowSnapshot). Agents work - identically: -

-
// Ingestion keeps the per-thread agent map in memory (it must merge events anyway).
-// On a MATERIAL change — status transition · name/model/phase discovery ·
-// currentActivity change · usage crossing a 25k step · terminal fields —
-// it appends ONE activity via the EXISTING thread.activity.append command:
-{ kind: "agent.snapshot",
-  tone: "info",
-  summary: "2 agents running",               // human-readable on purpose (see compat)
-  payload: { agents: ThreadAgentSnapshot[] } } // full roster, latest-wins
-
-// Client: scan thread.activities for the newest agent.snapshot → that IS the panel state.
-// Same derivation pattern as the context-window meter. Typed decode of the payload
-// happens client-side against the contracts schema; a failed decode = ignore the row.
-

What this deletes relative to the rev-2 design — with no loss of insight:

- - - - - - - - - - - - - - - - - - - - - - - - - -
Rev 2 (bespoke transport)Rev 3 (activity channel)
- New thread.agent.upsert command + thread.agent-upserted event - in the closed union - Reuses thread.activity.append — no new command or event types
- Subscriber opt-in gating + acceptAgentEvents + old/new contract tests - (closed-union decode risk) - - Not needed: kind is an open string, payload is - Unknown — old clients decode fine and render a rare benign "2 agents - running" info row (arguably a feature) -
- projection_thread_agents table + migration + snapshot-assembly changes + - shell-refetch exclusion - - Not needed: rows land in the existing activities projection; the 500-row cap is the - retention policy -
ServerConfig.threadAgents capability flag - Not needed: presence of an agent.snapshot activity is self-describing -
Reducer hydration from the new projection - Same trick ingestion already uses for task titles: on first event for a thread, read the - latest agent.snapshot from the loaded thread detail (findTaskTitleInActivities - precedent) -
-
    -
  • - Volume: full-roster payload (≤ ~10 agents, a few KB) × material - transitions only ≈ tens of rows per agent run — same order as the - task.progress rows Claude already writes today. 1s ticks and elapsed time - stay client-derived, never persisted. -
  • -
  • - Codex-synthesized task.* events still bypass - runtimeEventToActivities - (the marker survives from rev 2) — they feed the reducer only, so slice 2 adds no timeline - spam. New clients add one skip line for agent.snapshot in - deriveWorkLogEntries (precedent: context-window.updated is - skipped the same way). -
  • -
  • - Known trade-offs, accepted: (a) the payload is untyped on the wire — the - schema lives in contracts and the client decodes tolerantly; (b) latest-wins inside a - 500-cap window means a thread with 500+ later activities loses its old roster — at that - distance the agents are ancient history, and the timeline's task.* rows - remain; (c) full-roster-per-write is O(agents) redundant — at ≤10 agents this is noise, - and it's what makes latest-wins (and therefore everything else) simple. -
  • -
  • - Escape hatch: if per-agent typed wire events or bigger fleets ever - matter, the rev-2 design (dedicated event + projection + subscriber gating, preserved in - git history of this doc) is the graduation path. Don't build it until the counter metric - (§8) says so. -
  • -
- -

5.3b What happens to child-thread events — dropped today, distilled tomorrow

-

- Today, T3 durably stores none of it. Precisely: Codex v1 child-thread - notifications are suppressed at the session runtime (their item events are re-attributed to - the parent turn; thread/turn/usage events dropped); Codex v2 child chatter is unaudited (the - suppression doesn't fire — probe gate in §8); Claude subagent traffic is reduced to the - task.* activity rows and its parent_tool_use_id stream deltas are - filtered out. The only place raw child events land is the rotating NDJSON observability logs - — not queryable, not replayed. -

-

This plan changes that to "distilled, not mirrored":

-
    -
  • - Durably stored (new): the per-agent snapshot — identity, lifecycle, usage - rollups, recentActivity (last 6 summaries), result summary, script/output - pointers — as agent.snapshot activity rows in the existing activities - projection + event store. This is what survives restarts and reconnects. -
  • -
  • - Still not stored: full child transcripts (every tool call, message delta, - reasoning block). Deliberately — mirroring child streams into the orchestration store - would multiply event volume by fleet size (§3's replay-cost facts). The providers already - persist them (Codex rollout files readable via thread/read; Claude - agent-<id>.jsonl / output_file), so the phase-2 drill-in - RPC reads them on demand from provider storage instead of duplicating them. -
  • -
  • - Caveat that falls out: transcript drill-in is only as durable as provider - storage — e.g. an ephemeral Codex thread or a cleaned /tmp output file means - the snapshot survives but the deep transcript doesn't. Acceptable: the snapshot is the - record; the transcript is an inspection tool. -
  • -
- -

5.4 Recovery & staleness new (review-driven)

-
    -
  • - Reducer bootstrap: on the first event for a thread, hydrate the in-memory - agent map from the latest persisted agent.snapshot activity (same pattern - ingestion already uses to recover task titles from activities), so a mid-run server - restart doesn't orphan records when the next task_progress arrives without - its task_started. -
  • -
  • - Orphan sweep: provider sessions are child processes of the T3 server — on - session.exited (or a new session.started for the thread), all - non-terminal agents for that thread transition to stopped with - endedAt=now and an "orphaned on session exit" errorMessage. Reconciliation on - resume: Claude via the background_tasks control request; Codex via tracked - child threads. -
  • -
  • - Client staleness: updatedAt + the client's sync watermark - distinguish "running" from "stale while disconnected"; the UI's transport states - (loading/synchronizing/live/reconnecting) come from the existing subscription status - machinery, not the snapshot. -
  • -
  • - History: pre-feature threads cannot be backfilled — dropped fields only - exist in ephemeral NDJSON logs. Documented as a non-goal. -
  • -
- -

5.5 Drill-in

-
    -
  • - Phase 1 (ships with the panel): recentActivity ring buffer + - resultSummary + prompt preview. Workflow cards get a "View script" - affordance: scriptPath opens in the right panel's existing - file: surface (read-only file preview — zero new viewer code; the path is - outside the workspace root, so the file surface needs to accept absolute session paths or - the server proxies the read). -
  • -
  • - Phase 2 (full transcript): one read RPC - orchestration.getAgentTranscript {threadId, agentId}. Codex: - thread/read on the child thread. Claude: tail outputFile / - agent-<id>.jsonl server-side (the snapshot's - outputFile field exists for exactly this), rendered with the existing - work-log row components. Avoids turning on forwardSubagentText globally. -
  • -
- -

6 · Server changes (all additive)

-

- Rev 3: no new command, event type, projection table, migration, subscription input, or - capability flag. The entire delta is contracts (types only), two adapters, and one reducer - inside ingestion. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#WhereChangeSize
1packages/contracts - ThreadAgentSnapshot + status/usage schemas and the - agent.snapshot payload schema (used for client-side decode — no - wire-protocol change); extend TaskStarted/Progress/Completed payloads - (optional - name/agentType/model/toolUseId/parentTaskId/workflowName/phaseIndex/phaseTitle/phases/attempt/scriptPath/runId/outputFile - + typed usage + timeline-bypass marker) - S
2ClaudeAdapter - Carry the dropped fields; handle task_updated; approval → agent attribution - via agent_id; workflow_progress schema-guarded parse into - child task.* events; background_tasks reconciliation; - agentProgressSummaries per-instance option (default off) - M
3CodexSessionRuntime / CodexAdapter - Gated on live v2 probe. Track children from - thread/started.source.subAgent + subAgentActivity; route child - notifications to synthesized timeline-bypassing task.* events per §5.2; - keep child chatter out of the parent timeline (new filtering — the existing suppression - doesn't fire for v2) - M–L
4ProviderRuntimeIngestion - Agent reducer (in-memory, hydrated from the latest persisted - agent.snapshot) → material-transition agent.snapshot activity - appends through the existing command; orphan sweep on session exit; dispatch-rate - counter metric - M
-

- Non-goals v1: cost USD attribution, ACP (Cursor/Grok) support (no wire signal exists), - cross-thread agent trees, controlling agents (stop/steer — Codex exposes interruption only - as a model tool today), history backfill. OpenCode: falls out automatically if its adapter - ever emits task.*. -

- -

7 · Client changes

-
    -
  • - Derivation in client-runtime (shared with mobile): - deriveLatestAgentSnapshot(activities) — newest - agent.snapshot row, tolerant payload decode (mirrors - deriveLatestContextWindowSnapshot) — plus - deriveAgentPanelState(agents, syncStatus) — group by workflow, split - running/settled (by lastTurnId), compute totals, staleness from - updatedAt + subscription state. -
  • -
  • - Web: add "agents" to - RIGHT_PANEL_KINDS (singleton surface, storage v7→8); tab + "+" add-menu entry - in RightPanelTabs (surface always addable from the menu; the tab auto-appears - once the thread has agents); an AgentsPanel branch in ChatView's - surface switch; a compact live strip near the "Working…" row; one skip line for - agent.snapshot in deriveWorkLogEntries. Reuse: - Badge, animate-status-pulse, WorkingTimer, - SimpleWorkEntryRow. -
  • -
  • - Mobile: inspector-pane tenant on tablet, sheet on phone — later slice, - shares the derivation helpers. -
  • -
  • - Visual design + feature-to-field milestones: see the - companion UI plan. -
  • -
- -

8 · Phasing, risks, open questions

-

Phasing

-
    -
  1. - Slice 1 — contracts + Claude: #1, #2, #4, #5 → panel shows Claude - subagents + workflows end-to-end. -
  2. -
  3. - Slice 2 — Codex: entry gate: live v2 wire probe (spawn agents on - gpt-5.6-sol through T3's app-server wrapper, capture exactly which child notifications - arrive and what leaks into the timeline today). Then #3 → same panel lights up for Codex - with zero client work. -
  4. -
  5. Slice 3 — drill-in transcripts + mobile.
  6. -
-

Risks

-
    -
  • - workflow_progress is undeclared in the SDK types — schema-guarded parsing; - file upstream for typing. The background_tasks_changed stream event is - probe-only evidence — never a dependency (typed control request is the roster source). -
  • -
  • - Codex child-thread routing touches turn-attribution logic — needs tests that parent - transcript behavior is unchanged for both v1 and v2 models. -
  • -
  • - Event volume: material-transition persistence bounds it, but slice 1 ships with a counter - metric on agent-upsert dispatch rate so we see reality before slice 2 multiplies it. -
  • -
  • - Compat: one test that an old client's activity handling renders (or benignly ignores) an - agent.snapshot row — the open-string channel makes this low-risk, but verify - rather than assume. -
  • -
-

Open questions

-
    -
  • Settled-agent grouping: collapse by turn (current lean) or by workflow run?
  • -
  • - Auto-open the panel on first agent spawn? (Lean: badge on the tab + the inline strip, no - auto-open.) -
  • -
  • - agentProgressSummaries: per-instance setting shipped off — do we want a UI - toggle in thread settings, or instance config only? -
  • -
-
- - diff --git a/docs/project/plans/subagent-observability-ui.html b/docs/project/plans/subagent-observability-ui.html deleted file mode 100644 index ce43a24bb41..00000000000 --- a/docs/project/plans/subagent-observability-ui.html +++ /dev/null @@ -1,1424 +0,0 @@ - - - - - - Subagent Observability — UI Mockups - - - -
-

- T3 Code · Plan 2 of 2 · 2026-07-20 · rev 2 (post-review) · companion: - audit & tech spec -

-

Subagent Observability: UI Mockups

-

- Three directions for surfacing live subagents and workflows. All three consume the same - ThreadAgentSnapshot[] from the spec, use T3's existing tokens (DM Sans, the - sky/emerald/amber status language, animate-status-pulse dots, - Badge chips), and mount without new layout regions. They differ in - where attention lives. -

-

- Rev 2 after adversarial review (Fable + gpt-5.6-sol): every promised feature is now pinned - to a contract field and milestone (table below), - transport/staleness states are specified instead of assumed free, the Stop control is - explicitly out of v1 scope, and the Agents surface stays reachable from the panel "+" menu - even before any agent has run. -

- - - - - - - - - - - - - - - - - - - - - - - - - - -
DirectionOne-linerBest when
AAgents panel (right-panel surface)A first-class "Agents" tab beside Diff/Files/Browser with one card per agentYou want to watch a fleet while reading the chat
BInline live stripA collapsed roster docked above the composer that expands in placeGlanceable awareness with zero layout cost
CMission controlTree + drill-in split view with workflow phase barHeavy workflow runs; debugging what an agent did
- - -

- Option A Agents panel — a right-panel surface - recommended core -

-

- Adds kind: "agents" to the existing right-panel workspace. A tab with a live - count badge sits beside Diff/Files/Browser; the panel shows one compact card per agent, - grouped by origin (workflow vs. direct spawns), with a summary footer aggregating burn. - Cards expand in place to the recent-activity feed. -

- -
-
-
-
- t3code · fix-auth-flow -
- -
-
-
-
- Audit the auth flow and fix the session-refresh race — use a workflow. -
-
-
-
-
- Workflow: audit-auth-flow - · -
-
- Working for - · 4 agents running -
-
-
- Reply… -
-
-
-
-
- ⛬ Agents 4 - DiffFiles+ -
-
-
- Workflow · audit-auth-flow - {} script -
-
- ✓ Audit -
-
-
- audit:auth-entrypoints4m 02s -
-
Found 3 candidate races · report → /tmp/audit/entry.md
-
- 118.4k tok·41 tools -
-
-
- Verify2 running · 1 done -
-
-
- verify:session-refreshsonnet -
-
▸ Reproducing the race in sessionRefresh.test.ts
-
- 41.2k tok ·12 tools -
-
-
-
- verify:token-rotationsonnet -
-
▸ Read apps/server/src/auth/rotate.ts
-
- 26.0k tok ·8 tools -
-
-
- Fixpending -
-
- Direct spawns -
-
-
- Marlowexplorercodex -
-
- Waiting on approval · /root/marlow -
-
- 61.8k tok·blocked 41s -
-
-
-
- 3 running - 1 waiting - Σ 247k tok -
-
-
-
-
- Option A — "Agents" as a peer of Diff/Files. Workflow agents group under real phase - headers (✓ done / running counts / pending) from the snapshot's phases list; - "{} script" opens the workflow's JS file in the file surface. Token burn is a plain - counter with a live-tick ▲ — burn is unbounded, so it must not look like progress toward - completion. Amber = blocked on approval (clicking jumps to the prompt). -
-
- -
-
- Pros -
    -
  • Zero new layout machinery — tab strip, docking, sheet mode, resize all exist
  • -
  • Persistent: watch agents while scrolling chat; count badge when hidden
  • -
  • Cards scale from 1 subagent to a 10-agent workflow (groups + scroll)
  • -
  • Natural home for phase-2 drill-in (card → expanded feed)
  • -
-
-
- Cons -
    -
  • Competes for the right panel with Diff/Browser (tab switching)
  • -
  • Hidden by default on <980px until the user opens the sheet
  • -
  • Discovery depends on noticing the tab badge
  • -
-
-
- - -

- Option B Inline live strip — agents live in the conversation -

-

- No panel at all: a roster strip docks between the timeline and the composer whenever ≥1 - agent is live, replacing the bare "Working…" row. Collapsed, it's one line with a face-pile - and aggregate burn; expanded, one row per agent. Terminal states fold into the timeline as - ordinary work-log entries. Inspired by the existing turn-fold ("Worked for 4m 12s") - language. -

- -
-
-
-
- t3code · fix-auth-flow -
- -
-
-
-
- Audit the auth flow and fix the session-refresh race — use a workflow. -
-
-
-
-
- audit:auth-entrypoints — 3 candidate races - -
-
- -
-
- - 4 agents -
- V - V - M - A -
- · Verify 2/3 · · - Σ 247k - -
-
- verify:session-refreshReproducing the race in sessionRefresh.test.ts41.2k · -
-
- verify:token-rotationRead apps/server/src/auth/rotate.ts26.0k · -
-
- Marlow explorerWaiting on approval — Review approval request61.8k · -
-
- audit:auth-entrypointsDone · 3 candidate races found118.4k · 4m 02s -
-
- -
- Reply… -
-
-
-
-
-
- Option B — the roster is part of the conversation flow. Collapsed by default to a single - line; the ▾ expands rows in place. Rows click through to a drill-in sheet. -
-
- -
-
- Pros -
    -
  • Impossible to miss — agents appear exactly where you're already looking
  • -
  • Works identically on mobile/narrow (no panel dependency)
  • -
  • Cheapest build: one component + the existing work-log fold pattern
  • -
  • Keeps the right panel free for Diff/Browser
  • -
-
-
- Cons -
    -
  • Competes with the composer for vertical space when expanded
  • -
  • No room for rich per-agent detail — drill-in must open elsewhere
  • -
  • Long fleets (10+) need truncation ("+6 more")
  • -
-
-
- - -

Option C Mission control — tree + drill-in split

-

- The maximal version, for when a workflow is the main event: the right panel (or maximized - panel) becomes a two-pane console. Left: the agent tree — workflow → phases → agents, Codex - agent paths shown as real hierarchy. Right: the selected agent's live dossier — status, - model, burn breakdown, and a streaming activity feed built from - recentActivity (phase 2: full transcript). A phase progress bar sits on top. -

- -
-
-
-
- t3code · fix-auth-flow · Agents -
- -
-
-
-
-
-
- audit-auth-flow - workflow - {} script - · - Σ 247k tok · 41 tools -
-
-
-
-
-
-
- Audit ✓Verify 2/3Fix -
-
-
-
-
- Phase: Verify -
-
- verify:session-refresh41.2k -
-
- verify:token-rotation26.0k -
-
- verify:cookie-scopequeued -
-
- Phase: Audit -
-
- audit:auth-entrypoints118.4k -
-
- Direct · codex -
-
- Marlow explorer61.8k -
-
- /marlow/grep_worker8.1k -
-
-
-
- verify:session-refresh - sonnet - ⏹ Stop -
-
-
Elapsed
-
Tokens41.2k
-
Tools12
-
Runs1
-
-
- Prompt: “Adversarially verify: session refresh race in refreshSession() — try - to reproduce…” -
-
-
- 18:42:11▸ Bash · vp test run sessionRefresh.test.ts -
-
- 18:41:56▸ Edit · sessionRefresh.test.ts (+34) -
-
- 18:41:20▸ Read · apps/server/src/auth/refresh.ts -
-
- 18:40:58▸ Grep · "refreshSession" — 14 matches -
-
- 18:40:41Spawned · fork: last 2 turns -
-
-
-
-
-
-
-
-
- Option C — tree honors real structure: workflow phases (Claude) and nested agent paths - (Codex, /marlow/grep_worker). The dossier pane streams the recent-activity - feed. Stop is v2+ only: no per-subagent interrupt exists in T3's command - set today, and Codex exposes interruption only as a model tool — it needs its own protocol - work. -
-
- -
-
- Pros -
    -
  • Only option that shows structure: phases, nesting, queue state
  • -
  • Drill-in is first-class, not an afterthought — best debugging surface
  • -
  • Maximized mode turns T3 into a genuine fleet console
  • -
-
-
- Cons -
    -
  • Most surface area to build and maintain (~2–3× Option A)
  • -
  • Overkill for the common case of 1–2 subagents
  • -
  • Split panes need ≥ the maximized panel width to breathe
  • -
-
-
- - -

Recommendation

-
-

- Ship A, seed it with B's collapsed line, grow into C. These aren't - mutually exclusive — they're one system at three zoom levels. But (review pushback, - accepted) they are not all "the same data at more room": each step up needs - specific contract fields to exist first. The milestones below make that explicit so the UI - never promises what the wire can't deliver. -

-
    -
  1. - v1: Option A panel + Option B's - collapsed one-liner only (replacing today's bare "Working…" row; clicking it - opens the panel). Strictly limited to v1-milestone fields. -
  2. -
  3. - v1.5: in-card expansion in the panel → the recent-activity feed (C's - dossier content, without the split layout) + approval deep-links. -
  4. -
  5. - v2: when maximized, the Agents surface upgrades to C's tree + dossier - layout. Stop/steer controls land here — they need new provider-command plumbing, not - just layout. -
  6. -
-
- -

Feature → contract field → milestone

-

- Every visible affordance in the mocks, pinned to its data source. Nothing ships ahead of its - row. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
UI affordanceContract field(s)Milestone
Name, type chip, status dot, elapsed, token barname, agentType, status, firstStartedAt/lastActivityAt, usagev1
"What it's doing" line - currentActivity (AI summary when enabled) with - lastToolName fallback - v1
Workflow grouping + phase headers (✓ Audit / Verify / Fix pending) - kind, parentAgentId, phaseIndex per agent + ordered phases on - the workflow snapshot (phase status derived: done = all members settled, running = any - running, pending = no members yet) - v1
"{} script" → workflow JS in file surface - scriptPath from WorkflowOutput (Claude-only; Codex has no - workflow concept) - v1
Amber row → jump to pending approvalapprovalRequestId (spec §5.1) + existing approval panel focusv1.5
"attempt 2 — retrying" - activationCount + errorMessage (Claude workflows only — - workflow_progress.attempt; no Codex source) - v1.5
Recent-activity feed in card/dossierrecentActivity ring bufferv1.5
Result link ("report → /tmp/…")resultSummary (path rendering is client-side sniffing)v1.5
"While you were away" divider, stale badgesupdatedAt + client sync watermark from subscription statev1.5
Full transcript dossier, per-agent tool feedoutputFile + getAgentTranscript RPC (spec §5.5)v2
Stop / steer buttonsnew provider commands — not in the observability contract at allv2+
-

- Cut from the mocks after review: the "Isolation: worktree" stat (no wire source for either - provider) and the reasoning-effort chip (Codex v2 parent items don't carry effort; revisit - if a child-settings source is plumbed). The per-card provider chip is derived from the - thread's session, not a snapshot field. -

- -

Shared component inventory (all options)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PieceReuses
Status dot + pulse - animate-status-pulse, resolveThreadStatusPill color language - (sky=running, emerald=done, amber=waiting, red=failed, zinc=queued) -
Elapsed timers - WorkingTimer self-ticking DOM-write pattern (no React commits per tick) -
Model/role chipsBadge variants
Token burn counter - Plain tabular-nums count + live-tick indicator. Not a bar/ring: - burn has no denominator, and a fill implies progress. (ContextWindowMeter's - ring stays where a real denominator exists — context windows.) -
Activity feed rowsSimpleWorkEntryRow / work-log derivation, filtered by agentId
Panel shell / tabs / sheetRightPanelTabs + PreviewPanelShell resize + maximize
- -

States to design past the happy path

-
    -
  • - Empty / discovery: the tab auto-appears once the thread has any agent - (and persists with settled history), but the surface is always reachable from the - right panel's "+" add-menu with an explanatory empty state — so users can discover it - before ever triggering an agent. -
  • -
  • - Idle vs done (Codex): a Codex agent that finished a run is - idle and resumable, not completed — dimmed sky dot, "idle · resumable" label. If - the model sends it a follow-up, the card re-activates (run count increments, elapsed - resumes). Distinct from Claude's terminal green check. -
  • -
  • - Waiting on approval: amber row links straight to the pending approval via - approvalRequestId (highest-value click in the whole feature; v1.5). -
  • -
  • - Failure: red dot + errorMessage snippet on the card; Claude - workflow agents show attempt count ("attempt 2 — retrying"). Codex has no retry signal — - failed is failed. -
  • -
  • - Transport states (separate axis from agent lifecycle): loading (skeleton - cards), synchronizing, live, reconnecting (dim panel + "reconnecting…" banner, timers - freeze at lastActivityAt), and subscription-error. While disconnected, - running agents get a stale badge after 30s past updatedAt — a snapshot can't - distinguish "still running" from "died while we were away", so the UI must not pretend it - can. -
  • -
  • - Orphaned: if the provider session exits, the server sweeps non-terminal - agents to stopped ("orphaned on session exit") — the panel shows them as - stopped with that reason, no zombie spinners. -
  • -
  • - Reconnect catch-up: settled-while-away agents (settled - updatedAt newer than the client's last-sync watermark) group under a "while - you were away" divider (v1.5). -
  • -
  • - Overflow: >8 agents → group headers become sticky, settled groups - auto-collapse; the reducer caps settled agents carried in the roster at 50/thread (oldest - settled pruned first). -
  • -
- - -
- - diff --git a/packages/client-runtime/src/state/threadAgents.test.ts b/packages/client-runtime/src/state/threadAgents.test.ts index 713e936207e..197bef132f4 100644 --- a/packages/client-runtime/src/state/threadAgents.test.ts +++ b/packages/client-runtime/src/state/threadAgents.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import type { OrchestrationThreadActivity, ThreadAgentSnapshot } from "@t3tools/contracts"; import { deriveAgentPanelState, diff --git a/packages/client-runtime/src/state/threadAgents.ts b/packages/client-runtime/src/state/threadAgents.ts index 276f3f379c2..c0a237ed302 100644 --- a/packages/client-runtime/src/state/threadAgents.ts +++ b/packages/client-runtime/src/state/threadAgents.ts @@ -9,13 +9,35 @@ import { THREAD_AGENT_TERMINAL_STATUSES, THREAD_AGENTS_ACTIVITY_KIND, - ThreadAgentsActivityPayload, + ThreadAgentSnapshot, type OrchestrationThreadActivity, - type ThreadAgentSnapshot, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -const decodePayload = Schema.decodeUnknownOption(ThreadAgentsActivityPayload); +const decodeAgent = Schema.decodeUnknownOption(ThreadAgentSnapshot); + +/** + * Rows decode per-element: one malformed or forward-incompatible agent entry + * is skipped without discarding the rest of the roster. Only a payload with + * no decodable agents at all falls through to an older snapshot. + */ +function decodeRoster(payload: unknown): ReadonlyArray | undefined { + if (payload === null || typeof payload !== "object") { + return undefined; + } + const agents = (payload as { agents?: unknown }).agents; + if (!Array.isArray(agents)) { + return undefined; + } + const decoded: ThreadAgentSnapshot[] = []; + for (const candidate of agents) { + const result = decodeAgent(candidate); + if (result._tag === "Some") { + decoded.push(result.value); + } + } + return agents.length === 0 || decoded.length > 0 ? decoded : undefined; +} export function deriveLatestAgentSnapshot( activities: ReadonlyArray, @@ -25,9 +47,9 @@ export function deriveLatestAgentSnapshot( if (!activity || activity.kind !== THREAD_AGENTS_ACTIVITY_KIND) { continue; } - const decoded = decodePayload(activity.payload); - if (decoded._tag === "Some") { - return decoded.value.agents; + const roster = decodeRoster(activity.payload); + if (roster !== undefined) { + return roster; } } return []; @@ -60,9 +82,15 @@ export interface AgentPanelState { readonly totalTokens: number; } +function isSettledAgentStatus(status: ThreadAgentSnapshot["status"]): boolean { + // idle counts as settled for phase/summary purposes: the run finished even + // though the agent identity could be resumed. + return status === "idle" || isTerminalAgentStatus(status); +} + function phaseStatus(agents: ReadonlyArray): "pending" | "running" | "done" { if (agents.length === 0) return "pending"; - if (agents.every((agent) => isTerminalAgentStatus(agent.status))) return "done"; + if (agents.every((agent) => isSettledAgentStatus(agent.status))) return "done"; return "running"; } @@ -119,7 +147,7 @@ export function deriveAgentPanelState(agents: ReadonlyArray for (const agent of agents) { if (agent.status === "running" || agent.status === "pending") runningCount += 1; else if (agent.status === "waiting") waitingCount += 1; - else if (isTerminalAgentStatus(agent.status)) settledCount += 1; + else settledCount += 1; // idle + terminal totalTokens += agent.usage?.totalTokens ?? 0; } From ce1f1043bfb8658dc43d9e8360feb8a12d98622a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 02:13:45 -0700 Subject: [PATCH 09/17] fix(ingestion): agent roster lifecycle hardening from round-2 review - session.exited now releases the per-thread reducer state after the final snapshot persists (unbounded retention leak) - failed snapshot dispatch keeps the newer in-memory roster and flags the thread for re-dispatch on the next event instead of discarding memory (previous fix could lose transitions) - document the 500-cap hydration boundary: past the cap both server and clients have lost the snapshot, so empty hydration is consistent Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.test.ts | 22 +++++++ .../Layers/ProviderRuntimeIngestion.ts | 57 ++++++++++++------- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d90d0f1b7c6..3462955fccf 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -427,6 +427,28 @@ describe("ProviderRuntimeIngestion", () => { expect(latest?.agents).toHaveLength(1); expect(latest?.agents[0]?.status).toBe("stopped"); expect(latest?.agents[0]?.errorMessage).toBe("orphaned on session exit"); + + // The session-exit sweep releases the in-memory roster; a later session on + // the same thread re-hydrates from the persisted snapshot instead of the + // evicted map (agent-1 stays stopped, not resurrected). + harness.emit({ + type: "task.started", + eventId: asEventId("evt-agent-restart"), + provider: ProviderDriverKind.make("claudeAgent"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:04.000Z", + payload: { taskId: "agent-2", description: "Follow-up", taskType: "local_agent" }, + }); + await harness.drain(); + thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + const rehydrated = + thread?.activities.filter((activity) => activity.kind === "agent.snapshot") ?? []; + expect(rehydrated).toHaveLength(3); + const roster = rehydrated.at(-1)?.payload as { agents: Array } | undefined; + expect(roster?.agents.map((agent) => [agent.agentId, agent.status])).toEqual([ + ["agent-1", "stopped"], + ["agent-2", "running"], + ]); }); it("maps turn started/completed events into thread session updates", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 6706a7da9bc..377d5f5796d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -910,6 +910,10 @@ const make = Effect.gen(function* () { const serverSettingsService = yield* ServerSettingsService; const agentsByThread = new Map>(); const hydratedAgentThreads = new Set(); + // Threads whose latest roster failed to persist: the in-memory state is + // newer than the last agent.snapshot activity, so the next event must + // re-dispatch even if it is not material on its own. + const pendingRosterRedispatch = new Set(); let agentSnapshotDispatchCount = 0; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( @@ -1524,6 +1528,12 @@ const make = Effect.gen(function* () { }); if (!hydratedAgentThreads.has(thread.id)) { + // Hydration source is the 500-capped activities projection — the same + // list clients derive from. If the last agent.snapshot has aged past + // the cap (500 activities with no material agent transition), both + // sides have already lost it and hydrating empty is consistent, not a + // regression; the roster only ever contains agents from the era the + // timeline still shows. const detail = yield* getLoadedThreadDetail(); const latestSnapshot = detail?.activities.findLast( (activity) => activity.kind === THREAD_AGENTS_ACTIVITY_KIND, @@ -1568,6 +1578,9 @@ const make = Effect.gen(function* () { agentRosterMaterial = true; } } + if (pendingRosterRedispatch.has(thread.id) && threadAgents.size > 0) { + agentRosterMaterial = true; + } if (agentRosterMaterial) { pruneSettledAgents(threadAgents); const roster = Array.from(threadAgents.values()); @@ -1589,26 +1602,22 @@ const make = Effect.gen(function* () { payload: { agents: roster }, turnId: toTurnId(event.turnId) ?? null, }; - // The reducer map was already mutated; if the append fails, drop the - // hydration marker so the next event re-hydrates from the last - // *persisted* snapshot and re-detects the missed material change — - // otherwise the roster silently stays stale until the next transition. - yield* orchestrationEngine - .dispatch({ - type: "thread.activity.append", - commandId: yield* providerCommandId(event, "agent-snapshot-append"), - threadId: thread.id, - activity, - createdAt: activity.createdAt, - }) - .pipe( - Effect.tapError(() => - Effect.sync(() => { - hydratedAgentThreads.delete(thread.id); - agentsByThread.delete(thread.id); - }), - ), - ); + // The in-memory roster is authoritative (already folded); if the + // append fails, keep it and flag the thread so the NEXT event + // re-dispatches the full roster — discarding memory here would lose + // transitions newer than the last persisted snapshot. + // Armed before the dispatch and cleared only on success: if the append + // fails (the error propagates to processInputSafely as usual), the + // flag makes the NEXT event re-dispatch the full roster. + pendingRosterRedispatch.add(thread.id); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: yield* providerCommandId(event, "agent-snapshot-append"), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }); + pendingRosterRedispatch.delete(thread.id); agentSnapshotDispatchCount += 1; yield* Effect.logDebug("provider agent snapshot appended", { threadId: thread.id, @@ -1617,6 +1626,14 @@ const make = Effect.gen(function* () { }); } + // Sessions are done producing agent events once they exit; release the + // per-thread reducer state. A later session re-hydrates from the + // persisted snapshot (the exit sweep above just wrote the final roster). + if (event.type === "session.exited" && !pendingRosterRedispatch.has(thread.id)) { + agentsByThread.delete(thread.id); + hydratedAgentThreads.delete(thread.id); + } + const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; From 4b9b7c515d7458ffb27dae3d633696eb4c0b2b00 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 13:33:07 -0700 Subject: [PATCH 10/17] fix(ingestion): non-blocking snapshot appends + roster refresh before cap eviction - A failed agent.snapshot append no longer aborts the rest of the event's ingestion (Bugbot: repeated failures were dropping message deltas/session status). Failures are logged, interrupts propagate, and the redispatch flag retries on the next event. - While agents exist, the roster is re-published every ~400 timeline activities so the latest snapshot cannot age past the projector's 500-row cap (Macroscope: hydration and client scans always find one). Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.ts | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 377d5f5796d..d662cfe35c5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -914,6 +914,12 @@ const make = Effect.gen(function* () { // newer than the last agent.snapshot activity, so the next event must // re-dispatch even if it is not material on its own. const pendingRosterRedispatch = new Set(); + // Activities appended per thread since its last agent.snapshot. The + // activities projection keeps only the newest 500 rows; refreshing the + // roster before the last snapshot can age out guarantees hydration (and the + // client's latest-wins scan) always finds one while agents exist. + const activitiesSinceAgentSnapshot = new Map(); + const AGENT_SNAPSHOT_REFRESH_ACTIVITY_COUNT = 400; let agentSnapshotDispatchCount = 0; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( @@ -1529,11 +1535,10 @@ const make = Effect.gen(function* () { if (!hydratedAgentThreads.has(thread.id)) { // Hydration source is the 500-capped activities projection — the same - // list clients derive from. If the last agent.snapshot has aged past - // the cap (500 activities with no material agent transition), both - // sides have already lost it and hydrating empty is consistent, not a - // regression; the roster only ever contains agents from the era the - // timeline still shows. + // list clients derive from. While agents exist, the refresh counter + // below re-publishes the roster every ~400 activities so the latest + // snapshot cannot age past the cap; an empty result here means the + // thread genuinely has no roster in the visible timeline era. const detail = yield* getLoadedThreadDetail(); const latestSnapshot = detail?.activities.findLast( (activity) => activity.kind === THREAD_AGENTS_ACTIVITY_KIND, @@ -1581,6 +1586,13 @@ const make = Effect.gen(function* () { if (pendingRosterRedispatch.has(thread.id) && threadAgents.size > 0) { agentRosterMaterial = true; } + if ( + !agentRosterMaterial && + threadAgents.size > 0 && + (activitiesSinceAgentSnapshot.get(thread.id) ?? 0) >= AGENT_SNAPSHOT_REFRESH_ACTIVITY_COUNT + ) { + agentRosterMaterial = true; + } if (agentRosterMaterial) { pruneSettledAgents(threadAgents); const roster = Array.from(threadAgents.values()); @@ -1605,20 +1617,37 @@ const make = Effect.gen(function* () { // The in-memory roster is authoritative (already folded); if the // append fails, keep it and flag the thread so the NEXT event // re-dispatches the full roster — discarding memory here would lose - // transitions newer than the last persisted snapshot. - // Armed before the dispatch and cleared only on success: if the append - // fails (the error propagates to processInputSafely as usual), the - // flag makes the NEXT event re-dispatch the full roster. + // transitions newer than the last persisted snapshot. The failure is + // swallowed (logged) rather than raised: agent observability must + // never block the rest of this event's ingestion (message deltas, + // session status, timeline activities). Interrupts still propagate. pendingRosterRedispatch.add(thread.id); - yield* orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: yield* providerCommandId(event, "agent-snapshot-append"), - threadId: thread.id, - activity, - createdAt: activity.createdAt, - }); - pendingRosterRedispatch.delete(thread.id); - agentSnapshotDispatchCount += 1; + yield* orchestrationEngine + .dispatch({ + type: "thread.activity.append", + commandId: yield* providerCommandId(event, "agent-snapshot-append"), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }) + .pipe( + Effect.andThen( + Effect.sync(() => { + pendingRosterRedispatch.delete(thread.id); + activitiesSinceAgentSnapshot.set(thread.id, 0); + agentSnapshotDispatchCount += 1; + }), + ), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider agent snapshot append failed; will re-dispatch", { + threadId: thread.id, + agentCount: roster.length, + cause: Cause.pretty(cause), + }), + ), + ); yield* Effect.logDebug("provider agent snapshot appended", { threadId: thread.id, agentCount: roster.length, @@ -1632,6 +1661,7 @@ const make = Effect.gen(function* () { if (event.type === "session.exited" && !pendingRosterRedispatch.has(thread.id)) { agentsByThread.delete(thread.id); hydratedAgentThreads.delete(thread.id); + activitiesSinceAgentSnapshot.delete(thread.id); } const now = event.createdAt; @@ -2088,6 +2118,12 @@ const make = Effect.gen(function* () { } const activities = runtimeEventToActivities(event, taskTitle); + if (activities.length > 0 && agentsByThread.get(thread.id)?.size) { + activitiesSinceAgentSnapshot.set( + thread.id, + (activitiesSinceAgentSnapshot.get(thread.id) ?? 0) + activities.length, + ); + } yield* Effect.forEach(activities, (activity) => providerCommandId(event, "thread-activity-append").pipe( Effect.flatMap((commandId) => From a3248a123a64498666a7580e98241ce0999c1d1d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 13:39:18 -0700 Subject: [PATCH 11/17] fix(ingestion): seed roster refresh counter from snapshot age; log append success only on success Bugbot round 4: after restart the latest agent.snapshot can already sit deep in the capped projection, so the refresh counter now seeds from its actual index distance instead of zero; the 'appended' debug log moved into the success branch so a swallowed failure no longer logs success. Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d662cfe35c5..f1f3b7ae200 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1540,9 +1540,20 @@ const make = Effect.gen(function* () { // snapshot cannot age past the cap; an empty result here means the // thread genuinely has no roster in the visible timeline era. const detail = yield* getLoadedThreadDetail(); - const latestSnapshot = detail?.activities.findLast( + const activityList = detail?.activities ?? []; + const latestSnapshotIndex = activityList.findLastIndex( (activity) => activity.kind === THREAD_AGENTS_ACTIVITY_KIND, ); + const latestSnapshot = + latestSnapshotIndex >= 0 ? activityList[latestSnapshotIndex] : undefined; + // Seed the refresh counter with the snapshot's actual age in the + // capped projection — after a restart it may already sit hundreds of + // rows deep, and starting from zero would let it age out before the + // first refresh. + activitiesSinceAgentSnapshot.set( + thread.id, + latestSnapshotIndex >= 0 ? activityList.length - 1 - latestSnapshotIndex : 0, + ); const payload = latestSnapshot?.payload !== null && typeof latestSnapshot?.payload === "object" ? (latestSnapshot.payload as { agents?: unknown }) @@ -1632,10 +1643,15 @@ const make = Effect.gen(function* () { }) .pipe( Effect.andThen( - Effect.sync(() => { + Effect.gen(function* () { pendingRosterRedispatch.delete(thread.id); activitiesSinceAgentSnapshot.set(thread.id, 0); agentSnapshotDispatchCount += 1; + yield* Effect.logDebug("provider agent snapshot appended", { + threadId: thread.id, + agentCount: roster.length, + dispatchCount: agentSnapshotDispatchCount, + }); }), ), Effect.catchCause((cause) => @@ -1648,11 +1664,6 @@ const make = Effect.gen(function* () { }), ), ); - yield* Effect.logDebug("provider agent snapshot appended", { - threadId: thread.id, - agentCount: roster.length, - dispatchCount: agentSnapshotDispatchCount, - }); } // Sessions are done producing agent events once they exit; release the From 4586d5f3740d417a9261b021b8aeb47de5edc702 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 14:09:38 -0700 Subject: [PATCH 12/17] fix: address review handoff P1/P2 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: - Claude workflow agents keyed by stable row index (parent:wf:N), not per-attempt agentId — queued/blocked rows now appear, retries update one card instead of duplicating - agent.snapshot payloads carry a monotonic per-thread revision; client latest-wins selection uses it (deterministic under same-ms appends) - Codex child turn/completed decodes turn.status: failed→failed with error summary, interrupted→stopped, completed→idle - roster hydration is lazy (only on agent-touching events), no longer a full thread-detail load on every first event per thread P2: - reducer re-derives kind from event taskType/parent (no first-guess pinning); reactivation counts any settled→active transition - workflow container rows excluded from worker counts; container usage only counted when it has no member rows - settled cards lead with error/result, never stale activity text - newest agents array is authoritative on the client — undecodable rosters no longer resurrect older running snapshots - idle (resumable) agents no longer share the terminal retention pool - Codex usage uses the cumulative total breakdown consistently Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.ts | 49 ++++++++++--- .../src/provider/Layers/ClaudeAdapter.test.ts | 3 +- .../src/provider/Layers/ClaudeAdapter.ts | 10 ++- .../src/provider/Layers/CodexAdapter.ts | 53 +++++++++++---- apps/web/src/components/AgentsPanel.tsx | 14 ++-- .../src/state/threadAgents.test.ts | 24 ++++++- .../client-runtime/src/state/threadAgents.ts | 68 ++++++++++++++----- packages/contracts/src/threadAgents.ts | 3 + 8 files changed, 173 insertions(+), 51 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index f1f3b7ae200..9e1fbbc4046 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -141,7 +141,12 @@ export function foldTaskAgentEvent( const summary = "summary" in payload ? payload.summary : undefined; const nextUsage = "usage" in payload ? taskUsage(payload.usage) : undefined; const lastToolName = "lastToolName" in payload ? payload.lastToolName : undefined; - const currentActivity = summary ?? lastToolName ?? previous?.currentActivity; + const settledNow = status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(status); + // A settled agent's card shows its result/error, not a stale in-flight + // activity line ("Reading files…" on a failed card). + const currentActivity = settledNow + ? undefined + : (summary ?? lastToolName ?? previous?.currentActivity); const activityChanged = (summary !== undefined && summary !== previous?.currentActivity) || (lastToolName !== undefined && lastToolName !== previous?.lastToolName); @@ -154,15 +159,23 @@ export function foldTaskAgentEvent( const wasSettled = previous !== undefined && (previous.status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(previous.status)); - const reactivated = wasSettled && status === "running"; + // Any non-settled status counts as a fresh activation — an idle Codex agent + // resumed via a waiting approval still increments, not just idle→running. + const reactivated = + wasSettled && status !== "idle" && !THREAD_AGENT_TERMINAL_STATUSES.has(status); const explicitEndTime = event.type === "task.updated" ? event.payload.endTime : undefined; const terminal = THREAD_AGENT_TERMINAL_STATUSES.has(status); + // Re-derive kind whenever this event carries a taskType/parent — a child + // registered from an early notification (before its parent linkage arrived) + // must not stay pinned to a first-guess "other". + const eventKind = taskKind( + "taskType" in payload ? payload.taskType : undefined, + payload.parentTaskId, + ); const next: ThreadAgentSnapshot = { agentId: payload.taskId, provider: event.provider, - kind: - previous?.kind ?? - taskKind("taskType" in payload ? payload.taskType : undefined, payload.parentTaskId), + kind: eventKind !== "other" ? eventKind : (previous?.kind ?? eventKind), name: payload.name ?? description ?? payload.workflowName ?? previous?.name ?? payload.taskId, ...((payload.agentType ?? previous?.agentType) ? { agentType: payload.agentType ?? previous?.agentType } @@ -240,8 +253,11 @@ export function foldTaskAgentEvent( } export function pruneSettledAgents(agents: Map): void { + // Idle agents are resumable identities, not history — only terminal agents + // compete for the retention pool, so re-activating an old idle Codex child + // never loses its usage/activation record to pruning. const settled = Array.from(agents.values()) - .filter((agent) => agent.status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(agent.status)) + .filter((agent) => THREAD_AGENT_TERMINAL_STATUSES.has(agent.status)) .sort((left, right) => left.lastActivityAt.localeCompare(right.lastActivityAt)); for (const agent of settled.slice( 0, @@ -920,6 +936,10 @@ const make = Effect.gen(function* () { // client's latest-wins scan) always finds one while agents exist. const activitiesSinceAgentSnapshot = new Map(); const AGENT_SNAPSHOT_REFRESH_ACTIVITY_COUNT = 400; + // Monotonic per-thread roster revision. Snapshot activities carry it so the + // client picks the newest roster deterministically even when two appends + // share a createdAt millisecond. Seeded from the hydrated snapshot. + const agentRosterRevision = new Map(); let agentSnapshotDispatchCount = 0; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( @@ -1533,7 +1553,12 @@ const make = Effect.gen(function* () { return loadedThreadDetail; }); - if (!hydratedAgentThreads.has(thread.id)) { + // Hydrate lazily, only when an event actually touches the roster — + // never on ordinary traffic (message deltas would otherwise trigger a + // full thread-detail load per thread after every restart, serialized + // through the ingestion worker). + const eventTouchesAgents = isTaskAgentEvent(event) || event.type === "session.exited"; + if (eventTouchesAgents && !hydratedAgentThreads.has(thread.id)) { // Hydration source is the 500-capped activities projection — the same // list clients derive from. While agents exist, the refresh counter // below re-publishes the roster every ~400 activities so the latest @@ -1556,8 +1581,11 @@ const make = Effect.gen(function* () { ); const payload = latestSnapshot?.payload !== null && typeof latestSnapshot?.payload === "object" - ? (latestSnapshot.payload as { agents?: unknown }) + ? (latestSnapshot.payload as { agents?: unknown; revision?: unknown }) : undefined; + if (typeof payload?.revision === "number" && Number.isInteger(payload.revision)) { + agentRosterRevision.set(thread.id, payload.revision); + } const agents = new Map(); if (Array.isArray(payload?.agents)) { for (const candidate of payload.agents) { @@ -1615,6 +1643,7 @@ const make = Effect.gen(function* () { const settled = roster.length - active; const count = active > 0 ? active : settled; const summary = `${count} ${count === 1 ? "agent" : "agents"} ${active > 0 ? "active" : "settled"}`; + const nextRevision = (agentRosterRevision.get(thread.id) ?? 0) + 1; const snapshotUuid = yield* crypto.randomUUIDv4; const activity: OrchestrationThreadActivity = { id: EventId.make(`${event.eventId}:agent-snapshot:${snapshotUuid}`), @@ -1622,7 +1651,7 @@ const make = Effect.gen(function* () { tone: "info", kind: THREAD_AGENTS_ACTIVITY_KIND, summary, - payload: { agents: roster }, + payload: { agents: roster, revision: nextRevision }, turnId: toTurnId(event.turnId) ?? null, }; // The in-memory roster is authoritative (already folded); if the @@ -1646,6 +1675,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { pendingRosterRedispatch.delete(thread.id); activitiesSinceAgentSnapshot.set(thread.id, 0); + agentRosterRevision.set(thread.id, nextRevision); agentSnapshotDispatchCount += 1; yield* Effect.logDebug("provider agent snapshot appended", { threadId: thread.id, @@ -1673,6 +1703,7 @@ const make = Effect.gen(function* () { agentsByThread.delete(thread.id); hydratedAgentThreads.delete(thread.id); activitiesSinceAgentSnapshot.delete(thread.id); + agentRosterRevision.delete(thread.id); } const now = event.createdAt; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 91581f90c48..bc6aa5886fe 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1755,6 +1755,7 @@ describe("ClaudeAdapterLive", () => { { type: "workflow_phase", index: 0, title: "Inspect" }, { type: "workflow_agent", + index: 1, label: "Reviewer", phaseIndex: 0, phaseTitle: "Inspect", @@ -1805,7 +1806,7 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(phasePatch.payload.phases, [{ index: 0, title: "Inspect" }]); } const child = runtimeEvents.find( - (event) => event.type === "task.started" && event.payload.taskId === "agent-1", + (event) => event.type === "task.started" && event.payload.taskId === "workflow-1:wf:1", ); assert.equal(child?.type, "task.started"); if (child?.type === "task.started") { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 8f0febdfd87..d1972c52f79 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2805,14 +2805,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue; const item = entry as Record; if (item.type !== "workflow_agent") continue; - const agentId = readString(item.agentId); + // `index` is the stable identity: queued/blocked/pre-spawn-error + // events carry no agentId yet, and retries keep the index while + // minting a new agentId per attempt. Keying on agentId would drop + // queued agents and duplicate cards across retries. + const index = readNonNegativeInteger(item.index); const label = readString(item.label); - if (!agentId || !label) continue; + if (index === undefined || !label) continue; const state = readString(item.state); const childUsage = readNonNegativeInteger(item.tokens); const childToolUses = readNonNegativeInteger(item.toolCalls); const childPayload = { - taskId: RuntimeTaskId.make(agentId), + taskId: RuntimeTaskId.make(`${message.task_id}:wf:${index}`), description: label, name: label, taskType: "workflow_agent", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e96ad34c55f..50a6e17b30a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -546,7 +546,34 @@ function mapCollabAgentActivity( switch (wrapper.method) { case "turn/started": return [{ ...base, type: "task.started", payload: { ...base.payload } }]; - case "turn/completed": + case "turn/completed": { + const payload = readPayload(EffectCodexSchema.V2TurnCompletedNotification, inner.payload); + const turnStatus = payload?.turn.status; + const errorMessage = payload?.turn.error?.message; + if (turnStatus === "failed") { + return [ + { + ...base, + type: "task.completed", + payload: { + taskId: base.payload.taskId, + status: "failed", + ...(errorMessage ? { summary: errorMessage } : {}), + timelineBypass: true, + }, + }, + ]; + } + if (turnStatus === "interrupted") { + return [ + { + ...base, + type: "task.completed", + payload: { taskId: base.payload.taskId, status: "stopped", timelineBypass: true }, + }, + ]; + } + // completed (or unparseable payload): the identity is idle-and-resumable. return [ { ...base, @@ -554,6 +581,7 @@ function mapCollabAgentActivity( payload: { taskId: base.payload.taskId, status: "idle", timelineBypass: true }, }, ]; + } case "subAgent/interrupted": return [ { @@ -614,8 +642,11 @@ function mapCollabAgentActivity( EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification, inner.payload, ); - const usage = payload ? normalizeCodexTokenUsage(payload.tokenUsage) : undefined; - if (!usage) return []; + // All fields from the cumulative `total` breakdown — mixing the + // cumulative total with `last` (per-turn) breakdowns would make the + // numbers internally inconsistent, and `last` can shrink on follow-ups. + const total = payload?.tokenUsage.total; + if (!total || total.totalTokens <= 0) return []; return [ { ...base, @@ -623,17 +654,11 @@ function mapCollabAgentActivity( payload: { ...base.payload, usage: { - // Cumulative across the child's activations; `usedTokens` is - // only the latest context window and can shrink on follow-ups. - totalTokens: usage.totalProcessedTokens ?? usage.usedTokens, - ...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}), - ...(usage.cachedInputTokens !== undefined - ? { cachedInputTokens: usage.cachedInputTokens } - : {}), - ...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens } : {}), - ...(usage.reasoningOutputTokens !== undefined - ? { reasoningOutputTokens: usage.reasoningOutputTokens } - : {}), + totalTokens: total.totalTokens, + inputTokens: total.inputTokens, + cachedInputTokens: total.cachedInputTokens, + outputTokens: total.outputTokens, + reasoningOutputTokens: total.reasoningOutputTokens, }, }, }, diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 1c27f0ad5ac..0a8ad6234d0 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -90,13 +90,19 @@ function AgentElapsed({ agent }: { agent: ThreadAgentSnapshot }) { function AgentCard({ agent }: { agent: ThreadAgentSnapshot }) { const [expanded, setExpanded] = useState(false); const settled = isTerminalAgentStatus(agent.status); + // Settled cards lead with outcome (error first); live cards with activity. const activity = agent.status === "waiting" ? "Waiting on approval" - : (agent.currentActivity ?? - (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? - agent.resultSummary ?? - agent.errorMessage); + : settled || agent.status === "idle" + ? (agent.errorMessage ?? + agent.resultSummary ?? + agent.currentActivity ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null)) + : (agent.currentActivity ?? + (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? + agent.resultSummary ?? + agent.errorMessage); const hasFeed = agent.recentActivity.length > 0; return ( diff --git a/packages/client-runtime/src/state/threadAgents.test.ts b/packages/client-runtime/src/state/threadAgents.test.ts index 197bef132f4..b42f0b939a2 100644 --- a/packages/client-runtime/src/state/threadAgents.test.ts +++ b/packages/client-runtime/src/state/threadAgents.test.ts @@ -51,15 +51,33 @@ describe("deriveLatestAgentSnapshot", () => { expect(agents[0]?.resultSummary).toBe("Hi! How can I help you today?"); }); - it("skips rows whose payload fails to decode instead of failing", () => { + it("treats the newest agents array as authoritative even when its rows fail to decode", () => { + // Falling back to the older roster would resurrect a stale "running" + // snapshot; an undecodable newest roster must yield an empty panel. const agents = deriveLatestAgentSnapshot([ - activity("agent.snapshot", { agents: [persistedAgent] }, 1), - activity("agent.snapshot", { agents: [{ bogus: true }] }, 2), + activity("agent.snapshot", { agents: [persistedAgent], revision: 1 }, 1), + activity("agent.snapshot", { agents: [{ bogus: true }], revision: 2 }, 2), + ]); + expect(agents).toHaveLength(0); + }); + + it("skips bad rows within a roster while keeping decodable ones", () => { + const agents = deriveLatestAgentSnapshot([ + activity("agent.snapshot", { agents: [persistedAgent, { bogus: true }], revision: 1 }, 1), ]); expect(agents).toHaveLength(1); expect(agents[0]?.agentId).toBe(persistedAgent.agentId); }); + it("selects the highest revision regardless of list position", () => { + const older = { ...persistedAgent, status: "running" }; + const agents = deriveLatestAgentSnapshot([ + activity("agent.snapshot", { agents: [persistedAgent], revision: 5 }, 1), + activity("agent.snapshot", { agents: [older], revision: 4 }, 2), + ]); + expect(agents[0]?.status).toBe("completed"); + }); + it("returns an empty roster when no snapshot activity exists", () => { expect(deriveLatestAgentSnapshot([activity("task.progress", {}, 1)])).toHaveLength(0); }); diff --git a/packages/client-runtime/src/state/threadAgents.ts b/packages/client-runtime/src/state/threadAgents.ts index c0a237ed302..fbe114ab5b6 100644 --- a/packages/client-runtime/src/state/threadAgents.ts +++ b/packages/client-runtime/src/state/threadAgents.ts @@ -16,43 +16,64 @@ import * as Schema from "effect/Schema"; const decodeAgent = Schema.decodeUnknownOption(ThreadAgentSnapshot); +interface DecodedRoster { + readonly agents: ReadonlyArray; + readonly revision: number | undefined; +} + /** * Rows decode per-element: one malformed or forward-incompatible agent entry - * is skipped without discarding the rest of the roster. Only a payload with - * no decodable agents at all falls through to an older snapshot. + * is skipped without discarding the rest of the roster. Any payload with an + * `agents` array is authoritative — a roster whose rows all fail to decode + * yields an empty panel rather than resurrecting an older (possibly still + * "running") snapshot. */ -function decodeRoster(payload: unknown): ReadonlyArray | undefined { +function decodeRoster(payload: unknown): DecodedRoster | undefined { if (payload === null || typeof payload !== "object") { return undefined; } - const agents = (payload as { agents?: unknown }).agents; - if (!Array.isArray(agents)) { + const record = payload as { agents?: unknown; revision?: unknown }; + if (!Array.isArray(record.agents)) { return undefined; } const decoded: ThreadAgentSnapshot[] = []; - for (const candidate of agents) { + for (const candidate of record.agents) { const result = decodeAgent(candidate); if (result._tag === "Some") { decoded.push(result.value); } } - return agents.length === 0 || decoded.length > 0 ? decoded : undefined; + return { + agents: decoded, + revision: + typeof record.revision === "number" && Number.isInteger(record.revision) + ? record.revision + : undefined, + }; } export function deriveLatestAgentSnapshot( activities: ReadonlyArray, ): ReadonlyArray { - for (let index = activities.length - 1; index >= 0; index -= 1) { - const activity = activities[index]; - if (!activity || activity.kind !== THREAD_AGENTS_ACTIVITY_KIND) { + // Highest revision wins; list order breaks ties (and covers revision-less + // rosters from before the field existed). Guards against same-millisecond + // appends landing out of order in the capped projection. + let best: DecodedRoster | undefined; + for (const activity of activities) { + if (activity.kind !== THREAD_AGENTS_ACTIVITY_KIND) { continue; } const roster = decodeRoster(activity.payload); - if (roster !== undefined) { - return roster; + if (!roster) { + continue; + } + if (!best || roster.revision === undefined || best.revision === undefined) { + best = roster; + } else if (roster.revision >= best.revision) { + best = roster; } } - return []; + return best?.agents ?? []; } export function isTerminalAgentStatus(status: ThreadAgentSnapshot["status"]): boolean { @@ -140,15 +161,28 @@ export function deriveAgentPanelState(agents: ReadonlyArray groups.push({ workflow: null, phases: [], rest: direct }); } + // Workflow container rows are grouping chrome, not workers: they are + // excluded from worker counts, and a container's own usage only counts when + // it has no member rows to avoid double-counting the same tokens. + const workflowsWithMembers = new Set( + agents.flatMap((agent) => + agent.kind !== "workflow" && agent.parentAgentId ? [agent.parentAgentId] : [], + ), + ); let runningCount = 0; let waitingCount = 0; let settledCount = 0; let totalTokens = 0; for (const agent of agents) { - if (agent.status === "running" || agent.status === "pending") runningCount += 1; - else if (agent.status === "waiting") waitingCount += 1; - else settledCount += 1; // idle + terminal - totalTokens += agent.usage?.totalTokens ?? 0; + const isContainer = agent.kind === "workflow"; + if (!isContainer) { + if (agent.status === "running" || agent.status === "pending") runningCount += 1; + else if (agent.status === "waiting") waitingCount += 1; + else settledCount += 1; // idle + terminal + } + if (!isContainer || !workflowsWithMembers.has(agent.agentId)) { + totalTokens += agent.usage?.totalTokens ?? 0; + } } return { groups, runningCount, waitingCount, settledCount, totalTokens }; diff --git a/packages/contracts/src/threadAgents.ts b/packages/contracts/src/threadAgents.ts index ad43e918d2d..3a50a568317 100644 --- a/packages/contracts/src/threadAgents.ts +++ b/packages/contracts/src/threadAgents.ts @@ -134,5 +134,8 @@ export const THREAD_AGENTS_ACTIVITY_KIND = "agent.snapshot"; */ export const ThreadAgentsActivityPayload = Schema.Struct({ agents: Schema.Array(ThreadAgentSnapshot), + // Monotonic per-thread revision: latest-wins selection uses the highest + // revision (not list position), making same-timestamp appends deterministic. + revision: Schema.optional(NonNegativeInt), }); export type ThreadAgentsActivityPayload = typeof ThreadAgentsActivityPayload.Type; From 812aee975a9c4ac8c99cadc8513abe33e78c8096 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 14:18:05 -0700 Subject: [PATCH 13/17] =?UTF-8?q?fix(ingestion+claude):=20round-5=20review?= =?UTF-8?q?=20=E2=80=94=20hydration=20revision=20parity,=20amortized=20ref?= =?UTF-8?q?resh,=20queued=20workflow=20agents,=20kind=20materiality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hydration selects the persisted snapshot by highest revision (same rule as the client), so the reducer can't resume from a stale roster that landed later in the list - Ordinary traffic now counts toward unhydrated threads and triggers amortized hydration+refresh, so a settled snapshot can't age out of the 500-row cap after a restart with no agent events - Claude workflow_agent state:'start' without startedAt (queued) maps to pending instead of running - kind/parentAgentId upgrades count as material transitions Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.ts | 63 +++++++++++++------ .../src/provider/Layers/ClaudeAdapter.ts | 17 +++++ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 9e1fbbc4046..a62ffb66d4b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -235,6 +235,8 @@ export function foldTaskAgentEvent( Math.floor((next.usage?.totalTokens ?? 0) / AGENT_USAGE_MATERIAL_STEP); return ( previous.status !== next.status || + previous.kind !== next.kind || + previous.parentAgentId !== next.parentAgentId || previous.name !== next.name || previous.model !== next.model || previous.phaseIndex !== next.phaseIndex || @@ -1553,22 +1555,40 @@ const make = Effect.gen(function* () { return loadedThreadDetail; }); - // Hydrate lazily, only when an event actually touches the roster — - // never on ordinary traffic (message deltas would otherwise trigger a - // full thread-detail load per thread after every restart, serialized - // through the ingestion worker). + // Hydrate lazily: on agent-touching events, or amortized once ordinary + // traffic has appended enough activities that a persisted snapshot + // could be approaching the 500-row cap. Never on every first event — + // message deltas after a restart must not each trigger a full + // thread-detail load through the serialized ingestion worker. const eventTouchesAgents = isTaskAgentEvent(event) || event.type === "session.exited"; - if (eventTouchesAgents && !hydratedAgentThreads.has(thread.id)) { - // Hydration source is the 500-capped activities projection — the same - // list clients derive from. While agents exist, the refresh counter - // below re-publishes the roster every ~400 activities so the latest - // snapshot cannot age past the cap; an empty result here means the - // thread genuinely has no roster in the visible timeline era. + const unhydratedActivityPressure = + !hydratedAgentThreads.has(thread.id) && + (activitiesSinceAgentSnapshot.get(thread.id) ?? 0) >= AGENT_SNAPSHOT_REFRESH_ACTIVITY_COUNT; + if ( + (eventTouchesAgents || unhydratedActivityPressure) && + !hydratedAgentThreads.has(thread.id) + ) { const detail = yield* getLoadedThreadDetail(); const activityList = detail?.activities ?? []; - const latestSnapshotIndex = activityList.findLastIndex( - (activity) => activity.kind === THREAD_AGENTS_ACTIVITY_KIND, - ); + // Select the snapshot the same way the client does — highest revision + // wins, list position breaks ties — so the reducer never resumes from + // a stale lower-revision roster that landed later in the list. + let latestSnapshotIndex = -1; + let bestRevision = -1; + for (let index = 0; index < activityList.length; index += 1) { + const activity = activityList[index]; + if (!activity || activity.kind !== THREAD_AGENTS_ACTIVITY_KIND) continue; + const revision = + activity.payload !== null && + typeof activity.payload === "object" && + typeof (activity.payload as { revision?: unknown }).revision === "number" + ? ((activity.payload as { revision: number }).revision ?? -1) + : -1; + if (revision >= bestRevision) { + bestRevision = revision; + latestSnapshotIndex = index; + } + } const latestSnapshot = latestSnapshotIndex >= 0 ? activityList[latestSnapshotIndex] : undefined; // Seed the refresh counter with the snapshot's actual age in the @@ -1579,13 +1599,13 @@ const make = Effect.gen(function* () { thread.id, latestSnapshotIndex >= 0 ? activityList.length - 1 - latestSnapshotIndex : 0, ); + if (bestRevision >= 0) { + agentRosterRevision.set(thread.id, bestRevision); + } const payload = latestSnapshot?.payload !== null && typeof latestSnapshot?.payload === "object" - ? (latestSnapshot.payload as { agents?: unknown; revision?: unknown }) + ? (latestSnapshot.payload as { agents?: unknown }) : undefined; - if (typeof payload?.revision === "number" && Number.isInteger(payload.revision)) { - agentRosterRevision.set(thread.id, payload.revision); - } const agents = new Map(); if (Array.isArray(payload?.agents)) { for (const candidate of payload.agents) { @@ -2160,7 +2180,14 @@ const make = Effect.gen(function* () { } const activities = runtimeEventToActivities(event, taskTitle); - if (activities.length > 0 && agentsByThread.get(thread.id)?.size) { + // Count for hydrated threads with a live roster AND for not-yet-hydrated + // threads: the counter is what triggers amortized hydration under + // ordinary traffic after a restart, so a persisted snapshot gets + // refreshed before it can age out of the capped projection. + if ( + activities.length > 0 && + (!hydratedAgentThreads.has(thread.id) || agentsByThread.get(thread.id)?.size) + ) { activitiesSinceAgentSnapshot.set( thread.id, (activitiesSinceAgentSnapshot.get(thread.id) ?? 0) + activities.length, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d1972c52f79..0fb9f372ad8 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2851,6 +2851,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( type: "task.started", payload: childPayload, }); + // The CLI emits state:"start" both when queueing (queuedAt only) + // and when execution begins (startedAt set). A queued agent must + // not show as running in the panel. + if (readNonNegativeInteger(item.startedAt) === undefined) { + const pendingStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: pendingStamp.eventId, + createdAt: pendingStamp.createdAt, + type: "task.updated", + payload: { + taskId: childPayload.taskId, + status: "pending", + timelineBypass: true, + }, + }); + } } else if (state === "done" || state === "error") { yield* offerRuntimeEvent({ ...base, From ffd01045d01435e3c3099e7878d4e6023f9cceaf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 14:23:12 -0700 Subject: [PATCH 14/17] fix(ingestion): merge task usage field-wise instead of wholesale replace A payload carrying only totalTokens (e.g. terminal task_notification) no longer wipes previously known input/output/toolUses breakdowns. Co-Authored-By: Claude Fable 5 --- .../src/orchestration/Layers/ProviderRuntimeIngestion.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a62ffb66d4b..aa4d62f8283 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -186,7 +186,11 @@ export function foldTaskAgentEvent( ...((lastToolName ?? previous?.lastToolName) ? { lastToolName: lastToolName ?? previous?.lastToolName } : {}), - ...((nextUsage ?? previous?.usage) ? { usage: nextUsage ?? previous?.usage } : {}), + // Merge field-wise: a payload carrying only totalTokens (e.g. a terminal + // Claude task_notification) must not wipe previously known breakdowns. + ...(nextUsage || previous?.usage + ? { usage: { ...previous?.usage, ...nextUsage } as ThreadAgentUsage } + : {}), firstStartedAt: previous?.firstStartedAt ?? event.createdAt, lastActivityAt: event.createdAt, ...(!reactivated && (explicitEndTime ?? (terminal ? event.createdAt : previous?.endedAt)) From 61d0c3833729f6ed762db640eb0d3c8ae60ec25d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 18:50:24 -0700 Subject: [PATCH 15/17] fix(claude): consume undeclared background_tasks_changed silently The SDK stream emits this roster snapshot but its subtype is absent from the typed union, so it fell through to the unknown-subtype warning path and rendered as a spurious error row ('no displayable text content') in client work logs. The task_* lifecycle events carry the authoritative per-agent data; the typed background_tasks control request remains the reconciliation source. Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index bc6aa5886fe..b7998fda7d6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1823,6 +1823,24 @@ describe("ClaudeAdapterLive", () => { assert.equal(statusPatch.payload.isBackgrounded, true); assert.equal(statusPatch.payload.errorMessage, "waiting"); } + + // The undeclared background_tasks_changed roster snapshot is consumed + // silently — it must not surface as an unknown-subtype warning row. + harness.query.emit({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "workflow-1", task_type: "local_workflow", description: "Run checks" }], + session_id: "session", + uuid: "roster", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + const rosterWarnings = runtimeEvents.filter( + (event) => + event.type === "runtime.warning" && + typeof event.payload.message === "string" && + event.payload.message.includes("background_tasks_changed"), + ); + assert.equal(rosterWarnings.length, 0); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), From e9a498e28d0c30b40ed810e38ad94303bab56b4c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 20:01:20 -0700 Subject: [PATCH 16/17] chore: drop test fragment superseded by dedicated coverage test from #4244 Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index b7998fda7d6..bc6aa5886fe 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1823,24 +1823,6 @@ describe("ClaudeAdapterLive", () => { assert.equal(statusPatch.payload.isBackgrounded, true); assert.equal(statusPatch.payload.errorMessage, "waiting"); } - - // The undeclared background_tasks_changed roster snapshot is consumed - // silently — it must not surface as an unknown-subtype warning row. - harness.query.emit({ - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "workflow-1", task_type: "local_workflow", description: "Run checks" }], - session_id: "session", - uuid: "roster", - } as unknown as SDKMessage); - yield* Effect.yieldNow; - const rosterWarnings = runtimeEvents.filter( - (event) => - event.type === "runtime.warning" && - typeof event.payload.message === "string" && - event.payload.message.includes("background_tasks_changed"), - ); - assert.equal(rosterWarnings.length, 0); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), From a40376b28f77240ce2c9bedee1d82fb3573950e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 20:54:59 -0700 Subject: [PATCH 17/17] fix: dual-review pass (Fable + gpt-5.6-sol high) on rebased branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot rounds (Macroscope/Bugbot): - taskKind: explicit shell/monitor types win over parentTaskId fallback - lastStartedAt per activation; elapsed timers exclude idle gaps - client revision selection uses (revision ?? -1) matching server hydration - orphan-sweep append retries once on session.exited (often the last event) - live-strip wrapper keyed off hasLiveAgents (no phantom spacer) gpt-5.6-sol (high): - thread.reverted now invalidates the cached roster (no resurrecting reverted agents); new domain-event subscription - endedAt: explicit > existing > first-terminal ingestion time - roster strings bounded at 180 chars (recentActivity + currentActivity) - task_updated.patch.description maps to name - empty failed workflow renders its container card Fable: - deriveLatestAgentSnapshot picks the winner by peeking revision, decodes only that roster (was: full schema-decode of every roster per recompute) - idle Codex children get their own retention cap (25) — they never reach terminal status mid-session, so they were unbounded - v1 collab receivers re-checked inside the v2 diversion branch (arrival- order race); task usage schema committed to typed RuntimeTaskUsage; Claude usage carries input/output/cache breakdowns; assorted nits Co-Authored-By: Claude Fable 5 --- .../Layers/ProviderRuntimeIngestion.ts | 116 +++++++++++++----- .../src/provider/Layers/ClaudeAdapter.ts | 10 +- .../provider/Layers/CodexSessionRuntime.ts | 11 +- apps/web/src/components/AgentsPanel.tsx | 22 +++- apps/web/src/components/ChatView.tsx | 12 +- .../src/components/chat/AgentsLiveStrip.tsx | 2 + .../client-runtime/src/state/threadAgents.ts | 66 +++++----- packages/contracts/src/providerRuntime.ts | 4 +- packages/contracts/src/threadAgents.ts | 8 +- 9 files changed, 176 insertions(+), 75 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index aa4d62f8283..6c4b81fcffb 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -49,6 +49,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; const AGENT_SETTLED_RETENTION_LIMIT = 50; +const AGENT_IDLE_RETENTION_LIMIT = 25; const AGENT_USAGE_MATERIAL_STEP = 25_000; // Fields must satisfy the contract's NonNegativeInt or the persisted roster @@ -91,9 +92,12 @@ function taskStatus(event: ProviderRuntimeEvent): ThreadAgentStatus | undefined function taskKind(taskType: string | undefined, parentTaskId: string | undefined) { if (taskType === "local_agent") return "subagent" as const; if (taskType === "local_workflow") return "workflow" as const; - if (taskType === "workflow_agent" || parentTaskId) return "workflow_agent" as const; + if (taskType === "workflow_agent") return "workflow_agent" as const; if (taskType === "local_bash" || taskType === "shell") return "shell" as const; if (taskType === "monitor") return "monitor" as const; + // Parent linkage is only a workflow-membership hint when the type itself is + // unknown — a nested shell/monitor task keeps its explicit kind above. + if (parentTaskId) return "workflow_agent" as const; return "other" as const; } @@ -127,6 +131,13 @@ function workflowPhasesEqual( ); } +const AGENT_TEXT_LIMIT = 180; + +/** Roster snapshots duplicate these strings on every append — keep them small. */ +function boundAgentText(value: string): string { + return value.length > AGENT_TEXT_LIMIT ? `${value.slice(0, AGENT_TEXT_LIMIT - 1)}…` : value; +} + export function foldTaskAgentEvent( agents: Map, event: Extract< @@ -144,16 +155,23 @@ export function foldTaskAgentEvent( const settledNow = status === "idle" || THREAD_AGENT_TERMINAL_STATUSES.has(status); // A settled agent's card shows its result/error, not a stale in-flight // activity line ("Reading files…" on a failed card). - const currentActivity = settledNow + const rawCurrentActivity = settledNow ? undefined : (summary ?? lastToolName ?? previous?.currentActivity); + const currentActivity = + rawCurrentActivity === undefined ? undefined : boundAgentText(rawCurrentActivity); const activityChanged = (summary !== undefined && summary !== previous?.currentActivity) || (lastToolName !== undefined && lastToolName !== previous?.lastToolName); const recentActivity = activityChanged ? [ ...(previous?.recentActivity ?? []), - { at: event.createdAt, summary: summary ?? lastToolName ?? "Activity updated" }, + { + at: event.createdAt, + // Bounded: Codex child item summaries can carry full command/item + // text, and each entry is duplicated into every roster snapshot. + summary: boundAgentText(summary ?? lastToolName ?? "Activity updated"), + }, ].slice(-THREAD_AGENT_RECENT_ACTIVITY_LIMIT) : (previous?.recentActivity ?? []); const wasSettled = @@ -192,9 +210,19 @@ export function foldTaskAgentEvent( ? { usage: { ...previous?.usage, ...nextUsage } as ThreadAgentUsage } : {}), firstStartedAt: previous?.firstStartedAt ?? event.createdAt, + // Reset per activation so live timers exclude idle gaps between runs. + lastStartedAt: reactivated + ? event.createdAt + : (previous?.lastStartedAt ?? previous?.firstStartedAt ?? event.createdAt), lastActivityAt: event.createdAt, - ...(!reactivated && (explicitEndTime ?? (terminal ? event.createdAt : previous?.endedAt)) - ? { endedAt: explicitEndTime ?? (terminal ? event.createdAt : previous?.endedAt) } + // Prefer the provider's explicit end time, then an already-recorded one; + // only stamp ingestion time on the FIRST terminal transition so duplicate + // terminal events don't keep sliding the end forward. + ...(!reactivated && + (explicitEndTime ?? previous?.endedAt ?? (terminal ? event.createdAt : undefined)) + ? { + endedAt: explicitEndTime ?? previous?.endedAt ?? (terminal ? event.createdAt : undefined), + } : {}), activationCount: previous ? previous.activationCount + (reactivated ? 1 : 0) : 1, ...((event.turnId ?? previous?.lastTurnId) @@ -259,9 +287,7 @@ export function foldTaskAgentEvent( } export function pruneSettledAgents(agents: Map): void { - // Idle agents are resumable identities, not history — only terminal agents - // compete for the retention pool, so re-activating an old idle Codex child - // never loses its usage/activation record to pruning. + // Terminal agents are history and compete for the standard retention pool. const settled = Array.from(agents.values()) .filter((agent) => THREAD_AGENT_TERMINAL_STATUSES.has(agent.status)) .sort((left, right) => left.lastActivityAt.localeCompare(right.lastActivityAt)); @@ -271,6 +297,19 @@ export function pruneSettledAgents(agents: Map): vo )) { agents.delete(agent.agentId); } + // Idle agents are resumable identities, so they get their own (generous) + // cap rather than sharing the terminal pool — but Codex children end every + // run idle and never terminal until close/interrupt, so without a bound a + // long session accumulates them all and every material append persists the + // full roster. Evicting oldest-idle is safe: a resumed identity + // re-registers from its next event (the activation counter tolerates the + // restart). + const idle = Array.from(agents.values()) + .filter((agent) => agent.status === "idle") + .sort((left, right) => left.lastActivityAt.localeCompare(right.lastActivityAt)); + for (const agent of idle.slice(0, Math.max(0, idle.length - AGENT_IDLE_RETENTION_LIMIT))) { + agents.delete(agent.agentId); + } } // Fallback when the in-memory description cache no longer has the task name @@ -325,9 +364,9 @@ const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; -type TurnStartRequestedDomainEvent = Extract< +type IngestionDomainEvent = Extract< OrchestrationEvent, - { type: "thread.turn-start-requested" } + { type: "thread.turn-start-requested" | "thread.reverted" } >; type RuntimeIngestionInput = @@ -337,7 +376,7 @@ type RuntimeIngestionInput = } | { source: "domain"; - event: TurnStartRequestedDomainEvent; + event: IngestionDomainEvent; }; function toTurnId(value: TurnId | string | undefined): TurnId | undefined { @@ -1565,13 +1604,9 @@ const make = Effect.gen(function* () { // message deltas after a restart must not each trigger a full // thread-detail load through the serialized ingestion worker. const eventTouchesAgents = isTaskAgentEvent(event) || event.type === "session.exited"; - const unhydratedActivityPressure = - !hydratedAgentThreads.has(thread.id) && + const activityPressure = (activitiesSinceAgentSnapshot.get(thread.id) ?? 0) >= AGENT_SNAPSHOT_REFRESH_ACTIVITY_COUNT; - if ( - (eventTouchesAgents || unhydratedActivityPressure) && - !hydratedAgentThreads.has(thread.id) - ) { + if ((eventTouchesAgents || activityPressure) && !hydratedAgentThreads.has(thread.id)) { const detail = yield* getLoadedThreadDetail(); const activityList = detail?.activities ?? []; // Select the snapshot the same way the client does — highest revision @@ -1686,7 +1721,7 @@ const make = Effect.gen(function* () { // never block the rest of this event's ingestion (message deltas, // session status, timeline activities). Interrupts still propagate. pendingRosterRedispatch.add(thread.id); - yield* orchestrationEngine + const appendSnapshot = orchestrationEngine .dispatch({ type: "thread.activity.append", commandId: yield* providerCommandId(event, "agent-snapshot-append"), @@ -1708,16 +1743,24 @@ const make = Effect.gen(function* () { }); }), ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider agent snapshot append failed; will re-dispatch", { - threadId: thread.id, - agentCount: roster.length, - cause: Cause.pretty(cause), - }), - ), ); + // session.exited is often the thread's LAST event — the usual + // "re-dispatch on the next event" recovery never fires, so retry the + // final orphan-sweep roster once before giving up on it. + yield* appendSnapshot.pipe( + event.type === "session.exited" + ? Effect.retry({ times: 1 }) + : (effect: typeof appendSnapshot) => effect, + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("provider agent snapshot append failed; will re-dispatch", { + threadId: thread.id, + agentCount: roster.length, + cause: Cause.pretty(cause), + }), + ), + ); } // Sessions are done producing agent events once they exit; release the @@ -2212,7 +2255,22 @@ const make = Effect.gen(function* () { ).pipe(Effect.asVoid); }); - const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; + const processDomainEvent = (event: IngestionDomainEvent) => + Effect.sync(() => { + if (event.type !== "thread.reverted") { + return; + } + // A checkpoint revert pruned reverted-turn activities (including agent + // snapshots) from the projection. Drop the cached roster so the next + // agent event rehydrates from post-revert state instead of folding a + // stale map and resurrecting reverted agents. + const threadId = event.payload.threadId; + agentsByThread.delete(threadId); + hydratedAgentThreads.delete(threadId); + activitiesSinceAgentSnapshot.delete(threadId); + agentRosterRevision.delete(threadId); + pendingRosterRedispatch.delete(threadId); + }); const processInput = (input: RuntimeIngestionInput) => input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); @@ -2243,7 +2301,7 @@ const make = Effect.gen(function* () { ); yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.turn-start-requested") { + if (event.type !== "thread.turn-start-requested" && event.type !== "thread.reverted") { return Effect.void; } return worker.enqueue({ source: "domain", event }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 0fb9f372ad8..a40d8e2e9c0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -723,8 +723,14 @@ function normalizeRuntimeTaskUsage(value: unknown) { } const toolUses = readNonNegativeInteger(usage.tool_uses); const durationMs = readNonNegativeInteger(usage.duration_ms); + const inputTokens = readNonNegativeInteger(usage.input_tokens); + const outputTokens = readNonNegativeInteger(usage.output_tokens); + const cachedInputTokens = readNonNegativeInteger(usage.cache_read_input_tokens); return { totalTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), ...(toolUses !== undefined ? { toolUses } : {}), ...(durationMs !== undefined ? { durationMs } : {}), }; @@ -2895,12 +2901,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } case "task_updated": { const record = message as unknown as Record; - // SDK shape: {task_id, patch: {status?, end_time?, is_backgrounded?, error?}} + // SDK shape: {task_id, patch: {status?, description?, end_time?, is_backgrounded?, error?}} const patch = record.patch !== null && typeof record.patch === "object" && !Array.isArray(record.patch) ? (record.patch as Record) : {}; const status = readString(patch.status); + const patchedDescription = readString(patch.description); const endTime = typeof patch.end_time === "number" ? epochMillisToIso(patch.end_time) : undefined; yield* offerRuntimeEvent({ @@ -2917,6 +2924,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? { status } : {}), ...(endTime ? { endTime } : {}), + ...(patchedDescription ? { name: patchedDescription } : {}), ...(typeof patch.is_backgrounded === "boolean" ? { isBackgrounded: patch.is_backgrounded } : {}), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index ab21b3eaa1c..c66e1321226 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -899,9 +899,14 @@ export const makeCodexSessionRuntime = ( // the session's own provider thread nor a v1 collab receiver is // treated as an unregistered child. const providerThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - const knownChild = notificationThreadId - ? collabChildAgents.get(notificationThreadId) - : undefined; + // Re-check v1 receiver registration on every notification: if a + // thread was provisionally classified as a v2 child before the + // parent's collabAgentToolCall arrived, hand it back to the v1 + // suppression path once the receiver registration lands. + const knownChild = + notificationThreadId && !collabReceiverTurns.has(notificationThreadId) + ? collabChildAgents.get(notificationThreadId) + : undefined; const isForeignThread = notificationThreadId !== undefined && providerThreadId !== undefined && diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 0a8ad6234d0..7b6752d40bd 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -58,7 +58,10 @@ function AgentStatusDot({ status }: { status: ThreadAgentSnapshot["status"] }) { function AgentElapsed({ agent }: { agent: ThreadAgentSnapshot }) { const textRef = useRef(null); const settled = isTerminalAgentStatus(agent.status) || agent.status === "idle"; - const startMs = parseTimestampDate(agent.firstStartedAt)?.getTime() ?? null; + // Current-activation start (falls back to firstStartedAt for pre-field + // snapshots) so a resumed agent's timer excludes prior runs and idle gaps. + const startMs = + parseTimestampDate(agent.lastStartedAt ?? agent.firstStartedAt)?.getTime() ?? null; const endMs = (agent.endedAt ? parseTimestampDate(agent.endedAt)?.getTime() : null) ?? (settled ? (parseTimestampDate(agent.lastActivityAt)?.getTime() ?? null) : null); @@ -93,7 +96,7 @@ function AgentCard({ agent }: { agent: ThreadAgentSnapshot }) { // Settled cards lead with outcome (error first); live cards with activity. const activity = agent.status === "waiting" - ? "Waiting on approval" + ? "Waiting on approval or input" : settled || agent.status === "idle" ? (agent.errorMessage ?? agent.resultSummary ?? @@ -119,7 +122,7 @@ function AgentCard({ agent }: { agent: ThreadAgentSnapshot }) { {agent.name} {agent.agentType ? ( - + {agent.agentType} ) : null} @@ -262,6 +265,19 @@ function AgentGroup({ )}
+ {/* A failed/errored workflow with no member rows would otherwise render + as a bare header — surface the container itself so its status and + error are visible. */} + {group.workflow && + group.rest.length === 0 && + group.phases.every((phase) => phase.agents.length === 0) && + (group.workflow.status === "failed" || + group.workflow.status === "stopped" || + group.workflow.errorMessage) ? ( +
+ +
+ ) : null} {group.phases.map((phase) => (
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8648ad7fe74..aabf86521a1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1869,6 +1869,16 @@ function ChatViewContent(props: ChatViewProps) { () => deriveLatestAgentSnapshot(threadActivities), [threadActivities], ); + // Mirrors AgentsLiveStrip's own visibility rule so the wrapper doesn't + // reserve space (mb-1.5) after all agents settle. + const hasLiveAgents = useMemo( + () => + threadAgents.some( + (agent) => + agent.status === "running" || agent.status === "pending" || agent.status === "waiting", + ), + [threadAgents], + ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -5380,7 +5390,7 @@ function ChatViewContent(props: ChatViewProps) { ) : ( )} - {threadAgents.length > 0 ? ( + {hasLiveAgents ? (
diff --git a/apps/web/src/components/chat/AgentsLiveStrip.tsx b/apps/web/src/components/chat/AgentsLiveStrip.tsx index f85f8d90512..81aee88627f 100644 --- a/apps/web/src/components/chat/AgentsLiveStrip.tsx +++ b/apps/web/src/components/chat/AgentsLiveStrip.tsx @@ -21,6 +21,8 @@ const AgentsLiveStrip = memo(function AgentsLiveStrip({ const state = deriveAgentPanelState(agents); const liveCount = state.runningCount + state.waitingCount; if (liveCount === 0) { + // Parent wrappers must not reserve space when nothing renders (the + // caller keys visibility off hasLiveAgents with the same derivation). return null; } diff --git a/packages/client-runtime/src/state/threadAgents.ts b/packages/client-runtime/src/state/threadAgents.ts index fbe114ab5b6..0bdf496bc0f 100644 --- a/packages/client-runtime/src/state/threadAgents.ts +++ b/packages/client-runtime/src/state/threadAgents.ts @@ -3,8 +3,8 @@ * * The server ships the full per-thread roster latest-wins in the payload of * `agent.snapshot` activities (see `@t3tools/contracts` ThreadAgentsActivityPayload). - * Mirrors the `context-window.updated` pattern: scan activities newest-first, - * decode tolerantly, ignore rows that fail to decode. + * Mirrors the `context-window.updated` pattern: select the newest roster + * (highest revision), decode tolerantly, skip rows that fail to decode. */ import { THREAD_AGENT_TERMINAL_STATUSES, @@ -16,19 +16,12 @@ import * as Schema from "effect/Schema"; const decodeAgent = Schema.decodeUnknownOption(ThreadAgentSnapshot); -interface DecodedRoster { - readonly agents: ReadonlyArray; - readonly revision: number | undefined; +interface RosterCandidate { + readonly payload: { readonly agents: ReadonlyArray }; + readonly revision: number; } -/** - * Rows decode per-element: one malformed or forward-incompatible agent entry - * is skipped without discarding the rest of the roster. Any payload with an - * `agents` array is authoritative — a roster whose rows all fail to decode - * yields an empty panel rather than resurrecting an older (possibly still - * "running") snapshot. - */ -function decodeRoster(payload: unknown): DecodedRoster | undefined { +function peekRoster(payload: unknown): RosterCandidate | undefined { if (payload === null || typeof payload !== "object") { return undefined; } @@ -36,44 +29,49 @@ function decodeRoster(payload: unknown): DecodedRoster | undefined { if (!Array.isArray(record.agents)) { return undefined; } - const decoded: ThreadAgentSnapshot[] = []; - for (const candidate of record.agents) { - const result = decodeAgent(candidate); - if (result._tag === "Some") { - decoded.push(result.value); - } - } return { - agents: decoded, + payload: record as RosterCandidate["payload"], + // Missing revision ranks lowest (-1), mirroring server hydration: a + // revision-less legacy roster never beats a revisioned one, and among + // equals the later list position wins. revision: typeof record.revision === "number" && Number.isInteger(record.revision) ? record.revision - : undefined, + : -1, }; } export function deriveLatestAgentSnapshot( activities: ReadonlyArray, ): ReadonlyArray { - // Highest revision wins; list order breaks ties (and covers revision-less - // rosters from before the field existed). Guards against same-millisecond - // appends landing out of order in the capped projection. - let best: DecodedRoster | undefined; + // Two passes so only ONE roster is schema-decoded per recompute: rosters + // can dominate the 500-row activity window in busy sessions, and this runs + // inside a useMemo that re-fires on every activities change. Winner = + // highest revision, later list position breaking ties. + let best: RosterCandidate | undefined; for (const activity of activities) { if (activity.kind !== THREAD_AGENTS_ACTIVITY_KIND) { continue; } - const roster = decodeRoster(activity.payload); - if (!roster) { - continue; + const candidate = peekRoster(activity.payload); + if (candidate && (!best || candidate.revision >= best.revision)) { + best = candidate; } - if (!best || roster.revision === undefined || best.revision === undefined) { - best = roster; - } else if (roster.revision >= best.revision) { - best = roster; + } + if (!best) { + return []; + } + // The winning roster is authoritative: rows decode per-element (one bad row + // is skipped, the rest kept), and a fully undecodable roster yields an + // empty panel rather than resurrecting an older snapshot. + const decoded: ThreadAgentSnapshot[] = []; + for (const candidate of best.payload.agents) { + const result = decodeAgent(candidate); + if (result._tag === "Some") { + decoded.push(result.value); } } - return best?.agents ?? []; + return decoded; } export function isTerminalAgentStatus(status: ThreadAgentSnapshot["status"]): boolean { diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 2b86b18a93d..99ca1050f43 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -518,7 +518,7 @@ const TaskProgressPayload = Schema.Struct({ taskId: RuntimeTaskId, description: TrimmedNonEmptyStringSchema, summary: Schema.optional(TrimmedNonEmptyStringSchema), - usage: Schema.optional(Schema.Union([RuntimeTaskUsage, Schema.Unknown])), + usage: Schema.optional(RuntimeTaskUsage), lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), ...TaskAgentLinkage, }); @@ -555,7 +555,7 @@ const TaskCompletedPayload = Schema.Struct({ taskId: RuntimeTaskId, status: Schema.Literals(["completed", "failed", "stopped"]), summary: Schema.optional(TrimmedNonEmptyStringSchema), - usage: Schema.optional(Schema.Union([RuntimeTaskUsage, Schema.Unknown])), + usage: Schema.optional(RuntimeTaskUsage), ...TaskAgentLinkage, }); export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; diff --git a/packages/contracts/src/threadAgents.ts b/packages/contracts/src/threadAgents.ts index 3a50a568317..02d5021ac0d 100644 --- a/packages/contracts/src/threadAgents.ts +++ b/packages/contracts/src/threadAgents.ts @@ -96,6 +96,10 @@ export const ThreadAgentSnapshot = Schema.Struct({ lastToolName: Schema.optional(TrimmedNonEmptyString), usage: Schema.optional(ThreadAgentUsage), firstStartedAt: IsoDateTime, + // Start of the CURRENT activation. Live elapsed timers derive from this so + // idle gaps between resumptions don't inflate the display; absent on + // pre-field snapshots (clients fall back to firstStartedAt). + lastStartedAt: Schema.optional(IsoDateTime), lastActivityAt: IsoDateTime, // Cleared when a re-activation (Codex follow-up) starts a new run. endedAt: Schema.optional(IsoDateTime), @@ -112,9 +116,9 @@ export const ThreadAgentSnapshot = Schema.Struct({ phases: Schema.optional(Schema.Array(ThreadAgentWorkflowPhase)), scriptPath: Schema.optional(TrimmedNonEmptyString), runId: Schema.optional(TrimmedNonEmptyString), - // Set while waiting on an approval so the UI can deep-link to the request. + // Reserved for the approval deep-link milestone; no emitter populates it yet. approvalRequestId: Schema.optional(ApprovalRequestId), - // Claude transcript/output path; the drill-in RPC reads it on demand. + // Claude transcript/output path; reserved for the transcript drill-in milestone. outputFile: Schema.optional(TrimmedNonEmptyString), resultSummary: Schema.optional(TrimmedNonEmptyString), errorMessage: Schema.optional(TrimmedNonEmptyString),