From 4464d8c58b4f980eee7834155849fa2f652c4768 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:33:18 +0530 Subject: [PATCH 1/7] feat(orchestration-v2): add subagent observability --- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 38 +- .../Adapters/ClaudeAdapterV2.test.ts | 292 ++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 328 ++++++++++++++++-- .../Adapters/CodexAdapterV2.test.ts | 90 ++++- .../Adapters/CodexAdapterV2.ts | 212 ++++++++++- .../Adapters/CursorAdapterV2.ts | 29 ++ .../Adapters/OpenCodeAdapterV2.ts | 36 ++ .../FoundationPersistence.test.ts | 80 +++++ .../src/orchestration-v2/Orchestrator.ts | 90 ++++- .../orchestration-v2/ProjectionMaintenance.ts | 1 + .../src/orchestration-v2/ProjectionStore.ts | 71 +++- .../src/orchestration-v2/ProviderAdapter.ts | 6 + .../orchestration-v2/ProviderEventIngestor.ts | 10 + .../ProviderRuntimeRecoveryService.test.ts | 3 + .../ProviderTurnControlService.test.ts | 1 + .../RunExecutionService.test.ts | 69 +++- .../orchestration-v2/RunExecutionService.ts | 58 ++++ .../SubagentObservability.test.ts | 109 ++++++ .../orchestration-v2/SubagentObservability.ts | 163 +++++++++ .../ThreadForkService.test.ts | 1 + apps/server/src/persistence/Migrations.ts | 2 + .../035_036_OrchestrationV2.test.ts | 7 +- ...chestrationV2SubagentObservability.test.ts | 151 ++++++++ ...43_OrchestrationV2SubagentObservability.ts | 50 +++ apps/web/src/components/AgentsPanelV2.tsx | 247 +++++++++++++ apps/web/src/components/ChatView.tsx | 13 + apps/web/src/components/RightPanelTabs.tsx | 30 +- .../src/components/chat/V2LifecycleRow.tsx | 2 + apps/web/src/rightPanelStore.ts | 13 +- apps/web/src/session-logic.ts | 1 + apps/web/src/test-fixtures.ts | 1 + packages/client-runtime/package.json | 4 + .../state/orchestrationV2Projection.test.ts | 1 + .../src/state/orchestrationV2Projection.ts | 5 + .../state/orchestrationV2Subagents.test.ts | 146 ++++++++ .../src/state/orchestrationV2Subagents.ts | 127 +++++++ .../src/state/orchestrationV2TestFixtures.ts | 1 + packages/contracts/src/baseSchemas.ts | 2 + .../contracts/src/orchestrationV2.test.ts | 99 +++++- packages/contracts/src/orchestrationV2.ts | 117 +++++++ 40 files changed, 2656 insertions(+), 50 deletions(-) create mode 100644 apps/server/src/orchestration-v2/SubagentObservability.test.ts create mode 100644 apps/server/src/orchestration-v2/SubagentObservability.ts create mode 100644 apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.test.ts create mode 100644 apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.ts create mode 100644 apps/web/src/components/AgentsPanelV2.tsx create mode 100644 packages/client-runtime/src/state/orchestrationV2Subagents.test.ts create mode 100644 packages/client-runtime/src/state/orchestrationV2Subagents.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index da059b3862f..fc43d80e1e0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -12,6 +12,7 @@ import { type OrchestrationV2ProviderTurn, type OrchestrationV2RuntimeRequest, type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, type ProviderApprovalDecision, @@ -66,6 +67,7 @@ import { makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; +import { defaultSubagentRole, subagentActivationId } from "../SubagentObservability.ts"; import { ProviderAdapterEnsureThreadError, ProviderAdapterForkThreadError, @@ -1926,6 +1928,12 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV const turnItemOrdinal = existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId)); const taskStatus = update.status; + const activationId = subagentActivationId(nodeId, 1); + const settled = + taskStatus === "completed" || + taskStatus === "failed" || + taskStatus === "cancelled" || + taskStatus === "interrupted"; const task: OrchestrationV2Subagent = { ...(existing?.task ?? { id: nodeId, @@ -1942,12 +1950,21 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV prompt: update.prompt, title: update.title, model: update.model, + kind: "subagent" as const, + role: defaultSubagentRole(), result: null, + usage: null, + currentActivationId: activationId, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], startedAt: now, }), status: taskStatus, result: existing?.assistantText || update.result, - completedAt: taskStatus === "running" ? null : now, + currentActivationId: settled ? null : activationId, + completedAt: settled ? (existing?.task.completedAt ?? now) : null, updatedAt: now, }; const subagent: ActiveAcpSubagent = existing ?? { @@ -2065,7 +2082,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV ...subagent.task, status: taskStatus, result, - completedAt: taskStatus === "running" ? null : now, + completedAt: settled ? (subagent.task.completedAt ?? now) : null, updatedAt: now, }; const providerThreadId = subagent.task.providerThreadId; @@ -2112,6 +2129,23 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV }, }); yield* emitProviderEvent({ type: "subagent.updated", driver, subagent: subagent.task }); + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver, + activation: { + id: activationId, + threadId: subagent.task.threadId, + subagentId: subagent.task.id, + runId: subagent.task.runId, + providerTurnId: subagent.providerTurnId, + ordinal: 1, + status: taskStatus, + usage: null, + startedAt: subagent.task.startedAt, + completedAt: settled ? now : null, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation, + }); yield* emitProviderEvent({ type: "turn_item.updated", driver, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8e22379ae8d..3393081cb7e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1290,6 +1290,268 @@ describe("ClaudeAdapterV2 background wake turns", () => { }; }); + it.effect("projects Claude workflow phases and workers as structured subagents", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const workflowTaskId = "task-workflow-observability"; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-observability"), + text: "Run a two-phase workflow.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: workflowTaskId, + tool_use_id: "toolu-workflow-observability", + description: "Research and implement", + task_type: "local_workflow", + prompt: "Research, then implement.", + uuid: "00000000-0000-4000-8000-000000000121", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: workflowTaskId, + tool_use_id: "toolu-workflow-observability", + description: "Implementation started", + usage: { + input_tokens: 300, + output_tokens: 200, + total_tokens: 500, + tool_uses: 4, + }, + workflow_progress: [ + { type: "workflow_phase", index: 0, title: "Research" }, + { type: "workflow_phase", index: 1, title: "Implement" }, + { + type: "workflow_agent", + index: 0, + label: "Researcher", + state: "done", + phaseIndex: 0, + attempt: 0, + model: "claude-sonnet-4-6", + lastToolName: "Read", + tokens: 200, + toolCalls: 2, + }, + { + type: "workflow_agent", + index: 1, + label: "Implementer", + state: "active", + phase_index: 1, + attempt: 0, + model: "claude-opus-4-1", + last_tool_name: "Edit", + tokens: 300, + tool_calls: 2, + }, + ], + uuid: "00000000-0000-4000-8000-000000000122", + session_id: WAKE_NATIVE_SESSION, + }), + ); + + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + yield* awaitUntil( + () => + new Set( + subagentEvents() + .filter((event) => event.subagent.kind === "workflow_agent") + .map((event) => event.subagent.id), + ).size === 2, + "workflow workers projected", + ); + + const latestById = new Map( + subagentEvents().map((event) => [event.subagent.id, event.subagent]), + ); + const workflow = [...latestById.values()].find((agent) => agent.kind === "workflow"); + const workers = [...latestById.values()] + .filter((agent) => agent.kind === "workflow_agent") + .sort( + (left, right) => + (left.workflowMembership?.agentIndex ?? 0) - + (right.workflowMembership?.agentIndex ?? 0), + ); + assert.deepEqual(workflow?.role, { + name: "workflow-coordinator", + source: "app_default", + }); + assert.deepEqual(workflow?.workflow?.phases, [ + { index: 0, title: "Research" }, + { index: 1, title: "Implement" }, + ]); + assert.equal(workflow?.usage?.totalTokens, 500); + assert.deepEqual( + workers.map((worker) => worker.role), + [ + { name: "workflow-worker", source: "app_default" }, + { name: "workflow-worker", source: "app_default" }, + ], + ); + assert.deepEqual( + workers.map((worker) => [ + worker.status, + worker.workflowMembership?.phaseIndex, + worker.workflowMembership?.attempt, + worker.usage?.totalTokens, + ]), + [ + ["completed", 0, 1, 200], + ["running", 1, 1, 300], + ], + ); + + const workerUpdateCountBeforeStaleActive = subagentEvents().filter( + (event) => event.subagent.kind === "workflow_agent", + ).length; + const eventCountBeforeStaleActive = subagentEvents().length; + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: workflowTaskId, + tool_use_id: "toolu-workflow-observability", + description: "Provider replayed the completed attempt as active", + workflow_progress: [ + { + type: "workflow_agent", + index: 0, + label: "Researcher", + state: "active", + phaseIndex: 0, + attempt: 0, + tokens: 200, + }, + ], + uuid: "00000000-0000-4000-8000-000000000126", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => subagentEvents().length > eventCountBeforeStaleActive, + "stale workflow attempt inspected", + ); + assert.equal( + subagentEvents().filter((event) => event.subagent.kind === "workflow_agent").length, + workerUpdateCountBeforeStaleActive, + ); + + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: workflowTaskId, + tool_use_id: "toolu-workflow-observability", + description: "Research retry queued", + workflow_progress: [ + { type: "workflow_phase", index: 0, title: "Research" }, + { type: "workflow_phase", index: 1, title: "Implement" }, + { + type: "workflow_agent", + index: 0, + label: "Researcher", + state: "start", + phaseIndex: 0, + attempt: 1, + model: "claude-sonnet-4-6", + tokens: 50, + toolCalls: 1, + }, + ], + uuid: "00000000-0000-4000-8000-000000000123", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + subagentEvents().at(-1)?.subagent.kind === "workflow_agent" && + subagentEvents().at(-1)?.subagent.status === "pending", + "workflow retry projected", + ); + const retried = subagentEvents().at(-1)?.subagent; + assert.equal(retried?.activationCount, 2); + assert.equal(retried?.workflowMembership?.attempt, 2); + assert.equal(retried?.usage?.totalTokens, 250); + assert.lengthOf( + new Set( + harness.events + .filter( + ( + event, + ): event is Extract< + ProviderAdapterV2Event, + { type: "subagent_activation.updated" } + > => event.type === "subagent_activation.updated", + ) + .filter((event) => event.activation.subagentId === retried?.id) + .map((event) => event.activation.id), + ), + 2, + ); + + const workerUpdateCount = subagentEvents().filter( + (event) => event.subagent.kind === "workflow_agent", + ).length; + const eventCount = subagentEvents().length; + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: workflowTaskId, + tool_use_id: "toolu-workflow-observability", + description: "Provider sent a future workflow state", + workflow_progress: [ + { + type: "workflow_agent", + index: 0, + label: "Researcher", + state: "future_state", + phaseIndex: 0, + attempt: 2, + tokens: 100, + }, + ], + uuid: "00000000-0000-4000-8000-000000000124", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => subagentEvents().length > eventCount, + "unknown workflow state inspected", + ); + assert.equal( + subagentEvents().filter((event) => event.subagent.kind === "workflow_agent").length, + workerUpdateCount, + ); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("buffers wake output and requests a single continuation run", () => Effect.scoped( Effect.gen(function* () { @@ -1959,6 +2221,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { status: "completed", output_file: "/tmp/task-resume-subagent.output", summary: FIRST_SUMMARY, + usage: { + input_tokens: 70, + output_tokens: 30, + total_tokens: 100, + tool_uses: 2, + }, uuid: "00000000-0000-4000-8000-000000000402", session_id: WAKE_NATIVE_SESSION, }); @@ -1985,6 +2253,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { status: "completed", output_file: "/tmp/task-resume-subagent.output", summary: SECOND_SUMMARY, + usage: { + input_tokens: 50, + output_tokens: 30, + total_tokens: 80, + tool_uses: 1, + }, uuid: "00000000-0000-4000-8000-000000000407", session_id: WAKE_NATIVE_SESSION, }); @@ -1996,6 +2270,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event): event is Extract => event.type === "subagent.updated", ); + const activationEvents = () => + harness.events.filter( + ( + event, + ): event is Extract => + event.type === "subagent_activation.updated", + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -2047,6 +2328,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); + assert.deepEqual(subagentEvents().at(-1)?.subagent.role, { + name: "general-purpose", + source: "provider", + }); + assert.equal(subagentEvents().at(-1)?.subagent.usage?.totalTokens, 100); assert.isFalse(yield* harness.hasPendingBackgroundWork); // A user turn nudges the completed subagent via SendMessage; the @@ -2172,6 +2458,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { const finalSubagent = subagentEvents().at(-1)?.subagent; assert.equal(finalSubagent?.status, "completed"); assert.equal(finalSubagent?.result, SECOND_SUMMARY); + assert.equal(finalSubagent?.activationCount, 2); + assert.equal(finalSubagent?.usage?.totalTokens, 180); + assert.equal(finalSubagent?.usage?.toolUses, 3); + assert.lengthOf(new Set(activationEvents().map((event) => event.activation.id)), 2); + assert.equal(activationEvents().at(-1)?.activation.ordinal, 2); + assert.equal(activationEvents().at(-1)?.activation.usage?.totalTokens, 80); // The completion keeps the resuming run's attribution. assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-8c"); assert.isFalse(yield* harness.hasPendingBackgroundWork); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c681d89ab7a..d16d8abb5a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -32,6 +32,8 @@ import { type OrchestrationV2ProviderTurn, type OrchestrationV2RuntimeRequest, type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, + type OrchestrationV2SubagentUsage, type OrchestrationV2TurnItem, type OrchestrationV2WebSearchResult, type ProviderApprovalDecision, @@ -111,6 +113,13 @@ import { makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; +import { + accumulateSubagentUsage, + appendSubagentActivity, + mergeSubagentUsage, + providerSubagentRole, + subagentActivationId, +} from "../SubagentObservability.ts"; export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; @@ -1401,6 +1410,99 @@ function claudeTaskTypeFromSdkMessage(message: SDKMessage): string | null { return typeof taskType === "string" ? taskType : null; } +const nonNegativeInteger = (value: unknown) => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.round(value) : undefined; + +const recordField = (value: unknown, key: string) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Reflect.get(value, key) + : undefined; + +const firstRecordField = (value: unknown, keys: ReadonlyArray) => { + for (const key of keys) { + const field = recordField(value, key); + if (field !== undefined) return field; + } + return undefined; +}; + +const nonNegativeRecordField = (value: unknown, keys: ReadonlyArray) => + nonNegativeInteger(firstRecordField(value, keys)); + +function claudeSubagentUsage(value: unknown): OrchestrationV2SubagentUsage | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const inputTokens = nonNegativeInteger(recordField(value, "input_tokens")); + const cacheCreationTokens = nonNegativeInteger(recordField(value, "cache_creation_input_tokens")); + const cachedInputTokens = nonNegativeInteger(recordField(value, "cache_read_input_tokens")); + const outputTokens = nonNegativeInteger(recordField(value, "output_tokens")); + const totalTokens = + nonNegativeInteger(recordField(value, "total_tokens")) ?? + nonNegativeInteger(recordField(value, "totalTokens")) ?? + (inputTokens ?? 0) + + (cacheCreationTokens ?? 0) + + (cachedInputTokens ?? 0) + + (outputTokens ?? 0); + const toolUses = + nonNegativeInteger(recordField(value, "tool_uses")) ?? + nonNegativeInteger(recordField(value, "totalToolUseCount")); + const durationMs = + nonNegativeInteger(recordField(value, "duration_ms")) ?? + nonNegativeInteger(recordField(value, "totalDurationMs")); + if ( + inputTokens === undefined && + cacheCreationTokens === undefined && + cachedInputTokens === undefined && + outputTokens === undefined && + nonNegativeInteger(recordField(value, "total_tokens")) === undefined && + nonNegativeInteger(recordField(value, "totalTokens")) === undefined && + toolUses === undefined && + durationMs === undefined + ) { + return undefined; + } + return { + totalTokens, + ...(inputTokens === undefined && cacheCreationTokens === undefined + ? {} + : { inputTokens: (inputTokens ?? 0) + (cacheCreationTokens ?? 0) }), + ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), + ...(outputTokens === undefined ? {} : { outputTokens }), + ...(toolUses === undefined ? {} : { toolUses }), + ...(durationMs === undefined ? {} : { durationMs }), + }; +} + +const claudeWorkflowAgentStatus = (entry: unknown) => { + const state = firstRecordField(entry, ["state", "status"]); + if (typeof state !== "string") return null; + switch (state.toLowerCase()) { + case "start": + return nonNegativeRecordField(entry, ["startedAt", "started_at"]) === undefined + ? ("pending" as const) + : ("running" as const); + case "pending": + case "queued": + return "pending" as const; + case "active": + case "in_progress": + case "running": + case "working": + return "running" as const; + case "blocked": + case "waiting": + return "waiting" as const; + case "completed": + case "done": + case "finished": + return "completed" as const; + case "error": + case "failed": + return "failed" as const; + default: + return null; + } +}; + function isClaudeNonSubagentTask(message: SDKMessage): boolean { return claudeTaskTypeFromSdkMessage(message) === "local_bash"; } @@ -1910,6 +2012,7 @@ interface ActiveClaudeTurnContext { interface ActiveClaudeSubagent { task: OrchestrationV2Subagent; + activation: OrchestrationV2SubagentActivation; readonly childThreadId: ThreadId; readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; readonly turnItemId: OrchestrationV2TurnItem["id"]; @@ -2218,11 +2321,20 @@ export function makeClaudeAdapterV2( readonly toolUseId?: string; readonly prompt?: string; readonly title?: string; + readonly agentType?: string; + readonly roleFallback?: string; + readonly model?: string; + readonly kind?: OrchestrationV2Subagent["kind"]; + readonly workflow?: OrchestrationV2Subagent["workflow"]; + readonly workflowMembership?: OrchestrationV2Subagent["workflowMembership"]; + readonly parentNodeId?: OrchestrationV2ExecutionNode["id"]; + readonly allowCreateSettled?: boolean; readonly progress?: string; readonly result?: string; + readonly usage?: OrchestrationV2SubagentUsage; readonly status: Extract< - OrchestrationV2ExecutionNode["status"], - "running" | "completed" | "failed" | "cancelled" + OrchestrationV2Subagent["status"], + "pending" | "running" | "waiting" | "completed" | "failed" | "cancelled" >; readonly reopen?: boolean; }) { @@ -2234,7 +2346,11 @@ export function makeClaudeAdapterV2( ? undefined : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); - if (existingSubagent === undefined && input.status !== "running") { + if ( + existingSubagent === undefined && + input.status !== "running" && + input.allowCreateSettled !== true + ) { return; } // Status is monotone with one exception: task_started for a known @@ -2242,17 +2358,14 @@ export function makeClaudeAdapterV2( // completed subagent resumes it and re-emits task_started with the // same id), so it may re-open a terminal entry. A late or // out-of-order task_progress must not. - const isReopen = - input.reopen === true && + const previousSettled = existingSubagent !== undefined && - existingSubagent.task.status !== "running" && - input.status === "running"; - if ( - existingSubagent !== undefined && - existingSubagent.task.status !== "running" && - input.status === "running" && - !isReopen - ) { + (existingSubagent.task.status === "completed" || + existingSubagent.task.status === "failed" || + existingSubagent.task.status === "cancelled"); + const activeStart = input.status === "pending" || input.status === "running"; + const isReopen = input.reopen === true && previousSettled && activeStart; + if (existingSubagent !== undefined && previousSettled && activeStart && !isReopen) { return; } const lifecycleChanged = @@ -2262,9 +2375,13 @@ export function makeClaudeAdapterV2( // already pre-opened to running by bufferWakeMessage while the // projection node still holds the old terminal status; re-emit // the node lifecycle for authoritative task_started updates. - (input.reopen === true && input.status === "running"); + (input.reopen === true && activeStart); const now = yield* DateTime.now; + const settled = + input.status === "completed" || + input.status === "failed" || + input.status === "cancelled"; const nativeItemId = `task:${input.taskId}`; const nodeId = existingSubagent?.task.id ?? @@ -2301,12 +2418,13 @@ export function makeClaudeAdapterV2( existingSubagent.task, ) : existingSubagent.task; + const startsActivation = existingSubagent === undefined || isReopen; const task = { ...(priorTask ?? { id: nodeId, threadId: input.context.input.threadId, runId: input.context.input.runId, - parentNodeId: input.context.input.rootNodeId, + parentNodeId: input.parentNodeId ?? input.context.input.rootNodeId, origin: "provider_native" as const, createdBy: "agent" as const, driver: CLAUDE_PROVIDER, @@ -2320,8 +2438,16 @@ export function makeClaudeAdapterV2( }, prompt: input.prompt ?? "", title: input.title ?? null, - model: input.context.input.modelSelection.model, + model: input.model ?? input.context.input.modelSelection.model, + kind: input.kind ?? ("subagent" as const), + role: providerSubagentRole(input.agentType, input.roleFallback), result: null, + usage: null, + currentActivationId: null, + activationCount: 0, + workflow: null, + workflowMembership: null, + recentActivity: [], startedAt: now, }), status: input.status, @@ -2332,20 +2458,64 @@ export function makeClaudeAdapterV2( // subagents terminalize); attribution also enrolls the subagent // in the resuming run's active-child tracking so its fiber // outlives settle until the resumed task completes. - ...(input.reopen === true && - input.status === "running" && - existingSubagent !== undefined + ...(input.reopen === true && activeStart && existingSubagent !== undefined ? { runId: input.context.input.runId } : {}), ...(input.prompt === undefined ? {} : { prompt: input.prompt }), ...(input.title === undefined ? {} : { title: input.title }), + ...(input.model === undefined ? {} : { model: input.model }), + ...(input.kind === undefined ? {} : { kind: input.kind }), + ...(input.agentType === undefined + ? {} + : { role: providerSubagentRole(input.agentType, input.roleFallback) }), + ...(input.workflow === undefined ? {} : { workflow: input.workflow }), + ...(input.workflowMembership === undefined + ? {} + : { workflowMembership: input.workflowMembership }), ...(input.progress === undefined ? {} : { progress: input.progress }), ...(input.result === undefined ? {} : { result: input.result }), - completedAt: input.status === "running" ? null : now, + usage: accumulateSubagentUsage( + priorTask?.usage ?? null, + startsActivation ? undefined : existingSubagent?.activation.usage, + input.usage, + ), + activationCount: + existingSubagent === undefined || isReopen + ? (priorTask?.activationCount ?? 0) + 1 + : (priorTask?.activationCount ?? 1), + recentActivity: appendSubagentActivity( + priorTask?.recentActivity ?? [], + input.progress, + now, + ), + completedAt: settled ? now : null, updatedAt: now, } satisfies OrchestrationV2Subagent; + const activation = startsActivation + ? ({ + id: subagentActivationId(nodeId, task.activationCount), + threadId: task.threadId, + subagentId: nodeId, + runId: task.runId, + providerTurnId: input.context.providerTurnId, + ordinal: task.activationCount, + status: input.status, + usage: input.usage ?? null, + startedAt: now, + completedAt: settled ? now : null, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation) + : ({ + ...existingSubagent.activation, + status: input.status, + usage: mergeSubagentUsage(existingSubagent.activation.usage, input.usage), + completedAt: settled ? now : null, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation); + task.currentActivationId = settled ? null : activation.id; const subagent = { task, + activation, childThreadId, childRootNodeId, turnItemId: @@ -2376,7 +2546,7 @@ export function makeClaudeAdapterV2( if ( registered !== undefined && registered.task.status !== "running" && - input.status === "running" && + activeStart && !(isReopen && registered === existingSubagent) ) { return current; @@ -2435,7 +2605,7 @@ export function makeClaudeAdapterV2( runtimeRequestId: null, checkpointScopeId: null, startedAt: task.startedAt, - completedAt: input.status === "running" ? null : now, + completedAt: settled ? now : null, }, }); yield* emitProviderEvent({ @@ -2456,7 +2626,7 @@ export function makeClaudeAdapterV2( runtimeRequestId: null, checkpointScopeId: null, startedAt: task.startedAt, - completedAt: input.status === "running" ? null : now, + completedAt: settled ? now : null, }, }); } @@ -2501,6 +2671,11 @@ export function makeClaudeAdapterV2( driver: CLAUDE_PROVIDER, subagent: task, }); + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver: CLAUDE_PROVIDER, + activation, + }); yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -3151,12 +3326,16 @@ export function makeClaudeAdapterV2( new Set(current).add(message.task_id), ); } else { + const agentType = recordField(message, "subagent_type"); + const isWorkflow = message.task_type === "local_workflow"; yield* updateClaudeSubagentNode({ context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), ...(message.prompt === undefined ? {} : { prompt: message.prompt }), title: message.description, + ...(typeof agentType === "string" ? { agentType } : {}), + ...(isWorkflow ? { kind: "workflow", roleFallback: "workflow-coordinator" } : {}), status: "running", reopen: true, }); @@ -3165,22 +3344,115 @@ export function makeClaudeAdapterV2( if (message.type === "system" && message.subtype === "task_progress") { const progress = message.description.trim(); + const usage = claudeSubagentUsage(message.usage); + const agentType = recordField(message, "subagent_type"); + const workflowProgress = recordField(message, "workflow_progress"); + const workflowEntries = Array.isArray(workflowProgress) ? workflowProgress : []; + const phases = workflowEntries.flatMap((entry) => { + const index = nonNegativeRecordField(entry, ["index", "phaseIndex", "phase_index"]); + const title = firstRecordField(entry, ["title", "name"]); + return recordField(entry, "type") === "workflow_phase" && + index !== undefined && + typeof title === "string" && + title.trim() + ? [{ index, title: title.trim() }] + : []; + }); const isBackgroundTask = (yield* Ref.get(pendingBackgroundTaskIds)).has( message.task_id, ); + const shouldTrackSubagent = + !context.ignoredTaskIds.has(message.task_id) && !isBackgroundTask; if ( - progress.length > 0 && - !context.ignoredTaskIds.has(message.task_id) && - !isBackgroundTask + (progress.length > 0 || usage !== undefined || workflowEntries.length > 0) && + shouldTrackSubagent ) { yield* updateClaudeSubagentNode({ context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), - progress, + ...(progress.length === 0 ? {} : { progress }), + ...(usage === undefined ? {} : { usage }), + ...(typeof agentType === "string" ? { agentType } : {}), + ...(phases.length === 0 + ? {} + : { + kind: "workflow", + roleFallback: "workflow-coordinator", + workflow: { phases }, + }), status: "running", }); } + + const workflowParent = shouldTrackSubagent + ? (context.subagentsByTaskId.get(message.task_id) ?? + (message.tool_use_id === undefined + ? undefined + : context.subagentsByToolUseId.get(message.tool_use_id)) ?? + (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)) + : undefined; + if (workflowParent !== undefined) { + for (const entry of workflowEntries) { + if (recordField(entry, "type") !== "workflow_agent") continue; + const index = nonNegativeInteger(recordField(entry, "index")); + const label = firstRecordField(entry, ["label", "name"]); + if (index === undefined || typeof label !== "string" || !label.trim()) continue; + const status = claudeWorkflowAgentStatus(entry); + if (status === null) continue; + const phaseIndex = nonNegativeRecordField(entry, ["phaseIndex", "phase_index"]); + const attempt = (nonNegativeInteger(recordField(entry, "attempt")) ?? 0) + 1; + const model = recordField(entry, "model"); + const lastToolName = firstRecordField(entry, ["lastToolName", "last_tool_name"]); + const tokens = nonNegativeRecordField(entry, ["tokens", "total_tokens"]); + const toolUses = nonNegativeRecordField(entry, [ + "toolCalls", + "tool_calls", + "tool_uses", + ]); + const childUsage = + tokens === undefined + ? undefined + : ({ + totalTokens: tokens, + ...(toolUses === undefined ? {} : { toolUses }), + } satisfies OrchestrationV2SubagentUsage); + const workerTaskId = `${message.task_id}:workflow:${index}`; + const existingWorker = + context.subagentsByTaskId.get(workerTaskId) ?? + (yield* Ref.get(sessionSubagentsByTaskId)).get(workerTaskId); + const activeWorker = status === "pending" || status === "running"; + const reopen = + activeWorker && + existingWorker !== undefined && + attempt > (existingWorker.task.workflowMembership?.attempt ?? 0); + yield* updateClaudeSubagentNode({ + context, + taskId: workerTaskId, + title: label.trim(), + parentNodeId: workflowParent.task.id, + kind: "workflow_agent", + roleFallback: "workflow-worker", + ...(typeof model === "string" && model.trim() ? { model: model.trim() } : {}), + workflowMembership: { + workflowSubagentId: workflowParent.task.id, + agentIndex: index, + phaseIndex: phaseIndex ?? null, + attempt, + }, + allowCreateSettled: true, + ...(typeof lastToolName === "string" && lastToolName.trim() + ? { progress: lastToolName.trim() } + : {}), + ...(childUsage === undefined ? {} : { usage: childUsage }), + ...(typeof recordField(entry, "error") === "string" + ? { result: String(recordField(entry, "error")) } + : {}), + status, + reopen, + }); + } + } } if (message.type === "system" && message.subtype === "task_notification") { @@ -3188,11 +3460,13 @@ export function makeClaudeAdapterV2( // background registry is the durable ignore signal across turns. const wasBackgroundTask = yield* clearPendingBackgroundTask(message.task_id); if (!wasBackgroundTask && !context.ignoredTaskIds.has(message.task_id)) { + const usage = claudeSubagentUsage(message.usage); yield* updateClaudeSubagentNode({ context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), result: message.summary, + ...(usage === undefined ? {} : { usage }), status: message.status === "completed" ? "completed" diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index f4b85199796..d08fafc3cd1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -986,6 +986,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { (event): event is Extract => event.type === "subagent.updated", ); + const subagentActivationUpdates = () => + events.filter( + ( + event, + ): event is Extract => + event.type === "subagent_activation.updated", + ); return { runtime, providerThread, @@ -994,6 +1001,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { continuationRequests, terminalEvents, subagentUpdates, + subagentActivationUpdates, hasPendingBackgroundWork, }; }); @@ -3184,6 +3192,38 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }); + const childTokenUsageUpdated = ( + turnId: string, + totalTokens: number, + lastTokens: number, + ): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `thread/tokenUsage/updated/${turnId}`, + frame: { + method: "thread/tokenUsage/updated", + params: { + threadId: RESUME_CHILD_THREAD, + turnId, + tokenUsage: { + total: { + totalTokens, + inputTokens: totalTokens - 10, + cachedInputTokens: 0, + outputTokens: 10, + reasoningOutputTokens: 0, + }, + last: { + totalTokens: lastTokens, + inputTokens: lastTokens - 10, + cachedInputTokens: 0, + outputTokens: 10, + reasoningOutputTokens: 0, + }, + }, + }, + }, + }); + const resumeSubagentTranscript = makeCodexReplayTranscript({ scenario: RESUME_SCENARIO, entries: [ @@ -3232,6 +3272,18 @@ describe("CodexAdapterV2 post-settle continuation", () => { completedAtMs: 1782622442002, }), childTurnCompleted(RESUME_CHILD_TURN_1, 100), + childTokenUsageUpdated(RESUME_CHILD_TURN_1, 100, 100), + { + type: "emit_inbound", + label: "thread/status/changed/late-active", + frame: { + method: "thread/status/changed", + params: { + threadId: RESUME_CHILD_THREAD, + status: { type: "active", activeFlags: [] }, + }, + }, + }, { type: "emit_inbound", label: "item/completed/root-answer", @@ -3271,6 +3323,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { afterMs: 30_000, }), childTurnCompleted(RESUME_CHILD_TURN_2), + childTokenUsageUpdated(RESUME_CHILD_TURN_2, 180, 80), ], }); @@ -3303,8 +3356,20 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); const settledUpdates = harness.subagentUpdates(); const firstCompletion = settledUpdates[settledUpdates.length - 1]; - assert.equal(firstCompletion?.subagent.status, "completed"); + assert.equal(firstCompletion?.subagent.status, "idle"); assert.equal(firstCompletion?.subagent.result, "CODEX_FIRST_DONE"); + assert.equal(firstCompletion?.subagent.activationCount, 1); + assert.equal(firstCompletion?.subagent.usage?.totalTokens, 100); + assert.isNull(firstCompletion?.subagent.currentActivationId); + assert.lengthOf( + new Set( + settledUpdates + .filter((event) => event.subagent.status === "idle") + .map((event) => event.subagent.completedAt), + ), + 1, + "post-idle usage updates preserve the completion timestamp", + ); assert.isFalse(yield* harness.hasPendingBackgroundWork); const settledUpdateCount = settledUpdates.length; @@ -3323,10 +3388,31 @@ describe("CodexAdapterV2 post-settle continuation", () => { const latest = updates[updates.length - 1]; return ( latest !== undefined && - latest.subagent.status === "completed" && + latest.subagent.status === "idle" && latest.subagent.result === "CODEX_RESUME_DONE" ); }, "resumed subagent completion"); + const finalSubagent = harness.subagentUpdates().at(-1)?.subagent; + assert.equal(finalSubagent?.activationCount, 2); + assert.equal(finalSubagent?.usage?.totalTokens, 180); + assert.isNull(finalSubagent?.currentActivationId); + const activations = harness.subagentActivationUpdates(); + assert.lengthOf(new Set(activations.map((event) => event.activation.id)), 2); + assert.sameMembers( + [...new Set(activations.map((event) => event.activation.ordinal))], + [1, 2], + ); + assert.deepEqual( + [ + ...new Set( + activations.map((event) => + event.activation.providerTurnId?.replace(/^.*native-turn:/, ""), + ), + ), + ], + [RESUME_CHILD_TURN_1, RESUME_CHILD_TURN_2], + ); + assert.equal(activations.at(-1)?.activation.status, "completed"); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.terminalEvents(), 1); assert.lengthOf(harness.continuationRequests, 0); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 0ffd4232111..f44bae0a35d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -16,6 +16,8 @@ import type { OrchestrationV2PlanStep, OrchestrationV2RuntimeRequest, OrchestrationV2Subagent, + OrchestrationV2SubagentActivation, + OrchestrationV2SubagentUsage, OrchestrationV2TurnItem, ProviderUserInputAnswers, ProviderApprovalDecision, @@ -100,12 +102,24 @@ import { makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; +import { + appendSubagentActivity, + defaultSubagentRole, + mergeSubagentUsage, + subagentActivationId, +} from "../SubagentObservability.ts"; const CODEX_PROVIDER = ProviderDriverKind.make("codex"); export const CODEX_DRIVER_KIND = CODEX_PROVIDER; export const CODEX_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CODEX_DRIVER_KIND); const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({}); const CODEX_ASSISTANT_DELTA_FLUSH_INTERVAL_MS = 50; +const isSettledCodexSubagent = (subagent: OrchestrationV2Subagent) => + subagent.status === "idle" || + subagent.status === "completed" || + subagent.status === "failed" || + subagent.status === "cancelled" || + subagent.status === "interrupted"; const CodexBackgroundTerminalTerminateResponse = Schema.Struct({ terminated: Schema.Boolean, }); @@ -801,6 +815,7 @@ interface ActiveCodexTurnContext { readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; readonly subagent: CodexSubagentThreadContext | null; readonly startedAt: DateTime.Utc; + subagentActivation: OrchestrationV2SubagentActivation | null; } /** Snapshot of a still-running commandExecution item for interrupt/fail terminalization. */ @@ -892,6 +907,22 @@ type CodexSubAgentActivityItem = Extract< { readonly type: "subAgentActivity" } >; +const CODEX_SUBAGENT_ACTIVITY_LABELS: Readonly> = { + commandExecution: "Running command", + fileChange: "Editing files", + mcpToolCall: "Using MCP tool", + dynamicToolCall: "Using tool", + webSearch: "Searching the web", + reasoning: "Reasoning", + plan: "Updating plan", + todoList: "Updating tasks", + collabAgentToolCall: "Coordinating agents", + subAgentActivity: "Coordinating subagent", +}; + +const codexSubagentActivitySummary = (item: { readonly type: string }) => + CODEX_SUBAGENT_ACTIVITY_LABELS[item.type] ?? null; + export interface CodexAgentMessageDeltaUpdate { readonly turnId: string; readonly itemId: string; @@ -1475,6 +1506,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi rootNodeId: input.turnInput.rootNodeId, subagent: null, startedAt: input.startedAt, + subagentActivation: null, }; yield* Ref.update(activeTurns, (current) => { const updated = new Map(current); @@ -1684,20 +1716,42 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi readonly subagent: CodexSubagentThreadContext; readonly status: OrchestrationV2Subagent["status"]; readonly result?: string | null; + readonly progress?: string; + readonly activity?: string; + readonly usage?: OrchestrationV2SubagentUsage; + readonly currentActivationId?: OrchestrationV2Subagent["currentActivationId"]; + readonly activationCount?: number; readonly completedAt?: DateTime.Utc | null; }) => Effect.gen(function* () { const now = yield* DateTime.now; const terminal = + input.status === "idle" || input.status === "completed" || input.status === "failed" || input.status === "cancelled" || input.status === "interrupted"; - const completedAt = terminal ? (input.completedAt ?? now) : null; + const completedAt = terminal + ? (input.completedAt ?? input.subagent.task.completedAt ?? now) + : null; const task = { ...input.subagent.task, status: input.status, + ...(input.progress === undefined ? {} : { progress: input.progress }), result: input.result === undefined ? input.subagent.task.result : input.result, + usage: mergeSubagentUsage(input.subagent.task.usage, input.usage), + currentActivationId: + input.currentActivationId !== undefined + ? input.currentActivationId + : terminal + ? null + : input.subagent.task.currentActivationId, + activationCount: input.activationCount ?? input.subagent.task.activationCount, + recentActivity: appendSubagentActivity( + input.subagent.task.recentActivity, + input.activity, + now, + ), completedAt, updatedAt: now, } satisfies OrchestrationV2Subagent; @@ -1808,19 +1862,40 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi rootNodeId: providerNodeId, subagent, startedAt: turn.startedAt, + subagentActivation: null, }; + const activationOrdinal = subagent.task.activationCount + 1; + const activation = { + id: subagentActivationId(subagent.task.id, activationOrdinal), + threadId: subagent.task.threadId, + subagentId: subagent.task.id, + runId: subagent.parentContext.projectionRunId, + providerTurnId, + ordinal: activationOrdinal, + status: "running", + usage: null, + startedAt: turn.startedAt, + completedAt: null, + updatedAt: turn.startedAt, + } satisfies OrchestrationV2SubagentActivation; + activeContext.subagentActivation = activation; yield* Ref.update(activeTurns, (current) => { const updated = new Map(current); updated.set(turn.nativeTurnId, activeContext); return updated; }); - if (providerTurnOrdinal > 1 && subagent.task.status !== "running") { - yield* emitSubagentTaskUpdate({ - subagent, - status: "running", - result: null, - }); - } + yield* emitSubagentTaskUpdate({ + subagent, + status: "running", + result: providerTurnOrdinal > 1 ? null : subagent.task.result, + currentActivationId: activation.id, + activationCount: activationOrdinal, + }); + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver: CODEX_PROVIDER, + activation, + }); const now = yield* DateTime.now; yield* emitProviderEvent({ type: "provider_thread.updated", @@ -1968,8 +2043,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi prompt: input.prompt, title: input.title, model: input.model, - status: "running", + kind: "subagent", + role: defaultSubagentRole(), + status: "pending", result: null, + usage: null, + currentActivationId: null, + activationCount: 0, + workflow: null, + workflowMembership: null, + recentActivity: [], startedAt: now, completedAt: null, updatedAt: now, @@ -3136,12 +3219,107 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe(Effect.orDie, turnTerminalizationPermit.withPermits(1)), ); + yield* client.handleServerNotification("thread/tokenUsage/updated", (payload) => + Effect.gen(function* () { + const subagent = (yield* Ref.get(subagentThreads)).get(payload.threadId); + if (subagent === undefined || payload.tokenUsage.total.totalTokens <= 0) { + return; + } + const total = payload.tokenUsage.total; + const usage = { + totalTokens: total.totalTokens, + inputTokens: total.inputTokens, + cachedInputTokens: total.cachedInputTokens, + outputTokens: total.outputTokens, + reasoningOutputTokens: total.reasoningOutputTokens, + } satisfies OrchestrationV2SubagentUsage; + yield* emitSubagentTaskUpdate({ + subagent, + status: subagent.task.status, + usage, + }); + + const activeContext = (yield* Ref.get(activeTurns)).get(payload.turnId); + if ( + activeContext === undefined || + activeContext.subagent !== subagent || + activeContext.subagentActivation === null + ) { + return; + } + const last = payload.tokenUsage.last; + const activation = { + ...activeContext.subagentActivation, + usage: { + totalTokens: last.totalTokens, + inputTokens: last.inputTokens, + cachedInputTokens: last.cachedInputTokens, + outputTokens: last.outputTokens, + reasoningOutputTokens: last.reasoningOutputTokens, + }, + updatedAt: yield* DateTime.now, + } satisfies OrchestrationV2SubagentActivation; + activeContext.subagentActivation = activation; + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver: CODEX_PROVIDER, + activation, + }); + }).pipe(Effect.orDie), + ); + + yield* client.handleServerNotification("thread/status/changed", (payload) => + Effect.gen(function* () { + const subagent = (yield* Ref.get(subagentThreads)).get(payload.threadId); + if (subagent === undefined || isSettledCodexSubagent(subagent.task)) return; + const status = + payload.status.type === "active" + ? payload.status.activeFlags.length > 0 + ? ("waiting" as const) + : ("running" as const) + : payload.status.type === "systemError" + ? ("failed" as const) + : null; + if (status === null) return; + yield* emitSubagentTaskUpdate({ subagent, status }); + + const activeContext = Array.from((yield* Ref.get(activeTurns)).values()).find( + (context) => context.subagent === subagent, + ); + if (activeContext === undefined || activeContext.subagentActivation === null) return; + const now = yield* DateTime.now; + const activation = { + ...activeContext.subagentActivation, + status, + completedAt: status === "failed" ? now : null, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation; + activeContext.subagentActivation = activation; + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver: CODEX_PROVIDER, + activation, + }); + }).pipe(Effect.orDie), + ); + yield* client.handleServerNotification("item/started", (payload) => Effect.gen(function* () { const context = yield* awaitActiveTurn(payload.turnId); if (context === undefined) { return; } + if (context.subagent !== null) { + const activity = codexSubagentActivitySummary(payload.item); + if (activity !== null) { + yield* emitSubagentTaskUpdate({ + subagent: context.subagent, + status: "running", + progress: activity, + activity, + }); + } + } if (payload.item.type === "userMessage") { if (yield* emitSubagentUserMessage(context, payload.item)) { @@ -4022,9 +4200,23 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi completedAt: input.completedAt, }, }); + if (input.context.subagentActivation !== null) { + const activation = { + ...input.context.subagentActivation, + status: input.status, + completedAt: input.completedAt, + updatedAt: input.completedAt, + } satisfies OrchestrationV2SubagentActivation; + input.context.subagentActivation = activation; + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver: CODEX_PROVIDER, + activation, + }); + } yield* emitSubagentTaskUpdate({ subagent: input.context.subagent, - status: input.status, + status: input.status === "completed" ? "idle" : input.status, completedAt: input.completedAt, }); } diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 30b7a36c4d9..1162265631f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -23,6 +23,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, type OrchestrationV2TurnItem, type ProviderInstanceId, type ThreadId, @@ -79,6 +80,7 @@ import { makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; +import { defaultSubagentRole, subagentActivationId } from "../SubagentObservability.ts"; import { CURSOR_PROVIDER, CursorAgentSdkRunner, @@ -1477,6 +1479,7 @@ export function makeCursorAdapterV2( driver: CURSOR_PROVIDER, nativeThreadId: `${input.context.run.runId}:task:${input.callId}`, }); + const activationId = subagentActivationId(nodeId, 1); const task: OrchestrationV2Subagent = { ...(existing?.task ?? { id: nodeId, @@ -1497,7 +1500,15 @@ export function makeCursorAdapterV2( prompt: args.prompt, title: args.description, model: args.model ?? input.context.input.modelSelection.model, + kind: "subagent" as const, + role: defaultSubagentRole(), result: null, + usage: null, + currentActivationId: activationId, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], startedAt: now, }), nativeTaskRef: { @@ -1507,6 +1518,7 @@ export function makeCursorAdapterV2( }, status, result: resultText.length === 0 ? (existing?.task.result ?? null) : resultText, + currentActivationId: input.completed ? null : activationId, completedAt: input.completed ? now : null, updatedAt: now, }; @@ -1633,6 +1645,23 @@ export function makeCursorAdapterV2( driver: CURSOR_PROVIDER, subagent: task, }); + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver: CURSOR_PROVIDER, + activation: { + id: activationId, + threadId: task.threadId, + subagentId: task.id, + runId: task.runId, + providerTurnId: input.context.providerTurnId, + ordinal: 1, + status, + usage: null, + startedAt: task.startedAt, + completedAt: input.completed ? now : null, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation, + }); yield* emitProviderEvent({ type: "turn_item.updated", driver: CURSOR_PROVIDER, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 59ca6efb355..bd15ffa1467 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -28,6 +28,7 @@ import { type OrchestrationV2ProviderTurn, type OrchestrationV2RuntimeRequest, type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, type OrchestrationV2TurnItem, OpenCodeSettings as OpenCodeSettingsSchema, type PlanId, @@ -101,6 +102,11 @@ import { type ProviderAdapterDriverCreateInput, } from "../ProviderAdapterDriver.ts"; import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts"; +import { + appendSubagentActivity, + defaultSubagentRole, + subagentActivationId, +} from "../SubagentObservability.ts"; export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode"); export const OPENCODE_DRIVER_KIND = OPENCODE_PROVIDER; @@ -248,6 +254,7 @@ interface OpenCodeSubagentContext { childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; model: string | null; result: string | null; + recentActivity: OrchestrationV2Subagent["recentActivity"]; } interface OpenCodeThreadState { @@ -1086,6 +1093,7 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid childProviderThreadId: null, model: null, result: null, + recentActivity: [], }; subagentsByNativeItemId.set(part.id, context); } @@ -1195,6 +1203,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid : status.item === "pending" ? "pending" : "running"; + const activationId = subagentActivationId(nodeId, 1); + const settled = subagentStatus === "completed" || subagentStatus === "failed"; + context.recentActivity = appendSubagentActivity(context.recentActivity, title, now); const subagent: OrchestrationV2Subagent = { id: nodeId, threadId: turn.threadId, @@ -1210,8 +1221,16 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid prompt, title, model: context.model, + kind: "subagent", + role: defaultSubagentRole(), status: subagentStatus, result: context.result, + usage: null, + currentActivationId: settled ? null : activationId, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: context.recentActivity, startedAt: context.startedAt, completedAt, updatedAt: now, @@ -1242,6 +1261,23 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid driver: OPENCODE_PROVIDER, subagent, }); + yield* emitProviderEvent({ + type: "subagent_activation.updated", + driver: OPENCODE_PROVIDER, + activation: { + id: activationId, + threadId: subagent.threadId, + subagentId: subagent.id, + runId: subagent.runId, + providerTurnId: turn.providerTurnId, + ordinal: 1, + status: subagentStatus, + usage: null, + startedAt: context.startedAt, + completedAt, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation, + }); yield* emitProviderEvent({ type: "turn_item.updated", driver: OPENCODE_PROVIDER, diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index d218701a4a3..8a2a4520345 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -19,6 +19,7 @@ import { ProviderThreadId, RunAttemptId, RunId, + SubagentActivationId, ThreadId, TurnItemId, } from "@t3tools/contracts"; @@ -266,6 +267,7 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { const eventSink = yield* EventSinkV2; const projectionStore = yield* ProjectionStoreV2; const maintenance = yield* ProjectionMaintenanceV2; + const sql = yield* SqlClient.SqlClient; const now = yield* DateTime.now; const parentThreadId = ThreadId.make("thread:foundation-cross-thread:parent"); const childThreadId = ThreadId.make("thread:foundation-cross-thread:child"); @@ -273,6 +275,9 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { "provider-thread:foundation-cross-thread:child", ); const subagentId = NodeId.make("subagent:foundation-cross-thread"); + const activationId = SubagentActivationId.make( + "subagent:foundation-cross-thread:activation:1", + ); const spawnTransferId = ContextTransferId.make("transfer:foundation-cross-thread:spawn"); const resultTransferId = ContextTransferId.make("transfer:foundation-cross-thread:result"); const parentThread = makeThread(parentThreadId, now); @@ -321,8 +326,37 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { prompt: "Inspect the child flow", title: "Cross-thread child", model: modelSelection.model, + kind: "subagent", + role: { name: "general-purpose", source: "app_default" }, status: "completed", result: "done", + usage: null, + currentActivationId: null, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("event:foundation-cross-thread:subagent-activation"), + type: "subagent-activation.updated", + threadId: parentThreadId, + nodeId: subagentId, + providerInstanceId, + occurredAt: now, + payload: { + id: activationId, + threadId: parentThreadId, + subagentId, + runId: null, + providerTurnId: null, + ordinal: 1, + status: "completed", + usage: { totalTokens: 240, toolUses: 2 }, startedAt: now, completedAt: now, updatedAt: now, @@ -423,11 +457,57 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => { [childProviderThreadId], ); assert.equal(child.thread.activeProviderThreadId, childProviderThreadId); + assert.deepEqual( + parent.subagentActivations.map((activation) => ({ + id: activation.id, + totalTokens: activation.usage?.totalTokens, + })), + [ + { + id: activationId, + totalTokens: 240, + }, + ], + ); }); yield* assertCrossThreadProjection; assert.isTrue((yield* maintenance.verify).valid); + yield* sql` + INSERT INTO orchestration_v2_projection_subagent_activations ( + activation_id, + thread_id, + subagent_id, + run_id, + provider_turn_id, + ordinal, + status, + started_at, + completed_at, + updated_at, + payload_json + ) + VALUES ( + 'activation:foundation-cross-thread:stale', + ${parentThreadId}, + ${subagentId}, + NULL, + NULL, + 99, + 'completed', + NULL, + NULL, + '2026-07-26T00:00:00.000Z', + '{}' + ) + `; assert.isTrue((yield* maintenance.rebuild).valid); + const staleActivationRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM orchestration_v2_projection_subagent_activations + WHERE activation_id = 'activation:foundation-cross-thread:stale' + `; + assert.equal(staleActivationRows[0]?.count, 0); yield* assertCrossThreadProjection; }), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3de91906479..91c4e128b27 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -18,6 +18,7 @@ import { type OrchestrationV2ThreadShellSnapshot, type OrchestrationV2StoredEvent, type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, ProviderInstanceId, @@ -53,6 +54,7 @@ import { subagentResultForRun, subagentThreadTitle, } from "./SubagentProjection.ts"; +import { defaultSubagentRole, subagentActivationId } from "./SubagentObservability.ts"; import { ThreadForkServiceV2 } from "./ThreadForkService.ts"; export class OrchestratorDispatchError extends Schema.TaggedErrorClass()( @@ -222,7 +224,10 @@ function isBlockingRun(run: OrchestrationV2Run): boolean { function delegatedTaskTerminalStatus( status: OrchestrationV2Run["status"], -): OrchestrationV2Subagent["status"] | null { +): Extract< + OrchestrationV2Subagent["status"], + "completed" | "failed" | "cancelled" | "interrupted" +> | null { switch (status) { case "completed": case "failed": @@ -240,6 +245,12 @@ function delegatedTaskTerminalStatus( } } +const isTerminalSubagentActivation = (activation: OrchestrationV2SubagentActivation) => + activation.status === "completed" || + activation.status === "failed" || + activation.status === "cancelled" || + activation.status === "interrupted"; + function nextQueuedRun( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2Run | undefined { @@ -3687,6 +3698,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, }; + const activationId = subagentActivationId(taskNodeId, 1); const task: OrchestrationV2Subagent = { id: taskNodeId, threadId: command.parentThreadId, @@ -3702,8 +3714,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio prompt: command.task, title: command.title ?? null, model: command.modelSelection.model, + kind: "subagent", + role: defaultSubagentRole("delegated-worker"), status: "running", result: null, + usage: null, + currentActivationId: activationId, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], startedAt: now, completedAt: null, updatedAt: now, @@ -3726,6 +3746,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: null, }; const parentProviderTurn = providerTurnForRun(parentProjection, parentRun); + const activation = { + id: activationId, + threadId: command.parentThreadId, + subagentId: taskNodeId, + runId: parentRun.id, + providerTurnId: parentProviderTurn?.id ?? null, + ordinal: 1, + status: "running", + usage: null, + startedAt: now, + completedAt: null, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation; const taskTurnItem: OrchestrationV2TurnItem = { id: taskTurnItemId, threadId: command.parentThreadId, @@ -3780,6 +3813,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: task, }); + yield* emitEvent({ + type: "subagent-activation.updated", + threadId: command.parentThreadId, + runId: parentRun.id, + nodeId: taskNodeId, + driver: targetAdapter.driver, + providerInstanceId: command.modelSelection.instanceId, + occurredAt: now, + payload: activation, + }); yield* emitEvent({ type: "turn-item.updated", threadId: command.parentThreadId, @@ -4949,9 +4992,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerThreadId: childRun.providerThreadId, status: terminalStatus, result: result.text, + currentActivationId: null, completedAt: now, updatedAt: now, }; + const currentActivation = + task.currentActivationId === null + ? undefined + : parentProjection.subagentActivations.find( + (activation) => activation.id === task.currentActivationId, + ); + const terminalActivation = + task.currentActivationId === null || currentActivation?.status === terminalStatus + ? null + : currentActivation === undefined + ? ({ + id: task.currentActivationId, + threadId: task.threadId, + subagentId: task.id, + runId: task.runId, + providerTurnId: null, + ordinal: Math.max(1, task.activationCount), + status: terminalStatus, + usage: task.usage, + startedAt: task.startedAt, + completedAt: now, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation) + : isTerminalSubagentActivation(currentActivation) + ? null + : ({ + ...currentActivation, + status: terminalStatus, + completedAt: now, + updatedAt: now, + } satisfies OrchestrationV2SubagentActivation); const resultTransferId = yield* idAllocator.allocate.contextTransfer({ sourceThreadId: childThreadId, targetThreadId: parentThreadId, @@ -5036,6 +5111,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: updatedTask, }, + ...(terminalActivation === null + ? [] + : [ + { + type: "subagent-activation.updated" as const, + threadId: parentThreadId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + occurredAt: now, + payload: terminalActivation, + }, + ]), ...(parentNode === undefined ? [] : [ diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 84260f5ac8a..1d26c8b574f 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -152,6 +152,7 @@ export const layer: Layer.Layer< yield* sql`DELETE FROM orchestration_v2_projection_provider_threads`; yield* sql`DELETE FROM orchestration_v2_projection_provider_session_bindings`; yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions`; + yield* sql`DELETE FROM orchestration_v2_projection_subagent_activations`; yield* sql`DELETE FROM orchestration_v2_projection_subagents`; yield* sql`DELETE FROM orchestration_v2_projection_nodes`; yield* sql`DELETE FROM orchestration_v2_projection_run_attempts`; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index c6a446039e1..d463b3a0c6a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -25,6 +25,7 @@ import { OrchestrationV2RunJson as OrchestrationV2RunJsonSchema, OrchestrationV2RuntimeRequestJson as OrchestrationV2RuntimeRequestJsonSchema, OrchestrationV2SubagentJson as OrchestrationV2SubagentJsonSchema, + OrchestrationV2SubagentActivationJson as OrchestrationV2SubagentActivationJsonSchema, OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema, RunId, ThreadId, @@ -121,7 +122,7 @@ export class ProjectionStoreV2 extends Context.Service(items: ReadonlyArray, next: T): Array { const index = items.findIndex((item) => item.id === next.id); @@ -143,6 +144,7 @@ export function emptyProjection( attempts: [], nodes: [], subagents: [], + subagentActivations: [], providerSessions: [], providerThreads: [], providerTurns: [], @@ -212,6 +214,11 @@ export function applyToProjection( ...base, subagents: upsertById(base.subagents, event.payload), }; + case "subagent-activation.updated": + return { + ...base, + subagentActivations: upsertById(base.subagentActivations, event.payload), + }; case "provider-session.attached": case "provider-session.updated": return { @@ -422,6 +429,9 @@ const encodeNodePayload = Schema.encodeEffect( const encodeSubagentPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2SubagentJsonSchema), ); +const encodeSubagentActivationPayload = Schema.encodeEffect( + Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema), +); const encodeProviderSessionPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema), ); @@ -466,6 +476,10 @@ const decodeNodePayload = (json: string) => Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema))(json); const decodeSubagentPayload = (json: string) => Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentJsonSchema))(json); +const decodeSubagentActivationPayload = (json: string) => + Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2SubagentActivationJsonSchema))( + json, + ); const decodeProviderSessionPayload = (json: string) => Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2ProviderSessionJsonSchema))(json); const decodeProviderThreadPayload = (json: string) => @@ -1235,6 +1249,51 @@ export const layer: Layer.Layer = `; break; } + case "subagent-activation.updated": { + const payloadJson = yield* encodeSubagentActivationPayload(event.payload); + const payload = parseEncodedPayload(payloadJson); + yield* sql` + INSERT INTO orchestration_v2_projection_subagent_activations ( + activation_id, + thread_id, + subagent_id, + run_id, + provider_turn_id, + ordinal, + status, + started_at, + completed_at, + updated_at, + payload_json + ) + VALUES ( + ${event.payload.id}, + ${event.payload.threadId}, + ${event.payload.subagentId}, + ${event.payload.runId}, + ${event.payload.providerTurnId}, + ${event.payload.ordinal}, + ${event.payload.status}, + ${nullableStringField(payload, "startedAt")}, + ${nullableStringField(payload, "completedAt")}, + ${stringField(payload, "updatedAt")}, + ${payloadJson} + ) + ON CONFLICT(activation_id) + DO UPDATE SET + thread_id = excluded.thread_id, + subagent_id = excluded.subagent_id, + run_id = excluded.run_id, + provider_turn_id = excluded.provider_turn_id, + ordinal = excluded.ordinal, + status = excluded.status, + started_at = excluded.started_at, + completed_at = excluded.completed_at, + updated_at = excluded.updated_at, + payload_json = excluded.payload_json + `; + break; + } case "provider-session.attached": case "provider-session.updated": { const payloadJson = yield* encodeProviderSessionPayload(event.payload); @@ -1809,6 +1868,7 @@ export const layer: Layer.Layer = attemptRows, nodeRows, subagentRows, + subagentActivationRows, providerSessionRows, providerThreadRows, providerTurnRows, @@ -1846,6 +1906,12 @@ export const layer: Layer.Layer = WHERE thread_id = ${threadId} ORDER BY COALESCE(started_at, ''), subagent_id ASC `, + sql` + SELECT payload_json + FROM orchestration_v2_projection_subagent_activations + WHERE thread_id = ${threadId} + ORDER BY subagent_id ASC, ordinal ASC + `, sql` SELECT sessions.payload_json FROM orchestration_v2_projection_provider_sessions AS sessions @@ -1932,6 +1998,7 @@ export const layer: Layer.Layer = attempts, nodes, subagents, + subagentActivations, providerSessions, providerThreads, providerTurns, @@ -1948,6 +2015,7 @@ export const layer: Layer.Layer = decodeRows(decodeRunAttemptPayload, threadId)(attemptRows), decodeRows(decodeNodePayload, threadId)(nodeRows), decodeRows(decodeSubagentPayload, threadId)(subagentRows), + decodeRows(decodeSubagentActivationPayload, threadId)(subagentActivationRows), decodeRows(decodeProviderSessionPayload, threadId)(providerSessionRows), decodeRows(decodeProviderThreadPayload, threadId)(providerThreadRows), decodeRows(decodeProviderTurnPayload, threadId)(providerTurnRows), @@ -1967,6 +2035,7 @@ export const layer: Layer.Layer = attempts, nodes, subagents, + subagentActivations, providerSessions, providerThreads, providerTurns, diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index d8f3595bcf3..c138bcc6b0d 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -15,6 +15,7 @@ import { OrchestrationV2ProviderTurn, OrchestrationV2RuntimeRequest, OrchestrationV2Subagent, + OrchestrationV2SubagentActivation, OrchestrationV2TurnItem, ProviderApprovalDecision, ProviderInteractionMode, @@ -103,6 +104,11 @@ export const ProviderAdapterV2Event = Schema.Union([ driver: ProviderDriverKind, subagent: OrchestrationV2Subagent, }), + Schema.Struct({ + type: Schema.Literal("subagent_activation.updated"), + driver: ProviderDriverKind, + activation: OrchestrationV2SubagentActivation, + }), Schema.Struct({ type: Schema.Literal("message.updated"), driver: ProviderDriverKind, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 91e4a974390..8036f273406 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -191,6 +191,16 @@ export const layer: Layer.Layer event.type === "subagent.updated"); assert.isDefined(terminalSubagent); if (terminalSubagent?.type !== "subagent.updated") { @@ -1376,6 +1415,25 @@ it.effect("cascade helper is provider-neutral for Claude and Codex-shaped child assert.equal(terminalSubagent.payload.progress, "partial progress"); assert.equal(terminalSubagent.payload.result, "partial result"); assert.equal(terminalSubagent.payload.driver, driverKind); + assert.isNull(terminalSubagent.payload.currentActivationId); + + const terminalActivations = events.filter( + ( + event, + ): event is Extract => + event.type === "subagent-activation.updated", + ); + assert.deepEqual( + terminalActivations.map((event) => ({ + id: event.payload.id, + status: event.payload.status, + totalTokens: event.payload.usage?.totalTokens, + })), + [ + { id: previousActivationId, status: terminalStatus, totalTokens: 60 }, + { id: activationId, status: terminalStatus, totalTokens: 120 }, + ], + ); const terminalItem = events.find( (event) => event.type === "turn-item.updated" && event.payload.type === "subagent", @@ -1401,6 +1459,7 @@ it.effect("cascade helper is provider-neutral for Claude and Codex-shaped child } as OrchestrationV2Run, open: { subagents: new Map(), + activations: new Map(), turnItems: new Map(), childTurnItems: new Map([[childTurnItem.id, childTurnItem]]), nodes: new Map([[childNodeId, openChildNode]]), @@ -1793,8 +1852,16 @@ function makeRunOwnedSubagentFixture(input: { prompt: "hold", title: "Live-test subagent hold", model: null, + kind: "subagent", + role: { name: "general-purpose", source: "app_default" }, status: input.status, result: null, + usage: null, + currentActivationId: null, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], startedAt: now, completedAt: null, updatedAt: now, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a3d72b065b7..298f8058afb 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -13,11 +13,13 @@ import { type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, type OrchestrationV2TurnItem, type ProviderSessionId, type ProviderThreadId, type ProviderTurnId, type RunAttemptId, + type SubagentActivationId, type ThreadId, type TurnItemId, } from "@t3tools/contracts"; @@ -75,6 +77,7 @@ function isTerminalProviderTurnStatus(status: OrchestrationV2ProviderTurn["statu function isTerminalSubagentStatus(status: OrchestrationV2Subagent["status"]): boolean { return ( + status === "idle" || status === "completed" || status === "interrupted" || status === "failed" || @@ -94,6 +97,7 @@ const backgroundCapableTurnItemTypes: ReadonlySet; + readonly activations: ReadonlyMap; readonly turnItems: ReadonlyMap; readonly childTurnItems: ReadonlyMap; readonly nodes: ReadonlyMap; @@ -134,6 +139,7 @@ export function canRouteRelatedSubagent(status: OrchestrationV2Subagent["status" function emptyOpenRunOwnedSubagentProjection(): OpenRunOwnedSubagentProjection { return { subagents: new Map(), + activations: new Map(), turnItems: new Map(), childTurnItems: new Map(), nodes: new Map(), @@ -190,6 +196,7 @@ export function cascadeTerminalizeRunOwnedSubagents(input: { payload: { ...subagent, status: input.status, + currentActivationId: null, completedAt: input.completedAt, updatedAt: input.completedAt, }, @@ -240,6 +247,31 @@ export function cascadeTerminalizeRunOwnedSubagents(input: { }); } } + for (const activation of input.open.activations.values()) { + if ( + activation.status === "completed" || + activation.status === "interrupted" || + activation.status === "failed" || + activation.status === "cancelled" + ) { + continue; + } + events.push({ + id: yield* input.allocateEventId(), + type: "subagent-activation.updated", + threadId: activation.threadId, + runId: activation.runId ?? input.run.id, + nodeId: activation.subagentId, + providerInstanceId: input.run.providerInstanceId, + occurredAt: input.completedAt, + payload: { + ...activation, + status: input.status, + completedAt: input.completedAt, + updatedAt: input.completedAt, + }, + }); + } for (const turnItem of input.open.childTurnItems.values()) { if (!childThreadIds.has(turnItem.threadId) || isTerminalTurnItemStatus(turnItem.status)) { continue; @@ -360,6 +392,8 @@ export function routeProviderEvent( } case "subagent.updated": return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state]; + case "subagent_activation.updated": + return [ownsRun(event.activation.runId) || ownsChildThread(event.activation.threadId), state]; case "message.updated": return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state]; case "turn_item.updated": @@ -548,6 +582,7 @@ export const layer: Layer.Layer< const open = input.openRunOwnedSubagents ?? emptyOpenRunOwnedSubagentProjection(); const hasOpenSubagentProjection = open.subagents.size > 0 || + open.activations.size > 0 || open.turnItems.size > 0 || open.childTurnItems.size > 0 || open.nodes.size > 0; @@ -865,6 +900,29 @@ export const layer: Layer.Layer< }); } } + if (event.type === "subagent_activation.updated") { + const belongsToRootRun = event.activation.runId === input.run.id; + const belongsToOwnedChildThread = + event.activation.threadId !== input.run.threadId && + routing.ownedThreadIds.has(event.activation.threadId); + if (!belongsToRootRun && !belongsToOwnedChildThread) { + return; + } + yield* Ref.update(openRunOwnedSubagents, (current) => { + const activations = new Map(current.activations); + if ( + event.activation.status === "completed" || + event.activation.status === "interrupted" || + event.activation.status === "failed" || + event.activation.status === "cancelled" + ) { + activations.delete(event.activation.id); + } else { + activations.set(event.activation.id, event.activation); + } + return { ...current, activations }; + }); + } if (event.type === "node.updated") { const belongsToRootSubagent = event.node.kind === "subagent" && event.node.runId === input.run.id; diff --git a/apps/server/src/orchestration-v2/SubagentObservability.test.ts b/apps/server/src/orchestration-v2/SubagentObservability.test.ts new file mode 100644 index 00000000000..eeb8416ae8a --- /dev/null +++ b/apps/server/src/orchestration-v2/SubagentObservability.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + accumulateSubagentUsage, + appendSubagentActivity, + defaultSubagentRole, + mergeSubagentUsage, + providerSubagentRole, +} from "./SubagentObservability.ts"; +import * as DateTime from "effect/DateTime"; + +describe("subagent observability", () => { + it("preserves role provenance instead of using titles as roles", () => { + expect(providerSubagentRole(" reviewer ")).toEqual({ + name: "reviewer", + source: "provider", + }); + expect(providerSubagentRole(undefined)).toEqual(defaultSubagentRole()); + }); + + it("accumulates activation deltas into stable lifetime usage", () => { + const first = accumulateSubagentUsage(null, null, { + totalTokens: 100, + inputTokens: 70, + outputTokens: 30, + toolUses: 2, + }); + const firstAdvanced = accumulateSubagentUsage( + first, + { + totalTokens: 100, + inputTokens: 70, + outputTokens: 30, + toolUses: 2, + }, + { + totalTokens: 140, + inputTokens: 95, + outputTokens: 45, + toolUses: 3, + }, + ); + const resumed = accumulateSubagentUsage(firstAdvanced, null, { + totalTokens: 80, + inputTokens: 50, + outputTokens: 30, + toolUses: 1, + }); + + expect(firstAdvanced).toEqual({ + totalTokens: 140, + inputTokens: 95, + outputTokens: 45, + toolUses: 3, + }); + expect(resumed).toEqual({ + totalTokens: 220, + inputTokens: 145, + outputTokens: 75, + toolUses: 4, + }); + }); + + it("bounds recent activity and deduplicates adjacent updates", () => { + const now = DateTime.makeUnsafe("2026-07-26T00:00:00.000Z"); + const activity = Array.from({ length: 8 }, (_, index) => `step ${index}`).reduce( + (current, summary) => appendSubagentActivity(current, summary, now), + appendSubagentActivity([], "step 0", now), + ); + + expect(activity).toHaveLength(6); + expect(activity.map((entry) => entry.summary)).toEqual([ + "step 2", + "step 3", + "step 4", + "step 5", + "step 6", + "step 7", + ]); + expect(appendSubagentActivity([], " ", now)).toEqual([]); + }); + + it("merges cumulative provider counters without double-counting", () => { + expect( + mergeSubagentUsage( + { totalTokens: 100, outputTokens: 25 }, + { totalTokens: 140, outputTokens: 20 }, + ), + ).toEqual({ totalTokens: 140, outputTokens: 25 }); + }); + + it("preserves omitted activation counters when they reappear", () => { + const firstActivation = { totalTokens: 100, inputTokens: 70 }; + const withoutInput = mergeSubagentUsage(firstActivation, { totalTokens: 140 }); + const lifetimeWithoutInput = accumulateSubagentUsage(firstActivation, firstActivation, { + totalTokens: 140, + }); + const restoredInput = mergeSubagentUsage(withoutInput, { + totalTokens: 180, + inputTokens: 100, + }); + + expect(withoutInput).toEqual({ totalTokens: 140, inputTokens: 70 }); + expect(accumulateSubagentUsage(lifetimeWithoutInput, withoutInput, restoredInput)).toEqual({ + totalTokens: 180, + inputTokens: 100, + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/SubagentObservability.ts b/apps/server/src/orchestration-v2/SubagentObservability.ts new file mode 100644 index 00000000000..e8a868cda52 --- /dev/null +++ b/apps/server/src/orchestration-v2/SubagentObservability.ts @@ -0,0 +1,163 @@ +import { + SubagentActivationId, + type NodeId, + type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivity, + type OrchestrationV2SubagentRole, + type OrchestrationV2SubagentUsage, +} from "@t3tools/contracts"; +import type * as DateTime from "effect/DateTime"; + +export const SUBAGENT_RECENT_ACTIVITY_LIMIT = 6; +const SUBAGENT_ACTIVITY_TEXT_LIMIT = 180; + +export const defaultSubagentRole = (name = "general-purpose"): OrchestrationV2SubagentRole => ({ + name, + source: "app_default", +}); + +export const providerSubagentRole = ( + name: string | null | undefined, + fallback = "general-purpose", +): OrchestrationV2SubagentRole => { + const normalized = name?.trim(); + return normalized ? { name: normalized, source: "provider" } : defaultSubagentRole(fallback); +}; + +export const subagentActivationId = (subagentId: NodeId, ordinal: number) => + SubagentActivationId.make(`${subagentId}:activation:${ordinal}`); + +const boundedActivityText = (value: string) => { + const normalized = value.trim(); + return normalized.length > SUBAGENT_ACTIVITY_TEXT_LIMIT + ? `${normalized.slice(0, SUBAGENT_ACTIVITY_TEXT_LIMIT - 1)}…` + : normalized; +}; + +export const appendSubagentActivity = ( + current: OrchestrationV2Subagent["recentActivity"], + summary: string | null | undefined, + at: DateTime.Utc, +): ReadonlyArray => { + if (!summary?.trim()) return current; + const bounded = boundedActivityText(summary); + if (current.at(-1)?.summary === bounded) return current; + return [...current, { at, summary: bounded }].slice(-SUBAGENT_RECENT_ACTIVITY_LIMIT); +}; + +const cumulative = (previous: number | undefined, next: number | undefined) => + next === undefined ? previous : Math.max(previous ?? 0, next); + +export const mergeSubagentUsage = ( + previous: OrchestrationV2SubagentUsage | null, + next: OrchestrationV2SubagentUsage | null | undefined, +): OrchestrationV2SubagentUsage | null => { + if (next === undefined || next === null) return previous; + return { + totalTokens: Math.max(previous?.totalTokens ?? 0, next.totalTokens), + ...(cumulative(previous?.inputTokens, next.inputTokens) === undefined + ? {} + : { inputTokens: cumulative(previous?.inputTokens, next.inputTokens) }), + ...(cumulative(previous?.cachedInputTokens, next.cachedInputTokens) === undefined + ? {} + : { cachedInputTokens: cumulative(previous?.cachedInputTokens, next.cachedInputTokens) }), + ...(cumulative(previous?.outputTokens, next.outputTokens) === undefined + ? {} + : { outputTokens: cumulative(previous?.outputTokens, next.outputTokens) }), + ...(cumulative(previous?.reasoningOutputTokens, next.reasoningOutputTokens) === undefined + ? {} + : { + reasoningOutputTokens: cumulative( + previous?.reasoningOutputTokens, + next.reasoningOutputTokens, + ), + }), + ...(cumulative(previous?.toolUses, next.toolUses) === undefined + ? {} + : { toolUses: cumulative(previous?.toolUses, next.toolUses) }), + ...(cumulative(previous?.durationMs, next.durationMs) === undefined + ? {} + : { durationMs: cumulative(previous?.durationMs, next.durationMs) }), + }; +}; + +const accumulated = ( + previous: number | undefined, + previousActivation: number | undefined, + next: number | undefined, +) => + next === undefined ? previous : (previous ?? 0) + Math.max(0, next - (previousActivation ?? 0)); + +export const accumulateSubagentUsage = ( + previous: OrchestrationV2SubagentUsage | null, + previousActivation: OrchestrationV2SubagentUsage | null | undefined, + next: OrchestrationV2SubagentUsage | null | undefined, +): OrchestrationV2SubagentUsage | null => { + if (next === undefined || next === null) return previous; + return { + totalTokens: + (previous?.totalTokens ?? 0) + + Math.max(0, next.totalTokens - (previousActivation?.totalTokens ?? 0)), + ...(accumulated(previous?.inputTokens, previousActivation?.inputTokens, next.inputTokens) === + undefined + ? {} + : { + inputTokens: accumulated( + previous?.inputTokens, + previousActivation?.inputTokens, + next.inputTokens, + ), + }), + ...(accumulated( + previous?.cachedInputTokens, + previousActivation?.cachedInputTokens, + next.cachedInputTokens, + ) === undefined + ? {} + : { + cachedInputTokens: accumulated( + previous?.cachedInputTokens, + previousActivation?.cachedInputTokens, + next.cachedInputTokens, + ), + }), + ...(accumulated(previous?.outputTokens, previousActivation?.outputTokens, next.outputTokens) === + undefined + ? {} + : { + outputTokens: accumulated( + previous?.outputTokens, + previousActivation?.outputTokens, + next.outputTokens, + ), + }), + ...(accumulated( + previous?.reasoningOutputTokens, + previousActivation?.reasoningOutputTokens, + next.reasoningOutputTokens, + ) === undefined + ? {} + : { + reasoningOutputTokens: accumulated( + previous?.reasoningOutputTokens, + previousActivation?.reasoningOutputTokens, + next.reasoningOutputTokens, + ), + }), + ...(accumulated(previous?.toolUses, previousActivation?.toolUses, next.toolUses) === undefined + ? {} + : { + toolUses: accumulated(previous?.toolUses, previousActivation?.toolUses, next.toolUses), + }), + ...(accumulated(previous?.durationMs, previousActivation?.durationMs, next.durationMs) === + undefined + ? {} + : { + durationMs: accumulated( + previous?.durationMs, + previousActivation?.durationMs, + next.durationMs, + ), + }), + }; +}; diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index 410ec53b576..ab49188f8d1 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -92,6 +92,7 @@ it.effect("keeps a fork awake when its source thread is snoozed", () => attempts: [], nodes: [], subagents: [], + subagentActivations: [], providerSessions: [], providerThreads: [], providerTurns: [], diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a5af2690dc3..b53fb7bcd91 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -55,6 +55,7 @@ import Migration0039 from "./Migrations/039_OrchestrationV2ThreadLaunchWorkflows import Migration0040 from "./Migrations/040_ApplicationEventSource.ts"; import Migration0041 from "./Migrations/041_OrchestrationV2EffectCancellation.ts"; import Migration0042 from "./Migrations/042_ScheduledTasks.ts"; +import Migration0043 from "./Migrations/043_OrchestrationV2SubagentObservability.ts"; /** * Migration loader with all migrations defined inline. @@ -109,6 +110,7 @@ export const migrationEntries = [ [40, "ApplicationEventSource", Migration0040], [41, "OrchestrationV2EffectCancellation", Migration0041], [42, "ScheduledTasks", Migration0042], + [43, "OrchestrationV2SubagentObservability", Migration0043], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts index 43a46eef551..672285992af 100644 --- a/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("035_036_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 42 }, (_, index) => index + 1), + Array.from({ length: 43 }, (_, index) => index + 1), ); }), ); @@ -122,7 +122,7 @@ it.effect("upgrades a database already at released main migration 034", () => assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until")); assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at")); - yield* runMigrations({ toMigrationInclusive: 42 }); + yield* runMigrations({ toMigrationInclusive: 43 }); const migrations = yield* sql<{ readonly migration_id: number; @@ -130,7 +130,7 @@ it.effect("upgrades a database already at released main migration 034", () => }>` SELECT migration_id, name FROM effect_sql_migrations - WHERE migration_id BETWEEN 34 AND 42 + WHERE migration_id BETWEEN 34 AND 43 ORDER BY migration_id `; assert.deepStrictEqual( @@ -145,6 +145,7 @@ it.effect("upgrades a database already at released main migration 034", () => [40, "ApplicationEventSource"], [41, "OrchestrationV2EffectCancellation"], [42, "ScheduledTasks"], + [43, "OrchestrationV2SubagentObservability"], ], ); diff --git a/apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.test.ts b/apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.test.ts new file mode 100644 index 00000000000..577cdc65796 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.test.ts @@ -0,0 +1,151 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("043_OrchestrationV2SubagentObservability", (it) => { + it.effect("backfills old subagents and installs activation persistence", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 42 }); + yield* sql` + INSERT INTO orchestration_v2_projection_subagents ( + subagent_id, + thread_id, + run_id, + parent_node_id, + provider, + provider_thread_id, + child_thread_id, + origin, + status, + started_at, + completed_at, + updated_at, + payload_json + ) VALUES ( + 'node:legacy-subagent', + 'thread:legacy-subagent', + 'run:legacy-subagent', + 'node:legacy-root', + 'codex', + NULL, + NULL, + 'provider_native', + 'completed', + '2026-07-26T00:00:00.000Z', + '2026-07-26T00:01:00.000Z', + '2026-07-26T00:01:00.000Z', + '{"id":"node:legacy-subagent","status":"completed"}' + ) + `; + yield* sql` + INSERT INTO orchestration_v2_projection_subagents ( + subagent_id, + thread_id, + run_id, + parent_node_id, + provider, + provider_thread_id, + child_thread_id, + origin, + status, + started_at, + completed_at, + updated_at, + payload_json + ) VALUES ( + 'node:current-subagent', + 'thread:current-subagent', + 'run:current-subagent', + 'node:current-root', + 'claudeAgent', + NULL, + NULL, + 'provider_native', + 'running', + '2026-07-26T00:00:00.000Z', + NULL, + '2026-07-26T00:01:00.000Z', + '{"id":"node:current-subagent","status":"running","kind":"workflow_agent","role":{"name":"researcher","source":"provider"},"usage":{"totalTokens":12},"currentActivationId":"activation:current","activationCount":2,"workflow":null,"workflowMembership":null,"recentActivity":[{"at":"2026-07-26T00:00:30.000Z","summary":"kept"}]}' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 43 }); + + const rows = yield* sql<{ + readonly kind: string; + readonly role_name: string; + readonly role_source: string; + readonly activation_count: number; + readonly usage_type: string; + readonly activity_count: number; + }>` + SELECT + json_extract(payload_json, '$.kind') AS kind, + json_extract(payload_json, '$.role.name') AS role_name, + json_extract(payload_json, '$.role.source') AS role_source, + json_extract(payload_json, '$.activationCount') AS activation_count, + json_type(payload_json, '$.usage') AS usage_type, + json_array_length(json_extract(payload_json, '$.recentActivity')) AS activity_count + FROM orchestration_v2_projection_subagents + WHERE subagent_id = 'node:legacy-subagent' + `; + assert.deepStrictEqual(rows, [ + { + kind: "subagent", + role_name: "general-purpose", + role_source: "app_default", + activation_count: 1, + usage_type: "null", + activity_count: 0, + }, + ]); + + const currentRows = yield* sql<{ + readonly kind: string; + readonly role_name: string; + readonly role_source: string; + readonly total_tokens: number; + readonly current_activation_id: string; + readonly activation_count: number; + readonly activity_summary: string; + }>` + SELECT + json_extract(payload_json, '$.kind') AS kind, + json_extract(payload_json, '$.role.name') AS role_name, + json_extract(payload_json, '$.role.source') AS role_source, + json_extract(payload_json, '$.usage.totalTokens') AS total_tokens, + json_extract(payload_json, '$.currentActivationId') AS current_activation_id, + json_extract(payload_json, '$.activationCount') AS activation_count, + json_extract(payload_json, '$.recentActivity[0].summary') AS activity_summary + FROM orchestration_v2_projection_subagents + WHERE subagent_id = 'node:current-subagent' + `; + assert.deepStrictEqual(currentRows, [ + { + kind: "workflow_agent", + role_name: "researcher", + role_source: "provider", + total_tokens: 12, + current_activation_id: "activation:current", + activation_count: 2, + activity_summary: "kept", + }, + ]); + + const activationTable = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'orchestration_v2_projection_subagent_activations' + `; + assert.lengthOf(activationTable, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.ts b/apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.ts new file mode 100644 index 00000000000..215e9515abd --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_OrchestrationV2SubagentObservability.ts @@ -0,0 +1,50 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE orchestration_v2_projection_subagent_activations ( + activation_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + subagent_id TEXT NOT NULL, + run_id TEXT, + provider_turn_id TEXT, + ordinal INTEGER NOT NULL, + status TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + yield* sql`CREATE INDEX orchestration_v2_projection_subagent_activations_thread_idx ON orchestration_v2_projection_subagent_activations(thread_id, subagent_id, ordinal)`; + yield* sql`CREATE INDEX orchestration_v2_projection_subagent_activations_run_idx ON orchestration_v2_projection_subagent_activations(run_id, status)`; + + yield* sql` + UPDATE orchestration_v2_projection_subagents + SET payload_json = json_set( + payload_json, + '$.kind', COALESCE(json_extract(payload_json, '$.kind'), 'subagent'), + '$.role', COALESCE( + json_extract(payload_json, '$.role'), + json_object('name', 'general-purpose', 'source', 'app_default') + ), + '$.usage', json_extract(payload_json, '$.usage'), + '$.currentActivationId', json_extract(payload_json, '$.currentActivationId'), + '$.activationCount', COALESCE(json_extract(payload_json, '$.activationCount'), 1), + '$.workflow', json_extract(payload_json, '$.workflow'), + '$.workflowMembership', json_extract(payload_json, '$.workflowMembership'), + '$.recentActivity', COALESCE(json_extract(payload_json, '$.recentActivity'), json_array()) + ) + WHERE json_type(payload_json, '$.kind') IS NULL + OR json_type(payload_json, '$.role') IS NULL + OR json_type(payload_json, '$.usage') IS NULL + OR json_type(payload_json, '$.currentActivationId') IS NULL + OR json_type(payload_json, '$.activationCount') IS NULL + OR json_type(payload_json, '$.workflow') IS NULL + OR json_type(payload_json, '$.workflowMembership') IS NULL + OR json_type(payload_json, '$.recentActivity') IS NULL + `; +}); diff --git a/apps/web/src/components/AgentsPanelV2.tsx b/apps/web/src/components/AgentsPanelV2.tsx new file mode 100644 index 00000000000..a9af3175e0f --- /dev/null +++ b/apps/web/src/components/AgentsPanelV2.tsx @@ -0,0 +1,247 @@ +import { + deriveOrchestrationV2SubagentPanelState, + formatSubagentTokenCount, +} from "@t3tools/client-runtime/state/orchestration-v2-subagents"; +import type { + OrchestrationV2Subagent, + OrchestrationV2SubagentActivation, + OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import { + ActivityIcon, + BotIcon, + ChevronDownIcon, + CircleDotIcon, + CoinsIcon, + GitBranchIcon, + WorkflowIcon, +} from "lucide-react"; +import { useMemo } from "react"; +import * as DateTime from "effect/DateTime"; + +import { cn } from "~/lib/utils"; +import { Badge } from "./ui/badge"; +import { ScrollArea } from "./ui/scroll-area"; + +const statusTone = (status: OrchestrationV2Subagent["status"]) => + status === "failed" + ? "bg-destructive" + : status === "running" + ? "bg-emerald-500" + : status === "pending" || status === "waiting" + ? "bg-amber-500" + : status === "idle" + ? "bg-sky-500" + : "bg-muted-foreground/50"; + +const usageSummary = (agent: OrchestrationV2Subagent) => { + if (agent.usage === null) return null; + return [ + `${formatSubagentTokenCount(agent.usage.totalTokens)} tokens`, + agent.usage.toolUses === undefined ? null : `${agent.usage.toolUses} tools`, + ] + .filter((value): value is string => value !== null) + .join(" · "); +}; + +const activationLabel = (activation: OrchestrationV2SubagentActivation) => { + const usage = + activation.usage === null + ? null + : `${formatSubagentTokenCount(activation.usage.totalTokens)} tokens`; + return [`Run ${activation.ordinal}`, activation.status, usage] + .filter((value): value is string => value !== null) + .join(" · "); +}; + +const keyedActivities = (agent: OrchestrationV2Subagent) => { + const occurrences = new Map(); + return agent.recentActivity.map((activity) => { + const base = `${DateTime.formatIso(activity.at)}:${activity.summary}`; + const occurrence = (occurrences.get(base) ?? 0) + 1; + occurrences.set(base, occurrence); + return { activity, key: `${base}:${occurrence}` }; + }); +}; + +function AgentCard(props: { + agent: OrchestrationV2Subagent; + activations: ReadonlyArray; +}) { + const { agent } = props; + const detail = agent.progress?.trim() || agent.result?.trim() || agent.prompt.trim(); + const usage = usageSummary(agent); + const activities = keyedActivities(agent); + return ( +
+
+ +
+
+ +

+ {agent.title?.trim() || agent.role.name} +

+ + {agent.status} + +
+
+ + {agent.role.name} + + {agent.role.source === "app_default" ? ( + + default role + + ) : null} + {agent.model === null ? null : ( + + {agent.model} + + )} +
+ {detail.length > 0 ? ( +

+ {detail} +

+ ) : null} +
+ {usage === null ? null : ( + + + {usage} + + )} + + + {agent.activationCount} {agent.activationCount === 1 ? "run" : "runs"} + +
+
+
+ {agent.recentActivity.length > 0 || props.activations.length > 0 ? ( +
+ + + Activity and runs + +
+ {activities.map(({ activity, key }) => ( +
+ + {activity.summary} +
+ ))} + {props.activations.map((activation) => ( +
+ + {activationLabel(activation)} +
+ ))} +
+
+ ) : null} +
+ ); +} + +export function AgentsPanelV2(props: { projection: OrchestrationV2ThreadProjection | null }) { + const panel = useMemo( + () => + props.projection === null + ? null + : deriveOrchestrationV2SubagentPanelState({ + subagents: props.projection.subagents, + activations: props.projection.subagentActivations, + }), + [props.projection], + ); + + if (panel === null || panel.groups.length === 0) { + return ( +
+
+ +

No agents yet

+

+ Provider-native and delegated agents will appear here with their usage, roles, runs, and + recent activity. +

+
+
+ ); + } + + return ( +
+
+
+ +

Agents

+ + {formatSubagentTokenCount(panel.totalTokens)} tokens + +
+
+ {panel.activeCount} active + {panel.waitingCount} waiting + {panel.settledCount} settled +
+
+ +
+ {panel.groups.map((group, groupIndex) => ( +
+ {group.workflow === null ? null : ( +
+ +

+ {group.workflow.title?.trim() || "Workflow"} +

+ + {group.workflow.status} + +
+ )} + {group.phases.map((phase) => ( +
+
+ {phase.index + 1} + {phase.title} + {phase.status} +
+ {phase.agents.map((agent) => ( + + ))} +
+ ))} + {group.agents.map((agent) => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6a06e654f46..a39df665352 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -403,6 +403,9 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const AgentsPanelV2 = lazy(() => + import("./AgentsPanelV2").then((module) => ({ default: module.AgentsPanelV2 })), +); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -3083,6 +3086,10 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen, planSidebarOpen, ]); + const addAgentsSurface = useCallback(() => { + if (!activeThreadRef) return; + useRightPanelStore.getState().open(activeThreadRef, "agents"); + }, [activeThreadRef]); const openChangesFromThreadPanel = useCallback(() => { addDiffSurface(); }, [addDiffSurface]); @@ -5771,6 +5778,10 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> + ) : activeRightPanelSurface?.kind === "agents" ? ( + + + ) : activeRightPanelSurface?.kind === "plan" ? ( void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; + onAddAgents: () => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -90,6 +100,7 @@ function SurfaceMenuItem(props: { function RightPanelEmptyState(props: { onAddBrowser: () => void; + onAddAgents: () => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -98,6 +109,14 @@ function RightPanelEmptyState(props: { filesAvailable: boolean; }) { const actions = [ + { + label: "Agents", + description: "Inspect agent roles, runs, usage, and activity.", + icon: BotIcon, + available: true, + disabledReason: null, + onClick: props.onAddAgents, + }, { label: "Browser", description: "Open a local app or URL.", @@ -194,6 +213,8 @@ function surfaceTitle( terminalLabelsById: ReadonlyMap, ): string { switch (surface.kind) { + case "agents": + return "Agents"; case "diff": return "Diff"; case "files": @@ -246,6 +267,8 @@ function SurfaceIcon({ theme: "light" | "dark"; }) { switch (surface.kind) { + case "agents": + return ; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; @@ -445,6 +468,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { + + + Agents + ([ + "idle", "completed", "failed", ]); @@ -39,6 +40,7 @@ const FINAL_RESULT_SUBAGENT_STATUSES = new Set([ + "idle", "completed", "failed", "cancelled", diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 1d7af9a1106..b20829cb0aa 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -15,7 +15,15 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; import type { ThreadPanelPresentation } from "./rightPanelLayout"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; +export const RIGHT_PANEL_KINDS = [ + "plan", + "agents", + "diff", + "files", + "file", + "preview", + "terminal", +] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -30,6 +38,7 @@ export type RightPanelSurface = splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } + | { id: "agents"; kind: "agents" } | { id: "files"; kind: "files" } | { id: `file:${string}`; @@ -104,6 +113,8 @@ const singletonSurface = ( kind: Exclude, ): RightPanelSurface => { switch (kind) { + case "agents": + return { id: "agents", kind }; case "diff": return { id: "diff", kind }; case "files": diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a70dbecfe55..d22b36b0781 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -354,6 +354,7 @@ function projectedWorkEntryStatus( case "running": case "waiting": return "inProgress"; + case "idle": case "completed": return "completed"; case "failed": diff --git a/apps/web/src/test-fixtures.ts b/apps/web/src/test-fixtures.ts index f4804690b82..cb0b87da307 100644 --- a/apps/web/src/test-fixtures.ts +++ b/apps/web/src/test-fixtures.ts @@ -50,6 +50,7 @@ export function makeThreadProjectionFixture(): OrchestrationV2ThreadProjection { attempts: [], nodes: [], subagents: [], + subagentActivations: [], providerSessions: [], providerThreads: [], providerTurns: [], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d6dd2120307..d4a82e25962 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -79,6 +79,10 @@ "types": "./src/state/orchestrationV2Projection.ts", "default": "./src/state/orchestrationV2Projection.ts" }, + "./state/orchestration-v2-subagents": { + "types": "./src/state/orchestrationV2Subagents.ts", + "default": "./src/state/orchestrationV2Subagents.ts" + }, "./state/presentation": { "types": "./src/state/presentation.ts", "default": "./src/state/presentation.ts" diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index f804bd64761..07cf08f383f 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -85,6 +85,7 @@ const emptyProjection = { attempts: [], nodes: [], subagents: [], + subagentActivations: [], providerSessions: [], providerThreads: [], providerTurns: [], diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index db49437cf69..b89ee920a97 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -125,6 +125,11 @@ export function applyOrchestrationV2ProjectionEvent( return { ...base, nodes: upsertEntity(base.nodes, event.payload) }; case "subagent.updated": return { ...base, subagents: upsertEntity(base.subagents, event.payload) }; + case "subagent-activation.updated": + return { + ...base, + subagentActivations: upsertEntity(base.subagentActivations, event.payload), + }; case "provider-session.attached": case "provider-session.updated": return { diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts new file mode 100644 index 00000000000..1965e3653bb --- /dev/null +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts @@ -0,0 +1,146 @@ +import { + NodeId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + SubagentActivationId, + ThreadId, + type OrchestrationV2Subagent, + type OrchestrationV2SubagentActivation, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + deriveOrchestrationV2SubagentPanelState, + formatSubagentTokenCount, +} from "./orchestrationV2Subagents.ts"; + +const now = DateTime.makeUnsafe("2026-07-26T00:00:00.000Z"); +const threadId = ThreadId.make("thread-agents"); +const runId = RunId.make("run-agents"); +const workflowId = NodeId.make("workflow-1"); + +const agent = ( + id: string, + input: Partial = {}, +): OrchestrationV2Subagent => ({ + id: NodeId.make(id), + threadId, + runId, + parentNodeId: NodeId.make("root-node"), + origin: "provider_native", + createdBy: "agent", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Work", + title: id, + model: "claude-opus-4-1", + kind: "subagent", + role: { name: "general-purpose", source: "provider" }, + status: "running", + result: null, + usage: null, + currentActivationId: null, + activationCount: 1, + workflow: null, + workflowMembership: null, + recentActivity: [], + startedAt: now, + completedAt: null, + updatedAt: now, + ...input, +}); + +const activation = ( + id: string, + subagentId: OrchestrationV2Subagent["id"], + ordinal: number, +): OrchestrationV2SubagentActivation => ({ + id: SubagentActivationId.make(id), + threadId, + subagentId, + runId, + providerTurnId: null, + ordinal, + status: "completed", + usage: { totalTokens: ordinal * 100 }, + startedAt: now, + completedAt: now, + updatedAt: now, +}); + +describe("deriveOrchestrationV2SubagentPanelState", () => { + it("groups workflow agents by phase without double-counting workflow usage", () => { + const workflow = agent("workflow-1", { + kind: "workflow", + role: { name: "workflow-coordinator", source: "app_default" }, + status: "completed", + usage: { totalTokens: 900 }, + workflow: { + phases: [ + { index: 0, title: "Research" }, + { index: 1, title: "Implement" }, + ], + }, + }); + const researcher = agent("researcher", { + kind: "workflow_agent", + usage: { totalTokens: 400 }, + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 1, + phaseIndex: 0, + attempt: 1, + }, + }); + const auditor = agent("auditor", { + kind: "workflow_agent", + status: "completed", + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 0, + phaseIndex: 0, + attempt: 1, + }, + }); + const implementer = agent("implementer", { + kind: "workflow_agent", + status: "idle", + usage: { totalTokens: 500 }, + workflowMembership: { + workflowSubagentId: workflowId, + agentIndex: 1, + phaseIndex: 1, + attempt: 1, + }, + }); + const activations = [ + activation("activation-researcher-1", researcher.id, 1), + activation("activation-researcher-2", researcher.id, 2), + ]; + + const result = deriveOrchestrationV2SubagentPanelState({ + subagents: [workflow, researcher, auditor, implementer], + activations, + }); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0]?.phases.map((phase) => phase.title)).toEqual(["Research", "Implement"]); + expect(result.groups[0]?.phases[0]?.agents).toEqual([auditor, researcher]); + expect(result.activeCount).toBe(1); + expect(result.settledCount).toBe(2); + expect(result.totalTokens).toBe(900); + expect(result.activationsBySubagentId.get(researcher.id)).toEqual(activations); + }); + + it("formats compact token counts", () => { + expect(formatSubagentTokenCount(999)).toBe("999"); + expect(formatSubagentTokenCount(1_200)).toBe("1.2k"); + expect(formatSubagentTokenCount(999_999)).toBe("1M"); + expect(formatSubagentTokenCount(2_000_000)).toBe("2M"); + }); +}); diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.ts new file mode 100644 index 00000000000..baff921c6d1 --- /dev/null +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.ts @@ -0,0 +1,127 @@ +import type { + OrchestrationV2Subagent, + OrchestrationV2SubagentActivation, +} from "@t3tools/contracts"; + +export interface OrchestrationV2SubagentPhaseGroup { + readonly index: number; + readonly title: string; + readonly status: "pending" | "running" | "done"; + readonly agents: ReadonlyArray; +} + +export interface OrchestrationV2SubagentGroup { + readonly workflow: OrchestrationV2Subagent | null; + readonly phases: ReadonlyArray; + readonly agents: ReadonlyArray; +} + +export interface OrchestrationV2SubagentPanelState { + readonly groups: ReadonlyArray; + readonly activationsBySubagentId: ReadonlyMap< + string, + ReadonlyArray + >; + readonly activeCount: number; + readonly waitingCount: number; + readonly settledCount: number; + readonly totalTokens: number; +} + +export const isSettledOrchestrationV2Subagent = (agent: OrchestrationV2Subagent) => + agent.status === "idle" || + agent.status === "completed" || + agent.status === "failed" || + agent.status === "cancelled" || + agent.status === "interrupted"; + +const phaseStatus = (agents: ReadonlyArray) => + agents.length === 0 + ? ("pending" as const) + : agents.every(isSettledOrchestrationV2Subagent) + ? ("done" as const) + : ("running" as const); + +export function deriveOrchestrationV2SubagentPanelState(input: { + readonly subagents: ReadonlyArray; + readonly activations: ReadonlyArray; +}): OrchestrationV2SubagentPanelState { + const activationsBySubagentId = new Map(); + for (const activation of input.activations) { + const current = activationsBySubagentId.get(activation.subagentId) ?? []; + current.push(activation); + activationsBySubagentId.set(activation.subagentId, current); + } + for (const activations of activationsBySubagentId.values()) { + activations.sort((left, right) => left.ordinal - right.ordinal); + } + + const workflows = input.subagents.filter((agent) => agent.kind === "workflow"); + const membersByWorkflow = new Map(); + const direct: OrchestrationV2Subagent[] = []; + for (const agent of input.subagents) { + if (agent.kind === "workflow") continue; + const workflowId = agent.workflowMembership?.workflowSubagentId; + if (workflowId === undefined) { + direct.push(agent); + continue; + } + const members = membersByWorkflow.get(workflowId) ?? []; + members.push(agent); + membersByWorkflow.set(workflowId, members); + } + + const groups: OrchestrationV2SubagentGroup[] = workflows.map((workflow) => { + const members = membersByWorkflow.get(workflow.id) ?? []; + membersByWorkflow.delete(workflow.id); + const phases = (workflow.workflow?.phases ?? []).map((phase) => { + const agents = members + .filter((agent) => agent.workflowMembership?.phaseIndex === phase.index) + .toSorted( + (left, right) => + (left.workflowMembership?.agentIndex ?? 0) - + (right.workflowMembership?.agentIndex ?? 0), + ); + return { ...phase, status: phaseStatus(agents), agents }; + }); + const phasedIds = new Set(phases.flatMap((phase) => phase.agents.map((agent) => agent.id))); + return { + workflow, + phases, + agents: members.filter((agent) => !phasedIds.has(agent.id)), + }; + }); + for (const orphaned of membersByWorkflow.values()) direct.push(...orphaned); + if (direct.length > 0) groups.push({ workflow: null, phases: [], agents: direct }); + + const workers = input.subagents.filter((agent) => agent.kind !== "workflow"); + const workflowIdsWithMembers = new Set( + workers.flatMap((agent) => { + const workflowId = agent.workflowMembership?.workflowSubagentId; + return workflowId === undefined ? [] : [workflowId]; + }), + ); + return { + groups, + activationsBySubagentId, + activeCount: workers.filter((agent) => agent.status === "pending" || agent.status === "running") + .length, + waitingCount: workers.filter((agent) => agent.status === "waiting").length, + settledCount: workers.filter(isSettledOrchestrationV2Subagent).length, + totalTokens: input.subagents.reduce( + (total, agent) => + total + + (agent.kind === "workflow" && workflowIdsWithMembers.has(agent.id) + ? 0 + : (agent.usage?.totalTokens ?? 0)), + 0, + ), + }; +} + +export const formatSubagentTokenCount = (totalTokens: number) => + totalTokens >= 999_500 + ? `${(totalTokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M` + : totalTokens >= 1_000 + ? `${(totalTokens / 1_000).toFixed(1).replace(/\.0$/, "")}k` + : String(totalTokens); diff --git a/packages/client-runtime/src/state/orchestrationV2TestFixtures.ts b/packages/client-runtime/src/state/orchestrationV2TestFixtures.ts index 3907c395b42..250cfc53264 100644 --- a/packages/client-runtime/src/state/orchestrationV2TestFixtures.ts +++ b/packages/client-runtime/src/state/orchestrationV2TestFixtures.ts @@ -84,6 +84,7 @@ export const v2Projection: OrchestrationV2ThreadProjection = { attempts: [], nodes: [], subagents: [], + subagentActivations: [], providerSessions: [], providerThreads: [], providerTurns: [], diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 2a1764d82ce..13248712ef8 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -47,6 +47,8 @@ export const RunAttemptId = makeEntityId("RunAttemptId"); export type RunAttemptId = typeof RunAttemptId.Type; export const NodeId = makeEntityId("NodeId"); export type NodeId = typeof NodeId.Type; +export const SubagentActivationId = makeEntityId("SubagentActivationId"); +export type SubagentActivationId = typeof SubagentActivationId.Type; export const AuthSessionId = makeEntityId("AuthSessionId"); export type AuthSessionId = typeof AuthSessionId.Type; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 8c4ed1d6495..ffba2e80f59 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -24,8 +24,10 @@ import { OrchestrationV2CheckpointScope, OrchestrationV2Command, OrchestrationV2DomainEvent, + OrchestrationV2DomainEventJson, OrchestrationV2ShellSnapshot, OrchestrationV2Subagent, + OrchestrationV2SubagentActivation, OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, } from "./orchestrationV2.ts"; @@ -373,13 +375,89 @@ describe("orchestration V2 contracts", () => { prompt: "Inspect package.json", title: "Package audit", model: "gpt-5.4", - status: "completed", + kind: "subagent", + role: { name: "reviewer", source: "provider" }, + status: "idle", progress: "Inspecting package metadata", result: "Package is private.", + usage: { + totalTokens: 1200, + inputTokens: 900, + outputTokens: 300, + toolUses: 2, + }, + currentActivationId: null, + activationCount: 2, + workflow: null, + workflowMembership: null, + recentActivity: [{ at: now, summary: "Inspected package metadata" }], startedAt: now, completedAt: now, updatedAt: now, }); + const activation = Schema.decodeUnknownSync(OrchestrationV2SubagentActivation)({ + id: "node-subagent-1:activation:2", + threadId: "thread-1", + subagentId: subagent.id, + runId: "run-1", + providerTurnId: "provider-turn-1", + ordinal: 2, + status: "completed", + usage: { totalTokens: 400 }, + startedAt: now, + completedAt: now, + updatedAt: now, + }); + const { + kind: _kind, + role: _role, + usage: _usage, + currentActivationId: _currentActivationId, + activationCount: _activationCount, + workflow: _workflow, + workflowMembership: _workflowMembership, + recentActivity: _recentActivity, + ...legacySubagent + } = subagent; + const decodedLegacySubagent = Schema.decodeUnknownSync(OrchestrationV2Subagent)(legacySubagent); + const decodedLegacyEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEventJson)({ + id: "event-legacy-subagent", + type: "subagent.updated", + threadId: "thread-1", + runId: "run-1", + nodeId: "node-subagent-1", + driver: "codex", + providerInstanceId: "codex", + occurredAt: DateTime.formatIso(now), + payload: { + ...legacySubagent, + startedAt: DateTime.formatIso(now), + completedAt: DateTime.formatIso(now), + updatedAt: DateTime.formatIso(now), + }, + }); + const decodedActivityEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEventJson)({ + id: "event-subagent-activity", + type: "subagent.updated", + threadId: "thread-1", + runId: "run-1", + nodeId: "node-subagent-1", + driver: "codex", + providerInstanceId: "codex", + occurredAt: DateTime.formatIso(now), + payload: { + ...subagent, + recentActivity: [ + { + at: DateTime.formatIso(now), + summary: "Inspected package metadata", + }, + ], + startedAt: DateTime.formatIso(now), + completedAt: DateTime.formatIso(now), + updatedAt: DateTime.formatIso(now), + }, + }); const turnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem)({ id: "turn-item-subagent-1", type: "subagent", @@ -409,6 +487,24 @@ describe("orchestration V2 contracts", () => { expect(subagent.origin).toBe("provider_native"); expect(subagent.progress).toBe("Inspecting package metadata"); expect(subagent.childThreadId).toBeNull(); + expect(subagent.role).toEqual({ name: "reviewer", source: "provider" }); + expect(subagent.status).toBe("idle"); + expect(activation.ordinal).toBe(2); + expect(decodedLegacySubagent.role).toEqual({ + name: "general-purpose", + source: "app_default", + }); + expect(decodedLegacySubagent.activationCount).toBe(1); + if (decodedLegacyEvent.type !== "subagent.updated") { + throw new Error("expected legacy subagent event"); + } + expect(decodedLegacyEvent.payload.recentActivity).toEqual([]); + if (decodedActivityEvent.type !== "subagent.updated") { + throw new Error("expected subagent activity event"); + } + expect(DateTime.formatIso(decodedActivityEvent.payload.recentActivity[0]!.at)).toBe( + DateTime.formatIso(now), + ); expect(turnItem.type).toBe("subagent"); if (turnItem.type !== "subagent") throw new Error("expected subagent item"); expect(turnItem.progress).toBe("Inspecting package metadata"); @@ -444,6 +540,7 @@ describe("orchestration V2 contracts", () => { attempts: [], nodes: [], subagents: [], + subagentActivations: [], providerSessions: [], providerThreads: [], providerTurns: [], diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 186e0b36fb0..b0ae7541df1 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -23,6 +23,7 @@ import { RunAttemptId, RunId, RuntimeRequestId, + SubagentActivationId, ThreadId, TrimmedNonEmptyString, TurnItemId, @@ -427,6 +428,35 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ }); export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type; +export const OrchestrationV2SubagentUsage = 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 OrchestrationV2SubagentUsage = typeof OrchestrationV2SubagentUsage.Type; + +export const OrchestrationV2SubagentRole = Schema.Struct({ + name: TrimmedNonEmptyString, + source: Schema.Literals(["provider", "app_default"]), +}); +export type OrchestrationV2SubagentRole = typeof OrchestrationV2SubagentRole.Type; + +export const OrchestrationV2SubagentActivity = Schema.Struct({ + at: Schema.DateTimeUtc, + summary: TrimmedNonEmptyString, +}); +export type OrchestrationV2SubagentActivity = typeof OrchestrationV2SubagentActivity.Type; + +export const OrchestrationV2WorkflowPhase = Schema.Struct({ + index: NonNegativeInt, + title: TrimmedNonEmptyString, +}); +export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type; + export const OrchestrationV2Subagent = Schema.Struct({ id: NodeId, threadId: ThreadId, @@ -442,10 +472,19 @@ export const OrchestrationV2Subagent = Schema.Struct({ prompt: Schema.String, title: Schema.NullOr(Schema.String), model: Schema.NullOr(Schema.String), + kind: Schema.Literals(["subagent", "workflow", "workflow_agent"]).pipe( + Schema.withDecodingDefault(Effect.succeed("subagent" as const)), + ), + role: OrchestrationV2SubagentRole.pipe( + Schema.withDecodingDefault( + Effect.succeed({ name: "general-purpose", source: "app_default" as const }), + ), + ), status: Schema.Literals([ "pending", "running", "waiting", + "idle", "completed", "failed", "cancelled", @@ -453,12 +492,58 @@ export const OrchestrationV2Subagent = Schema.Struct({ ]), progress: Schema.optional(Schema.String), result: Schema.NullOr(Schema.String), + usage: Schema.NullOr(OrchestrationV2SubagentUsage).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + currentActivationId: Schema.NullOr(SubagentActivationId).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + activationCount: NonNegativeInt.pipe(Schema.withDecodingDefault(Effect.succeed(1))), + workflow: Schema.NullOr( + Schema.Struct({ + phases: Schema.Array(OrchestrationV2WorkflowPhase), + }), + ).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + workflowMembership: Schema.NullOr( + Schema.Struct({ + workflowSubagentId: NodeId, + agentIndex: NonNegativeInt, + phaseIndex: Schema.NullOr(NonNegativeInt), + attempt: PositiveInt, + }), + ).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + recentActivity: Schema.Array(OrchestrationV2SubagentActivity).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), startedAt: Schema.NullOr(Schema.DateTimeUtc), completedAt: Schema.NullOr(Schema.DateTimeUtc), updatedAt: Schema.DateTimeUtc, }); export type OrchestrationV2Subagent = typeof OrchestrationV2Subagent.Type; +export const OrchestrationV2SubagentActivation = Schema.Struct({ + id: SubagentActivationId, + threadId: ThreadId, + subagentId: NodeId, + runId: Schema.NullOr(RunId), + providerTurnId: Schema.NullOr(ProviderTurnId), + ordinal: PositiveInt, + status: Schema.Literals([ + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted", + ]), + usage: Schema.NullOr(OrchestrationV2SubagentUsage), + startedAt: Schema.NullOr(Schema.DateTimeUtc), + completedAt: Schema.NullOr(Schema.DateTimeUtc), + updatedAt: Schema.DateTimeUtc, +}); +export type OrchestrationV2SubagentActivation = typeof OrchestrationV2SubagentActivation.Type; + export const OrchestrationV2CheckpointScope = Schema.Struct({ id: CheckpointScopeId, threadId: ThreadId, @@ -693,6 +778,7 @@ export const OrchestrationV2TurnItemStatus = Schema.Literals([ "pending", "running", "waiting", + "idle", "completed", "failed", "cancelled", @@ -1032,6 +1118,11 @@ export const OrchestrationV2DomainEvent = Schema.Union([ type: Schema.Literal("subagent.updated"), payload: OrchestrationV2Subagent, }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("subagent-activation.updated"), + payload: OrchestrationV2SubagentActivation, + }), Schema.Struct({ ...OrchestrationV2EventBase.fields, type: Schema.Literals(["provider-session.attached", "provider-session.updated"]), @@ -1111,6 +1202,7 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ attempts: Schema.Array(OrchestrationV2RunAttempt), nodes: Schema.Array(OrchestrationV2ExecutionNode), subagents: Schema.Array(OrchestrationV2Subagent), + subagentActivations: Schema.Array(OrchestrationV2SubagentActivation), providerSessions: Schema.Array(OrchestrationV2ProviderSession), providerThreads: Schema.Array(OrchestrationV2ProviderThread), providerTurns: Schema.Array(OrchestrationV2ProviderTurn), @@ -1284,12 +1376,29 @@ export type OrchestrationV2ExecutionNodeJson = typeof OrchestrationV2ExecutionNo export const OrchestrationV2SubagentJson = OrchestrationV2Subagent.mapFields((fields) => ({ ...fields, + recentActivity: Schema.Array( + OrchestrationV2SubagentActivity.mapFields((activityFields) => ({ + ...activityFields, + at: Schema.DateTimeUtcFromString, + })), + ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), startedAt: Schema.NullOr(Schema.DateTimeUtcFromString), completedAt: Schema.NullOr(Schema.DateTimeUtcFromString), updatedAt: Schema.DateTimeUtcFromString, })); export type OrchestrationV2SubagentJson = typeof OrchestrationV2SubagentJson.Type; +export const OrchestrationV2SubagentActivationJson = OrchestrationV2SubagentActivation.mapFields( + (fields) => ({ + ...fields, + startedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + completedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + updatedAt: Schema.DateTimeUtcFromString, + }), +); +export type OrchestrationV2SubagentActivationJson = + typeof OrchestrationV2SubagentActivationJson.Type; + export const OrchestrationV2CheckpointScopeJson = OrchestrationV2CheckpointScope.mapFields( (fields) => ({ ...fields, @@ -1575,6 +1684,9 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti attempts: Schema.Array(OrchestrationV2RunAttemptJson), nodes: Schema.Array(OrchestrationV2ExecutionNodeJson), subagents: Schema.Array(OrchestrationV2SubagentJson), + subagentActivations: Schema.Array(OrchestrationV2SubagentActivationJson).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), providerSessions: Schema.Array(OrchestrationV2ProviderSessionJson), providerThreads: Schema.Array(OrchestrationV2ProviderThreadJson), providerTurns: Schema.Array(OrchestrationV2ProviderTurnJson), @@ -1699,6 +1811,11 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ type: Schema.Literal("subagent.updated"), payload: OrchestrationV2SubagentJson, }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("subagent-activation.updated"), + payload: OrchestrationV2SubagentActivationJson, + }), Schema.Struct({ ...OrchestrationV2JsonEventBaseFields, type: Schema.Literals(["provider-session.attached", "provider-session.updated"]), From 5765399ebb82563fd9329b70d243ba2a4d0385db Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:00:36 +0530 Subject: [PATCH 2/7] fix(orchestration-v2): preserve nullable event attribution --- .../ProviderEventIngestor.test.ts | 81 +++++++++++++++++++ .../orchestration-v2/ProviderEventIngestor.ts | 4 +- .../fixtures/subagent_v2/codex_output.ts | 10 ++- .../subagent_v2_nested/codex_output.ts | 18 +++-- 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index ea2d2123e31..f663b8e4cd3 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -8,6 +8,8 @@ import { type OrchestrationV2ProviderThread, ProviderDriverKind, ProviderInstanceId, + RunId, + SubagentActivationId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -367,4 +369,83 @@ layer("ProviderEventIngestorV2", (it) => { assert.equal(messageEvents[0]?.threadId, childThreadId); }), ); + + it.effect("does not replace explicit null run and node overrides with ambient ids", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const ingestor = yield* ProviderEventIngestorV2; + const idAllocator = yield* IdAllocatorV2; + const projectId = yield* idAllocator.allocate.project({ + fixtureName: "provider-event-null-overrides", + }); + const threadId = yield* idAllocator.allocate.thread({ + fixtureName: "provider-event-null-overrides", + projectId, + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const ambientRunId = RunId.make("run:provider-event-null-overrides"); + const ambientNodeId = NodeId.make("node:provider-event-null-overrides"); + const subagentId = NodeId.make("node:provider-event-null-overrides:subagent"); + + const activationEvents = yield* ingestor.normalize({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId, + runId: ambientRunId, + nodeId: ambientNodeId, + event: { + type: "subagent_activation.updated", + driver: CODEX_DRIVER, + activation: { + id: SubagentActivationId.make( + "node:provider-event-null-overrides:subagent:activation:1", + ), + threadId, + subagentId, + runId: null, + providerTurnId: null, + ordinal: 1, + status: "completed", + usage: null, + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }, + }); + const messageEvents = yield* ingestor.normalize({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId, + runId: ambientRunId, + nodeId: ambientNodeId, + event: { + type: "message.updated", + driver: CODEX_DRIVER, + message: { + createdBy: "agent", + creationSource: "provider", + id: MessageId.make("message:provider-event-null-overrides"), + threadId, + runId: null, + nodeId: null, + role: "assistant", + text: "Detached provider message", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }, + }); + + assert.isUndefined(activationEvents[0]?.runId); + assert.equal(activationEvents[0]?.nodeId, subagentId); + assert.isUndefined(messageEvents[0]?.runId); + assert.isUndefined(messageEvents[0]?.nodeId); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 8036f273406..f921472d81c 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -125,8 +125,8 @@ export const layer: Layer.Layer candidate.subagentId === subagent.id, + ); + assert.isDefined(activation); + assert.equal(activation.status, "completed"); + assert.isNotNull(activation.completedAt); const parentItem = projection.turnItems.find( (item) => item.type === "subagent" && item.subagentId === subagent.id, ); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_output.ts index b01365cb5e6..df38709413c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_output.ts @@ -26,7 +26,7 @@ function projectionById( return projection; } -function assertCompletedProviderNativeSubagent(input: { +function assertIdleProviderNativeSubagent(input: { readonly projection: OrchestrationV2ThreadProjection; readonly title: string; readonly result: string; @@ -43,12 +43,20 @@ function assertCompletedProviderNativeSubagent(input: { assert.equal(subagent.createdBy, "agent"); assert.equal(subagent.driver, "codex"); assert.equal(subagent.title, input.title); - assert.equal(subagent.status, "completed"); + assert.equal(subagent.status, "idle"); assert.equal(subagent.result, input.result); + assert.isNull(subagent.currentActivationId); + assert.equal(subagent.activationCount, 1); assert.isNotNull(subagent.childThreadId); assert.isNotNull(subagent.providerThreadId); assert.isNotNull(subagent.nativeTaskRef); assert.isNotNull(subagent.completedAt); + const activation = input.projection.subagentActivations.find( + (candidate) => candidate.subagentId === subagent.id, + ); + assert.isDefined(activation); + assert.equal(activation.status, "completed"); + assert.isNotNull(activation.completedAt); return subagent; } @@ -71,7 +79,7 @@ export function assertSubagentV2NestedOutput( assertUserMessagesInclude(rootProjection, [SUBAGENT_V2_PROMPT]); assert.lengthOf(result.shellSnapshot.threads, 4); - const first = assertCompletedProviderNativeSubagent({ + const first = assertIdleProviderNativeSubagent({ projection: rootProjection, title: "/root/hello_agent", result: "Subagent says: “Hello.”", @@ -88,7 +96,7 @@ export function assertSubagentV2NestedOutput( assert.lengthOf(firstProjection.providerTurns, 1); assertTurnItemTypes(firstProjection, ["subagent", "assistant_message"]); - const second = assertCompletedProviderNativeSubagent({ + const second = assertIdleProviderNativeSubagent({ projection: firstProjection, title: "/root/hello_agent/hello_agent", result: "Subagent says: “Hello.”", @@ -105,7 +113,7 @@ export function assertSubagentV2NestedOutput( assert.lengthOf(secondProjection.providerTurns, 1); assertTurnItemTypes(secondProjection, ["subagent", "assistant_message"]); - const third = assertCompletedProviderNativeSubagent({ + const third = assertIdleProviderNativeSubagent({ projection: secondProjection, title: "/root/hello_agent/hello_agent/hello_agent", result: "Hello.", From a65967aa051536eef7ac0c61c4e9e1417fe9b055 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:10:10 +0530 Subject: [PATCH 3/7] fix agents panel layout and child visibility --- apps/web/src/components/ChatView.tsx | 17 ++++++++++------- apps/web/src/components/RightPanelTabs.tsx | 3 +-- apps/web/src/components/Sidebar.logic.test.ts | 16 +++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 8 ++++++-- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a39df665352..86d77bf0388 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5912,22 +5912,24 @@ function ChatViewContent(props: ChatViewProps) { ); const panelLayoutControls = (
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( - - ) : null} {panelToggleControls}
); + const rightPanelLayoutControls = ( +
+ + +
+ ); return (
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
{ }); expect(isSidebarSubagentThread(subagent)).toBe(true); + expect( + isSidebarSubagentThread( + makeThreadFixture({ + forkedFrom: { type: "node", nodeId: NodeId.make("node-provider-subagent") }, + }), + ), + ).toBe(true); expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a917631d1a4..6ef0bbf8998 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -92,8 +92,12 @@ export function buildMultiSelectThreadContextMenuItems(input: { ]; } -export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; +export function isSidebarSubagentThread( + thread: Pick, +): boolean { + // Provider-native subagents are node-owned child threads. Keep the structural + // marker as a compatibility fallback when an older shell omits their lineage. + return thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node"; } export function getSidebarForkParentThreadId( From a84095aebfa8b9c8a46a964057f444fe9373827d Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:05:14 +0530 Subject: [PATCH 4/7] fix subagent lifecycle and usage semantics --- .../Adapters/ClaudeAdapterV2.ts | 8 +-- .../Adapters/CodexAdapterV2.test.ts | 12 +++++ .../Adapters/CodexAdapterV2.ts | 40 +++++++++----- .../orchestration-v2/RunExecutionService.ts | 1 - .../SubagentObservability.test.ts | 53 +++++++++++++------ .../orchestration-v2/SubagentObservability.ts | 10 +++- .../fixtures/subagent_v2/codex_output.ts | 1 + apps/web/src/components/AgentsPanelV2.tsx | 14 +++-- .../src/components/chat/V2LifecycleRow.tsx | 2 - apps/web/src/session-logic.ts | 1 - .../state/orchestrationV2Subagents.test.ts | 14 +++++ .../src/state/orchestrationV2Subagents.ts | 19 +++---- packages/contracts/src/orchestrationV2.ts | 1 - 13 files changed, 123 insertions(+), 53 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d16d8abb5a5..384f36b4573 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -114,9 +114,9 @@ import { subagentThreadTitle, } from "../SubagentProjection.ts"; import { - accumulateSubagentUsage, + accumulateCumulativeSubagentUsage, appendSubagentActivity, - mergeSubagentUsage, + mergeCumulativeSubagentUsage, providerSubagentRole, subagentActivationId, } from "../SubagentObservability.ts"; @@ -2474,7 +2474,7 @@ export function makeClaudeAdapterV2( : { workflowMembership: input.workflowMembership }), ...(input.progress === undefined ? {} : { progress: input.progress }), ...(input.result === undefined ? {} : { result: input.result }), - usage: accumulateSubagentUsage( + usage: accumulateCumulativeSubagentUsage( priorTask?.usage ?? null, startsActivation ? undefined : existingSubagent?.activation.usage, input.usage, @@ -2508,7 +2508,7 @@ export function makeClaudeAdapterV2( : ({ ...existingSubagent.activation, status: input.status, - usage: mergeSubagentUsage(existingSubagent.activation.usage, input.usage), + usage: mergeCumulativeSubagentUsage(existingSubagent.activation.usage, input.usage), completedAt: settled ? now : null, updatedAt: now, } satisfies OrchestrationV2SubagentActivation); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index d08fafc3cd1..c3768fffcb7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -49,6 +49,7 @@ import { CODEX_DEFAULT_INSTANCE_ID, CODEX_DRIVER_KIND, codexBackgroundCommandDetail, + codexCollabAgentStatus, codexThreadRuntimeParams, type CodexAgentMessageDeltaUpdate, type CodexAppServerClientFactoryShape, @@ -61,6 +62,17 @@ import { } from "./CodexAdapterV2.ts"; import { makeReplayServerConfig } from "./CodexAdapterV2.testkit.ts"; +describe("CodexAdapterV2 subagent status mapping", () => { + it("maps every provider state without treating terminal states as running", () => { + assert.deepEqual( + ["pendingInit", "running", "completed", "interrupted", "errored", "shutdown", "notFound"].map( + (status) => codexCollabAgentStatus(status as Parameters[0]), + ), + ["pending", "running", "idle", "interrupted", "failed", "cancelled", "failed"], + ); + }); +}); + describe("CodexAdapterV2 assistant message streaming", () => { it.effect("makes accumulated assistant text visible after the bounded flush interval", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f44bae0a35d..9106f187571 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -105,7 +105,7 @@ import { import { appendSubagentActivity, defaultSubagentRole, - mergeSubagentUsage, + mergeCumulativeSubagentUsage, subagentActivationId, } from "../SubagentObservability.ts"; @@ -900,6 +900,27 @@ type CodexCollabAgentToolCallItem = Extract< CodexSchema.V2ItemCompletedNotification__ThreadItem, { readonly type: "collabAgentToolCall" } >; +type CodexCollabAgentStatus = CodexCollabAgentToolCallItem["agentsStates"][string]["status"]; + +export function codexCollabAgentStatus( + status: CodexCollabAgentStatus, +): OrchestrationV2Subagent["status"] { + switch (status) { + case "pendingInit": + return "pending"; + case "running": + return "running"; + case "completed": + return "idle"; + case "interrupted": + return "interrupted"; + case "errored": + case "notFound": + return "failed"; + case "shutdown": + return "cancelled"; + } +} type CodexSubAgentActivityItem = Extract< | CodexSchema.V2ItemStartedNotification__ThreadItem @@ -1739,7 +1760,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi status: input.status, ...(input.progress === undefined ? {} : { progress: input.progress }), result: input.result === undefined ? input.subagent.task.result : input.result, - usage: mergeSubagentUsage(input.subagent.task.usage, input.usage), + usage: mergeCumulativeSubagentUsage(input.subagent.task.usage, input.usage), currentActivationId: input.currentActivationId !== undefined ? input.currentActivationId @@ -1775,7 +1796,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi nativeItemRef: task.nativeTaskRef, parentItemId: null, ordinal: input.subagent.turnItemOrdinal, - status: task.status, + // The task identity is reusable and therefore idle between + // turns; this timeline item represents the completed turn. + status: task.status === "idle" ? "completed" : task.status, title: task.title, startedAt: task.startedAt, completedAt: task.completedAt, @@ -2274,18 +2297,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi if (subagent === undefined) { continue; } - const nativeStatus = String(state.status); - const status: OrchestrationV2Subagent["status"] = - nativeStatus === "completed" - ? "completed" - : nativeStatus === "failed" || nativeStatus === "errored" - ? "failed" - : nativeStatus === "cancelled" || nativeStatus === "closed" - ? "cancelled" - : "running"; yield* emitSubagentTaskUpdate({ subagent, - status, + status: codexCollabAgentStatus(state.status), ...(state.message === null ? {} : { result: state.message }), }); } diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 298f8058afb..ab8152d0143 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -97,7 +97,6 @@ const backgroundCapableTurnItemTypes: ReadonlySet { }); it("accumulates activation deltas into stable lifetime usage", () => { - const first = accumulateSubagentUsage(null, null, { + const first = accumulateCumulativeSubagentUsage(null, null, { totalTokens: 100, inputTokens: 70, outputTokens: 30, toolUses: 2, }); - const firstAdvanced = accumulateSubagentUsage( + const firstAdvanced = accumulateCumulativeSubagentUsage( first, { totalTokens: 100, @@ -40,7 +40,7 @@ describe("subagent observability", () => { toolUses: 3, }, ); - const resumed = accumulateSubagentUsage(firstAdvanced, null, { + const resumed = accumulateCumulativeSubagentUsage(firstAdvanced, null, { totalTokens: 80, inputTokens: 50, outputTokens: 30, @@ -82,28 +82,51 @@ describe("subagent observability", () => { it("merges cumulative provider counters without double-counting", () => { expect( - mergeSubagentUsage( + mergeCumulativeSubagentUsage( { totalTokens: 100, outputTokens: 25 }, { totalTokens: 140, outputTokens: 20 }, ), ).toEqual({ totalTokens: 140, outputTokens: 25 }); }); - it("preserves omitted activation counters when they reappear", () => { - const firstActivation = { totalTokens: 100, inputTokens: 70 }; - const withoutInput = mergeSubagentUsage(firstActivation, { totalTokens: 140 }); - const lifetimeWithoutInput = accumulateSubagentUsage(firstActivation, firstActivation, { + it("is idempotent for duplicate and late cumulative snapshots", () => { + const latest = { totalTokens: 140, + inputTokens: 95, + outputTokens: 45, + toolUses: 3, + }; + const duplicate = mergeCumulativeSubagentUsage(latest, latest); + const late = mergeCumulativeSubagentUsage(duplicate, { + totalTokens: 100, + inputTokens: 70, + outputTokens: 30, + toolUses: 2, }); - const restoredInput = mergeSubagentUsage(withoutInput, { + + expect(duplicate).toEqual(latest); + expect(late).toEqual(latest); + expect(accumulateCumulativeSubagentUsage(latest, latest, duplicate)).toEqual(latest); + }); + + it("preserves omitted activation counters when they reappear", () => { + const firstActivation = { totalTokens: 100, inputTokens: 70 }; + const withoutInput = mergeCumulativeSubagentUsage(firstActivation, { totalTokens: 140 }); + const lifetimeWithoutInput = accumulateCumulativeSubagentUsage( + firstActivation, + firstActivation, + { + totalTokens: 140, + }, + ); + const restoredInput = mergeCumulativeSubagentUsage(withoutInput, { totalTokens: 180, inputTokens: 100, }); expect(withoutInput).toEqual({ totalTokens: 140, inputTokens: 70 }); - expect(accumulateSubagentUsage(lifetimeWithoutInput, withoutInput, restoredInput)).toEqual({ - totalTokens: 180, - inputTokens: 100, - }); + expect( + accumulateCumulativeSubagentUsage(lifetimeWithoutInput, withoutInput, restoredInput), + ).toEqual({ totalTokens: 180, inputTokens: 100 }); }); }); diff --git a/apps/server/src/orchestration-v2/SubagentObservability.ts b/apps/server/src/orchestration-v2/SubagentObservability.ts index e8a868cda52..eafdd347654 100644 --- a/apps/server/src/orchestration-v2/SubagentObservability.ts +++ b/apps/server/src/orchestration-v2/SubagentObservability.ts @@ -48,7 +48,10 @@ export const appendSubagentActivity = ( const cumulative = (previous: number | undefined, next: number | undefined) => next === undefined ? previous : Math.max(previous ?? 0, next); -export const mergeSubagentUsage = ( +// Provider progress frames are cumulative snapshots within one scope, not +// deltas. Taking the maximum makes duplicate and late out-of-order frames +// idempotent without inventing usage the provider did not report. +export const mergeCumulativeSubagentUsage = ( previous: OrchestrationV2SubagentUsage | null, next: OrchestrationV2SubagentUsage | null | undefined, ): OrchestrationV2SubagentUsage | null => { @@ -88,7 +91,10 @@ const accumulated = ( ) => next === undefined ? previous : (previous ?? 0) + Math.max(0, next - (previousActivation ?? 0)); -export const accumulateSubagentUsage = ( +// Lifetime usage advances only by the change since the prior snapshot for the +// current activation. Pass no prior activation snapshot when a new activation +// starts so its first cumulative snapshot is added in full. +export const accumulateCumulativeSubagentUsage = ( previous: OrchestrationV2SubagentUsage | null, previousActivation: OrchestrationV2SubagentUsage | null | undefined, next: OrchestrationV2SubagentUsage | null | undefined, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts index f19069021e1..99721b85fdb 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts @@ -62,6 +62,7 @@ export function assertSubagentV2Output( if (parentItem?.type !== "subagent") { throw new Error(`Missing parent lifecycle item for subagent ${subagent.id}`); } + assert.equal(parentItem.status, "completed"); assert.equal(parentItem.result, subagent.result); if (subagent.childThreadId === null) { throw new Error(`Subagent ${subagent.id} is missing its child thread`); diff --git a/apps/web/src/components/AgentsPanelV2.tsx b/apps/web/src/components/AgentsPanelV2.tsx index a9af3175e0f..ac389c14cc5 100644 --- a/apps/web/src/components/AgentsPanelV2.tsx +++ b/apps/web/src/components/AgentsPanelV2.tsx @@ -151,15 +151,17 @@ function AgentCard(props: { } export function AgentsPanelV2(props: { projection: OrchestrationV2ThreadProjection | null }) { + const subagents = props.projection?.subagents ?? null; + const activations = props.projection?.subagentActivations ?? null; const panel = useMemo( () => - props.projection === null + subagents === null || activations === null ? null : deriveOrchestrationV2SubagentPanelState({ - subagents: props.projection.subagents, - activations: props.projection.subagentActivations, + subagents, + activations, }), - [props.projection], + [activations, subagents], ); if (panel === null || panel.groups.length === 0) { @@ -187,7 +189,9 @@ export function AgentsPanelV2(props: { projection: OrchestrationV2ThreadProjecti

Agents

- {formatSubagentTokenCount(panel.totalTokens)} tokens + {panel.totalTokens === null + ? "Usage unavailable" + : `${formatSubagentTokenCount(panel.totalTokens)} tokens`}
diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index d43b6c610dc..b609abe5665 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -32,7 +32,6 @@ export function isV2LifecycleItem(item: OrchestrationV2TurnItem): boolean { // Aborted subagents (cancelled/interrupted) keep whatever result text had // streamed before the abort, so only completed/failed results are final. const FINAL_RESULT_SUBAGENT_STATUSES = new Set([ - "idle", "completed", "failed", ]); @@ -40,7 +39,6 @@ const FINAL_RESULT_SUBAGENT_STATUSES = new Set([ - "idle", "completed", "failed", "cancelled", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d22b36b0781..a70dbecfe55 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -354,7 +354,6 @@ function projectedWorkEntryStatus( case "running": case "waiting": return "inProgress"; - case "idle": case "completed": return "completed"; case "failed": diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts index 1965e3653bb..e0e48d36091 100644 --- a/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.test.ts @@ -137,6 +137,20 @@ describe("deriveOrchestrationV2SubagentPanelState", () => { expect(result.activationsBySubagentId.get(researcher.id)).toEqual(activations); }); + it("distinguishes unavailable usage from a reported zero", () => { + const unavailable = deriveOrchestrationV2SubagentPanelState({ + subagents: [agent("unreported")], + activations: [], + }); + const reportedZero = deriveOrchestrationV2SubagentPanelState({ + subagents: [agent("reported-zero", { usage: { totalTokens: 0 } })], + activations: [], + }); + + expect(unavailable.totalTokens).toBeNull(); + expect(reportedZero.totalTokens).toBe(0); + }); + it("formats compact token counts", () => { expect(formatSubagentTokenCount(999)).toBe("999"); expect(formatSubagentTokenCount(1_200)).toBe("1.2k"); diff --git a/packages/client-runtime/src/state/orchestrationV2Subagents.ts b/packages/client-runtime/src/state/orchestrationV2Subagents.ts index baff921c6d1..3da1a7dd493 100644 --- a/packages/client-runtime/src/state/orchestrationV2Subagents.ts +++ b/packages/client-runtime/src/state/orchestrationV2Subagents.ts @@ -25,7 +25,7 @@ export interface OrchestrationV2SubagentPanelState { readonly activeCount: number; readonly waitingCount: number; readonly settledCount: number; - readonly totalTokens: number; + readonly totalTokens: number | null; } export const isSettledOrchestrationV2Subagent = (agent: OrchestrationV2Subagent) => @@ -101,6 +101,11 @@ export function deriveOrchestrationV2SubagentPanelState(input: { return workflowId === undefined ? [] : [workflowId]; }), ); + const reportedUsage = input.subagents.flatMap((agent) => + agent.usage === null || (agent.kind === "workflow" && workflowIdsWithMembers.has(agent.id)) + ? [] + : [agent.usage.totalTokens], + ); return { groups, activationsBySubagentId, @@ -108,14 +113,10 @@ export function deriveOrchestrationV2SubagentPanelState(input: { .length, waitingCount: workers.filter((agent) => agent.status === "waiting").length, settledCount: workers.filter(isSettledOrchestrationV2Subagent).length, - totalTokens: input.subagents.reduce( - (total, agent) => - total + - (agent.kind === "workflow" && workflowIdsWithMembers.has(agent.id) - ? 0 - : (agent.usage?.totalTokens ?? 0)), - 0, - ), + totalTokens: + reportedUsage.length === 0 + ? null + : reportedUsage.reduce((total, tokens) => total + tokens, 0), }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b0ae7541df1..a50a7785728 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -778,7 +778,6 @@ export const OrchestrationV2TurnItemStatus = Schema.Literals([ "pending", "running", "waiting", - "idle", "completed", "failed", "cancelled", From 53cb447aa2a1397cc311ab9c4aeb997cb01fa8f5 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:11:51 +0530 Subject: [PATCH 5/7] update replay fixtures for idle subagents --- .../orchestration-v2/testkit/fixtures/subagent/codex_output.ts | 3 ++- .../testkit/fixtures/subagent_continue/codex_output.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts index 6e9160624ba..5dde986bb7f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts @@ -43,7 +43,7 @@ export function assertSubagentOutput( assert.lengthOf(result.shellSnapshot.threads, 3); assert.deepEqual( projection.subagents.map((subagent) => subagent.status), - ["completed", "completed"], + ["idle", "idle"], ); assert.isTrue( projection.subagents.some( @@ -76,6 +76,7 @@ export function assertSubagentOutput( throw new Error(`Missing parent lifecycle item for subagent ${subagent.id}`); } assert.equal(parentItem.result, subagent.result); + assert.equal(parentItem.status, "completed"); if (subagent.childThreadId === null) { throw new Error(`Subagent ${subagent.id} is missing its child thread`); } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_output.ts index b631cbf565f..e277e918402 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_output.ts @@ -34,7 +34,7 @@ export function assertSubagentContinueOutput( assert.lengthOf(projection.subagents, 1); const subagent = projection.subagents[0]!; - assert.equal(subagent.status, "completed"); + assert.equal(subagent.status, "idle"); assert.equal(subagent.result, "initial subagent response"); assert.isNotNull(subagent.childThreadId); if (subagent.childThreadId === null) { From db500cb864d7ed555a16115861a940f273e9c891 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:00:41 +0530 Subject: [PATCH 6/7] hide subagent threads from sidebar v2 --- apps/web/src/components/SidebarV2.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f5261c62e06..eba321460c4 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -104,6 +104,7 @@ import { formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, + isSidebarSubagentThread, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, @@ -1370,6 +1371,7 @@ export default function SidebarV2() { const visible = threads.filter( (thread) => thread.archivedAt === null && + !isSidebarSubagentThread(thread) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); From 8e7791ac198a569bf889664e090ce0dc8aac2225 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:14:12 +0530 Subject: [PATCH 7/7] fix subagent thread visibility and panel controls --- .../ThreadManagementService.test.ts | 60 ++++++++++++++++++- .../ThreadManagementService.ts | 15 +++-- apps/server/src/orchestration-v2/http.ts | 4 +- apps/server/src/ws.ts | 16 ++++- apps/web/src/components/ChatView.tsx | 17 +----- apps/web/src/components/Sidebar.logic.ts | 9 +-- .../components/chat/MessagesTimeline.test.tsx | 6 +- .../chat/ThreadRelationshipsControl.tsx | 32 +++++----- .../src/components/chat/V2ItemInspector.tsx | 6 -- .../src/components/chat/V2LifecycleRow.tsx | 2 +- .../routes/_chat.$environmentId.$threadId.tsx | 35 ++++++++++- apps/web/src/state/entities.ts | 49 +++++++++------ apps/web/src/threadVisibility.test.ts | 53 ++++++++++++++++ apps/web/src/threadVisibility.ts | 9 +++ 14 files changed, 237 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/threadVisibility.test.ts create mode 100644 apps/web/src/threadVisibility.ts diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 9965044ec8e..510d14c7429 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -1,14 +1,16 @@ import { expect, it } from "@effect/vitest"; import { CommandId, + NodeId, type OrchestrationV2Command, + type OrchestrationV2ThreadShell, ProjectId, ProviderInstanceId, RunId, ThreadId, } from "@t3tools/contracts"; -import { withCreationProvenance } from "./ThreadManagementService.ts"; +import { userFacingShellSnapshot, withCreationProvenance } from "./ThreadManagementService.ts"; it("stamps authoritative provenance on commands that create threads or messages", () => { const command: OrchestrationV2Command = { @@ -55,3 +57,59 @@ it("leaves commands that do not create durable authored content unchanged", () = }), ).toBe(command); }); + +it("removes internal subagent children from active and archived shell collections", () => { + const rootId = ThreadId.make("thread:thread-management:root"); + const forkId = ThreadId.make("thread:thread-management:fork"); + const lineageSubagentId = ThreadId.make("thread:thread-management:lineage-subagent"); + const nodeSubagentId = ThreadId.make("thread:thread-management:node-subagent"); + const shell = ( + id: ThreadId, + lineage: OrchestrationV2ThreadShell["lineage"], + forkedFrom: OrchestrationV2ThreadShell["forkedFrom"], + ) => + ({ + id, + lineage, + forkedFrom, + }) as OrchestrationV2ThreadShell; + const rootLineage = { + rootThreadId: rootId, + parentThreadId: null, + relationshipToParent: null, + } as const; + const snapshot = userFacingShellSnapshot({ + schemaVersion: 3, + snapshotSequence: 10, + threads: [ + shell(rootId, rootLineage, null), + shell( + forkId, + { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "fork", + }, + { type: "run", threadId: rootId, runId: RunId.make("run:thread-management:fork") }, + ), + shell( + lineageSubagentId, + { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + null, + ), + ], + archivedThreads: [ + shell(nodeSubagentId, rootLineage, { + type: "node", + nodeId: NodeId.make("node:thread-management:subagent"), + }), + ], + }); + + expect(snapshot.threads.map((thread) => thread.id)).toEqual([rootId, forkId]); + expect(snapshot.archivedThreads).toEqual([]); +}); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 7a4301980e5..565694286fd 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -171,6 +171,16 @@ export class ThreadManagementService extends Context.Service< ThreadManagementServiceShape >()("t3/orchestration-v2/ThreadManagementService") {} +export const isInternalSubagentThread = ( + thread: Pick, +) => thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node"; + +export const userFacingShellSnapshot = (snapshot: OrchestrationV2ThreadShellSnapshot) => ({ + ...snapshot, + threads: snapshot.threads.filter((thread) => !isInternalSubagentThread(thread)), + archivedThreads: snapshot.archivedThreads.filter((thread) => !isInternalSubagentThread(thread)), +}); + export function isActiveRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || @@ -269,10 +279,7 @@ const make = Effect.gen(function* () { Effect.map((snapshot) => snapshot.threads .filter((thread) => thread.projectId === input.projectId) - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => input.includeSubagents || !isInternalSubagentThread(thread)) .toSorted( (left, right) => DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) || diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index ef4ebd9fa1d..ca376ba0106 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -66,7 +66,9 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const base = yield* sql.withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot(); + const threads = yield* threadManagement + .getShellSnapshot() + .pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot)); return { schemaVersion: threads.schemaVersion, snapshotSequence: yield* applicationEvents.latestApplicationSequence, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1d51a3b6b9d..0e6d6a5d578 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -748,7 +748,9 @@ const makeWsRpcLayer = ( const base = yield* sql.withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot(); + const threads = yield* threadManagement + .getShellSnapshot() + .pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot)); return { schemaVersion: threads.schemaVersion, snapshotSequence: yield* applicationEvents.latestApplicationSequence, @@ -797,7 +799,12 @@ const makeWsRpcLayer = ( const survivors = coalesceShellApplicationEvents(events); const needsThreadSnapshot = survivors.some((stored) => !("aggregateKind" in stored)); const threadSnapshot = needsThreadSnapshot - ? yield* threadManagement.getShellSnapshot().pipe(Effect.map(Option.some)) + ? yield* threadManagement + .getShellSnapshot() + .pipe( + Effect.map(ThreadManagementService.userFacingShellSnapshot), + Effect.map(Option.some), + ) : Option.none(); return yield* Effect.forEach( survivors, @@ -933,7 +940,9 @@ const makeWsRpcLayer = ( .withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot(); + const threads = yield* threadManagement + .getShellSnapshot() + .pipe(Effect.map(ThreadManagementService.userFacingShellSnapshot)); return { schemaVersion: threads.schemaVersion, snapshotSequence: yield* applicationEvents.latestApplicationSequence, @@ -966,6 +975,7 @@ const makeWsRpcLayer = ( .pipe( Stream.mapEffect((stored) => threadManagement.getShellSnapshot().pipe( + Effect.map(ThreadManagementService.userFacingShellSnapshot), Effect.map((nextSnapshot) => archivedShellStreamItemFromSnapshot({ stored, snapshot: nextSnapshot }), ), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 86d77bf0388..3d603ee16e3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5901,15 +5901,6 @@ function ChatViewContent(props: ChatViewProps) { showThreadPanelControl={!inlineRightPanelOwnsTitleBar} /> ); - const threadPanelHeaderControl = ( -
- -
- ); const panelLayoutControls = (
{panelToggleControls} @@ -5921,7 +5912,7 @@ function ChatViewContent(props: ChatViewProps) { maximized={rightPanelMaximized} onToggle={toggleRightPanelMaximized} /> - +
); @@ -5954,11 +5945,7 @@ function ChatViewContent(props: ChatViewProps) { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > - {inlineRightPanelOwnsTitleBar - ? threadPanelHeaderControl - : !rightPanelOpen - ? panelLayoutControls - : null} + {!inlineRightPanelOwnsTitleBar && !rightPanelOpen ? panelLayoutControls : null} , -): boolean { - // Provider-native subagents are node-owned child threads. Keep the structural - // marker as a compatibility fallback when an older shell omits their lineage. - return thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node"; -} - export function getSidebarForkParentThreadId( thread: Pick, ) { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 54266c8e707..43890f74087 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -852,7 +852,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain('data-v2-item-type="subagent"'); - expect(markup).toContain('aria-label="Open Package audit"'); + expect(markup).not.toContain('aria-label="Open Package audit"'); expect(markup).toContain("Reading src/index.ts"); expect(markup).not.toContain("Inspect the package"); expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); @@ -908,7 +908,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-v2-subagent-result-disclosure="true"'); expect(markup).toContain('data-v2-subagent-result="true"'); expect(markup).toContain('aria-label="Show full result for Isolation report"'); - expect(markup).toContain('aria-label="Open Isolation report"'); + expect(markup).not.toContain('aria-label="Open Isolation report"'); expect(markup).toContain("Tests should be isolated."); expect(markup).toContain("Result: no shared state."); expect(markup).not.toContain("Explain test isolation"); @@ -961,7 +961,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toContain('data-v2-item-type="subagent"'); - expect(markup).toContain('aria-label="Open Package audit"'); + expect(markup).not.toContain('aria-label="Open Package audit"'); expect(markup).toContain("Reading src/index.ts"); expect(markup).not.toContain("Partial streamed answer so far"); expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"'); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 2685280112c..ba130881efa 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -104,21 +104,23 @@ export function ThreadRelationshipsPanel(props: { const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); const latestCompletedRun = projection?.runs.findLast((run) => run.status === "completed") ?? null; const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); - const relationshipRows = immediateThreadRelationships(graph, props.threadId).toSorted( - (left, right) => - relationshipSortKey({ - edge: left.edge, - threadId: left.threadId, - currentThreadId: props.threadId, - mergeTargetThreadId, - }) - - relationshipSortKey({ - edge: right.edge, - threadId: right.threadId, - currentThreadId: props.threadId, - mergeTargetThreadId, - }), - ); + const relationshipRows = immediateThreadRelationships(graph, props.threadId) + .filter((relationship) => relationship.edge.kind !== "subagent") + .toSorted( + (left, right) => + relationshipSortKey({ + edge: left.edge, + threadId: left.threadId, + currentThreadId: props.threadId, + mergeTargetThreadId, + }) - + relationshipSortKey({ + edge: right.edge, + threadId: right.threadId, + currentThreadId: props.threadId, + mergeTargetThreadId, + }), + ); const canMerge = mergeTargetThreadId !== null && latestCompletedRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 25463bb6205..3e2f1d87bb0 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -259,12 +259,6 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} - {item.type === "subagent" && item.childThreadId !== null ? ( - - ) : null} - {item.type === "handoff" ? (

diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index b609abe5665..263b72ffe6f 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -144,7 +144,7 @@ export function V2LifecycleRow(props: { title={subagentDisplayTitle(item.title ?? "Subagent")} detail={detail} badge={item.status} - threadId={item.childThreadId} + threadId={null} expandedDetail={finalResult} onOpenThread={props.onOpenThread} /> diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 271f5073968..10c09338184 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -4,11 +4,16 @@ import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; +import { + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteRenderState, +} from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; +import { isInternalSubagentThread } from "../threadVisibility"; function ChatThreadRouteView() { const navigate = useNavigate(); @@ -42,6 +47,10 @@ function ChatThreadRouteView() { }); const serverThreadStarted = threadHasStarted(serverThreadShell); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; + const subagentParentThreadId = + serverThreadShell && isInternalSubagentThread(serverThreadShell) + ? serverThreadShell.lineage.parentThreadId + : null; useEffect(() => { if (!threadRef || !bootstrapComplete) { @@ -53,6 +62,24 @@ function ChatThreadRouteView() { } }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); + useEffect(() => { + if (!threadRef || !serverThreadShell || !isInternalSubagentThread(serverThreadShell)) { + return; + } + if (subagentParentThreadId === null) { + void navigate({ to: "/", replace: true }); + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams({ + environmentId: threadRef.environmentId, + threadId: subagentParentThreadId, + }), + replace: true, + }); + }, [navigate, serverThreadShell, subagentParentThreadId, threadRef]); + useEffect(() => { if (!threadRef || !serverThreadStarted || !draftThread) { return; @@ -60,7 +87,11 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || renderState !== "ready") { + if ( + !threadRef || + renderState !== "ready" || + (serverThreadShell !== null && isInternalSubagentThread(serverThreadShell)) + ) { return null; } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 3391e221de2..cef26f88f52 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -1,13 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; +import { useMemo } from "react"; import type { EnvironmentProject, EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { isUserFacingThread } from "../threadVisibility"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom } from "./server"; import { allEnvironmentShellsBootstrappedAtom } from "./shell"; @@ -24,9 +27,6 @@ const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( Atom.withLabel("web-project-refs:empty"), ); -const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( - Atom.withLabel("web-thread-refs:empty"), -); const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-shell:empty"), ); @@ -59,7 +59,11 @@ export function useProjectRefs(): ReadonlyArray { } export function useThreadRefs(): ReadonlyArray { - return useAtomValue(environmentThreadShells.threadRefsAtom); + const threads = useThreadShells(); + return useMemo( + () => threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + [threads], + ); } export function useEnvironmentProjectRefs( @@ -75,10 +79,17 @@ export function useEnvironmentProjectRefs( export function useEnvironmentThreadRefs( environmentId: EnvironmentId | null, ): ReadonlyArray { - return useAtomValue( - environmentId === null - ? EMPTY_THREAD_REFS_ATOM - : environmentThreadShells.environmentThreadRefsAtom(environmentId), + const threads = useThreadShells(); + return useMemo( + () => + environmentId === null + ? EMPTY_THREAD_REFS + : threads.flatMap((thread) => + thread.environmentId === environmentId + ? [scopeThreadRef(thread.environmentId, thread.id)] + : [], + ), + [environmentId, threads], ); } @@ -91,7 +102,8 @@ export function useServerConfigs(): ReadonlyMap { } export function useThreadShells(): ReadonlyArray { - return useAtomValue(environmentThreadShells.threadShellsAtom); + const threads = useAtomValue(environmentThreadShells.threadShellsAtom); + return useMemo(() => threads.filter(isUserFacingThread), [threads]); } export function useAllEnvironmentShellsBootstrapped(): boolean { @@ -101,7 +113,8 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { export function useThreadShellsForProjectRefs( refs: ReadonlyArray, ): ReadonlyArray { - return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); + const threads = useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); + return useMemo(() => threads.filter(isUserFacingThread), [threads]); } export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { @@ -173,17 +186,19 @@ export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boo export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { - return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); + return appAtomRegistry + .get(environmentThreadShells.threadShellsAtom) + .filter((thread) => thread.environmentId === environmentId && isUserFacingThread(thread)) + .map((thread) => scopeThreadRef(thread.environmentId, thread.id)); } export function readThreadRefs(): ReadonlyArray { - return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); + return appAtomRegistry + .get(environmentThreadShells.threadShellsAtom) + .filter(isUserFacingThread) + .map((thread) => scopeThreadRef(thread.environmentId, thread.id)); } export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { - return ( - appAtomRegistry - .get(environmentThreadShells.threadRefsAtom) - .find((ref) => ref.threadId === threadId) ?? null - ); + return readThreadRefs().find((ref) => ref.threadId === threadId) ?? null; } diff --git a/apps/web/src/threadVisibility.test.ts b/apps/web/src/threadVisibility.test.ts new file mode 100644 index 00000000000..8bfad0a7964 --- /dev/null +++ b/apps/web/src/threadVisibility.test.ts @@ -0,0 +1,53 @@ +import { NodeId, RunId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { isInternalSubagentThread, isUserFacingThread } from "./threadVisibility"; + +const rootId = ThreadId.make("thread:visibility:root"); +const rootLineage = { + rootThreadId: rootId, + parentThreadId: null, + relationshipToParent: null, +} as const; + +describe("thread visibility", () => { + it("keeps roots and user forks visible", () => { + expect(isUserFacingThread({ lineage: rootLineage, forkedFrom: null })).toBe(true); + expect( + isUserFacingThread({ + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "fork", + }, + forkedFrom: { + type: "run", + threadId: rootId, + runId: RunId.make("run:visibility:fork"), + }, + }), + ).toBe(true); + }); + + it("hides explicit and node-owned subagent children", () => { + expect( + isInternalSubagentThread({ + lineage: { + rootThreadId: rootId, + parentThreadId: rootId, + relationshipToParent: "subagent", + }, + forkedFrom: null, + }), + ).toBe(true); + expect( + isInternalSubagentThread({ + lineage: rootLineage, + forkedFrom: { + type: "node", + nodeId: NodeId.make("node:visibility:subagent"), + }, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/threadVisibility.ts b/apps/web/src/threadVisibility.ts new file mode 100644 index 00000000000..9c71cb4a75e --- /dev/null +++ b/apps/web/src/threadVisibility.ts @@ -0,0 +1,9 @@ +import type { OrchestrationV2ThreadShell } from "@t3tools/contracts"; + +export const isInternalSubagentThread = ( + thread: Pick, +) => thread.lineage.relationshipToParent === "subagent" || thread.forkedFrom?.type === "node"; + +export const isUserFacingThread = ( + thread: Pick, +) => !isInternalSubagentThread(thread);