diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx index ea66da928150c..983f82c9782a0 100644 --- a/apps/mobile/src/features/threads/SubagentRow.tsx +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -1,6 +1,10 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; -import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import { + formatSubagentUsage, + resolveSubagentContextUsage, + resolveSubagentMetadata, +} from "@t3tools/client-runtime/state/subagent-display"; import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { View } from "react-native"; @@ -26,6 +30,7 @@ type SubagentRowSubagent = Pick< | "status" | "progress" | "result" + | "usage" >; export function SubagentRow(props: { @@ -112,38 +117,42 @@ function SubagentMetadata(props: { parentProject, childProject, }); + const usage = formatSubagentUsage(resolveSubagentContextUsage(subagent, child)); return ( - - - - {provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""} - {modelLabel} - - {workspace.map(({ label, value }) => ( - // The row reads this label in place of the icon. collapsable keeps the - // view (and label) from being flattened away without making it a - // separate accessibility stop. - - · - - - {value} - - - ))} + + + + + {provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""} + {modelLabel} + + {workspace.map(({ label, value }) => ( + // The row reads this label in place of the icon. collapsable keeps the + // view (and label) from being flattened away without making it a + // separate accessibility stop. + + · + + + {value} + + + ))} + + {usage ? {usage} : null} ); } diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index cf99eafa04cbe..54d59adf267bb 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -89,6 +89,7 @@ export function ThreadSubagentGroup(props: { result: live?.result ?? item.result, progress: live?.progress ?? item.progress, model: live?.model ?? null, + usage: live?.usage, }; }); const grouped = agents.length > 1; diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 65b3731673ec4..28451b63489c6 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -8,6 +8,7 @@ import type { OrchestrationV2Subagent, } from "@t3tools/contracts"; import { + formatSubagentUsage, resolveSubagentMetadata, subagentDetailPreview, } from "@t3tools/client-runtime/state/subagent-display"; @@ -20,6 +21,7 @@ import { CircleDashedIcon, CircleXIcon, FolderIcon, + GaugeIcon, GitBranchIcon, TerminalIcon, } from "lucide-react"; @@ -49,9 +51,11 @@ export function SubagentTooltipContent(props: { status: OrchestrationV2TurnItemStatus; result?: string | null | undefined; progress?: string | null | undefined; + usage?: OrchestrationV2Subagent["usage"] | null; }) { const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); const preview = subagentDetailPreview(props); + const usage = formatSubagentUsage(props.usage ?? null); const model = props.model?.trim(); const modelSlug = props.provider ? resolveSelectableModel(props.provider.driver, model, props.provider.models) @@ -178,6 +182,12 @@ export function SubagentTooltipContent(props: { ); })} + {usage ? ( +
+ + {usage} +
+ ) : null} {preview ? (
diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx index 342fb968abe98..02b30e8e1c3e5 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.browser.test.tsx @@ -832,6 +832,76 @@ it("shows readable models and only differing workspace details in agent tooltips expect(text()).not.toContain("Worktree"); expect(text()).not.toContain("Workspace"); + expect(text()).not.toContain("context"); + expect(text()).not.toContain("tool uses"); + const contextAgent = { + ...projection.subagents[0], + origin: "provider_native", + driver: "claudeAgent", + providerInstanceId: "claude", + status: "running", + usage: { contextTokens: 41_100, toolUses: 2 }, + }; + state.projection = { ...projection, subagents: [contextAgent] }; + await update(); + expect(text()).toContain("41.1K context · 2 tool uses"); + + state.projection = { + ...projection, + subagents: [{ ...contextAgent, status: "completed" }], + }; + state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "running" } }]; + await update(); + expect(text()).not.toContain("context"); + expect(text()).not.toContain("tool uses"); + + state.projection = { ...projection, subagents: [contextAgent] }; + await update(); + expect(text()).toContain("41.1K context · 2 tool uses"); + state.projection = { + ...projection, + subagents: [{ ...contextAgent, usage: { contextTokens: 12_000, toolUses: 3 } }], + }; + await update(); + expect(text()).toContain("12K context · 3 tool uses"); + expect(text()).not.toContain("41.1K context"); + + state.projection = { + ...projection, + subagents: [ + { + ...contextAgent, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-09-16T12:01:00Z"), + }, + ], + }; + const completedChild = { + ...child, + status: "completed", + activityRunStatus: null, + latestRunRequestedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-16T12:01:00Z"), + }; + await rerender(completedChild); + await click(sectionToggle(false)); + expect(text()).toContain("41.1K context · 2 tool uses"); + const newerCompletedChild = { + ...completedChild, + latestRunRequestedAt: DateTime.makeUnsafe("2026-09-16T12:02:00Z"), + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-16T12:03:00Z"), + }; + await rerender(newerCompletedChild); + expect(text()).toContain("Worker"); + expect(text()).not.toContain("context"); + expect(text()).not.toContain("tool uses"); + await click(sectionToggle(true)); + + state.projection = projection; + await rerender(); + expect(text()).not.toContain("context"); + expect(text()).not.toContain("tool uses"); + for (const [model, expected] of [ [null, "Not reported"], ["", "Not reported"], diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index b4688c45f5710..9f8e5c6387485 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -11,7 +11,10 @@ import { projectedSubagentsToRuntime, type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; +import { + formatSubagentDisplayTitle, + resolveSubagentContextUsage, +} from "@t3tools/client-runtime/state/subagent-display"; import { deriveThreadRelationshipGraph, immediateThreadRelationships, @@ -198,6 +201,7 @@ function currentSubagent( : status, startedAt: startedAt ? DateTime.formatIso(startedAt) : null, completedAt: completedAt ? DateTime.formatIso(completedAt) : null, + contextUsage: newerRun ? null : agent.contextUsage, progress: null, result: null, error: null, @@ -487,6 +491,10 @@ export function ThreadRelationshipsPanel(props: { status={agent.status} result={agent.result} progress={agent.progress} + usage={resolveSubagentContextUsage( + { status: agent.nativeStatus, usage: agent.contextUsage }, + node?.thread, + )} parentThread={currentThread ?? undefined} childThread={node?.thread ?? undefined} parentProject={currentProject} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 3a59284ff4c7f..b6b5999d18279 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -16,7 +16,10 @@ import { type HandoffEndpoints, type HandoffTimelineRun, } from "@t3tools/client-runtime/handoff"; -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; +import { + formatSubagentDisplayTitle, + resolveSubagentContextUsage, +} from "@t3tools/client-runtime/state/subagent-display"; import { ProviderDriverKind, type OrchestrationV2Notification, @@ -517,6 +520,8 @@ function SubagentTimelineLink(props: { status={liveStatus} result={agent?.result ?? props.result} progress={agent?.progress ?? props.progress} + usage={agent?.usage} + taskStatus={agent?.status ?? props.status} /> @@ -526,6 +531,8 @@ function SubagentTimelineLink(props: { function SubagentTimelineTooltip( props: Parameters[0] & { model: string | null; + usage: OrchestrationV2Subagent["usage"]; + taskStatus: OrchestrationV2Subagent["status"]; modelSelection: OrchestrationV2Subagent["modelSelection"]; elapsed: ReactNode; }, @@ -557,6 +564,7 @@ function SubagentTimelineTooltip( status={props.status} result={props.result} progress={props.progress} + usage={resolveSubagentContextUsage({ status: props.taskStatus, usage: props.usage }, child)} parentThread={parent} childThread={child} parentProject={parentProject ?? undefined} diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index d4d0db34e2ac1..fc577645acbb1 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,10 +1,12 @@ import type { + OrchestrationV2Subagent, OrchestrationV2TurnItemStatus, OrchestrationV2ThreadShell, OrchestrationProjectShell, ServerProvider, } from "@t3tools/contracts"; import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { formatTokens } from "@t3tools/shared/usageFormat"; import { fileBasename } from "@t3tools/shared/path"; import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; @@ -122,3 +124,26 @@ export function subagentDetailPreview(input: { const compact = detail.replace(/\s+/gu, " "); return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null; } + +export function resolveSubagentContextUsage( + subagent: { + readonly status: OrchestrationV2Subagent["status"]; + readonly usage?: OrchestrationV2Subagent["usage"] | null; + }, + childThread?: Pick | null, +): OrchestrationV2Subagent["usage"] | null { + // A follow-up runs in the child thread without updating the settled task. + if ( + isTerminalSubagentStatus(subagent.status) && + (childThread?.activityRunStatus || childThread?.status === "queued") + ) { + return null; + } + return subagent.usage ?? null; +} + +export function formatSubagentUsage(usage: OrchestrationV2Subagent["usage"] | null): string | null { + if (!usage) return null; + const tools = `${usage.toolUses} ${usage.toolUses === 1 ? "tool use" : "tool uses"}`; + return `${formatTokens(usage.contextTokens)} context · ${tools}`; +} diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index 5cf96fc937d22..81f7da909a26a 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + OrchestrationV2SubagentJson, ProviderDriverKind, ProviderInstanceId, ThreadId, @@ -7,6 +8,7 @@ import { type OrchestrationV2SubagentWorkflow, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; import { deriveWorkflowGroups, @@ -58,6 +60,56 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { expect(agent?.phases).toEqual([]); }); + it("keeps wire context snapshots separate from cumulative workflow usage", () => { + const decode = Schema.decodeUnknownSync(OrchestrationV2SubagentJson); + const encode = Schema.encodeSync(OrchestrationV2SubagentJson); + const wire = { + ...subagent(), + threadId: "parent", + runId: "run", + parentNodeId: "root", + origin: "provider_native", + createdBy: "agent", + driver: "claudeAgent", + providerInstanceId: "claude", + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + startedAt: "2026-08-01T10:00:00.000Z", + updatedAt: "2026-08-01T10:00:05.000Z", + }; + for (const usage of [ + { contextTokens: 41_100, toolUses: 2 }, + { contextTokens: 12_000, toolUses: 3 }, + { contextTokens: 0, toolUses: 0 }, + undefined, + ]) { + const projected = decode({ ...wire, ...(usage === undefined ? {} : { usage }) }); + const roundTripped = decode(encode(projected)); + const [plain] = projectedSubagentsToRuntime([roundTripped]); + expect(plain).toMatchObject({ contextUsage: usage ?? null, usage: null }); + + const [coordinator, child] = projectedSubagentsToRuntime([ + { + ...roundTripped, + workflow: { + phases: [], + agents: [member({ totalTokens: 500, toolCalls: 1 })], + totalTokens: 1000, + }, + }, + ]); + expect(coordinator).toMatchObject({ + contextUsage: usage ?? null, + usage: { totalTokens: 1000 }, + }); + expect(child).toMatchObject({ + contextUsage: null, + usage: { totalTokens: 500, toolUses: 1 }, + }); + } + }); + it("projects a coordinator plus one row per member", () => { const rows = projectedSubagentsToRuntime([ subagent({ diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index cba84900ea099..043d75110e72f 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -55,6 +55,7 @@ export interface RuntimeSubagent { readonly status: RuntimeSubagentStatus; readonly activationCount: number; readonly usage: SubagentUsage | null; + readonly contextUsage?: OrchestrationV2Subagent["usage"] | null; readonly progress: string | null; readonly lastToolName: string | null; readonly result: string | null; @@ -152,6 +153,7 @@ function workflowMembersToRuntime( status, activationCount: agent.attempt ?? 1, usage: workflowUsage(agent), + contextUsage: null, progress: agent.prompt ?? null, result: failed ? null : (agent.result ?? null), error: failed ? (agent.result ?? null) : null, @@ -179,6 +181,7 @@ export function projectedSubagentsToRuntime( readonly model: string | null; readonly status: OrchestrationV2Subagent["status"]; readonly progress?: string | undefined; + readonly usage?: OrchestrationV2Subagent["usage"]; readonly childThreadId?: string | null | undefined; readonly workflow?: OrchestrationV2SubagentWorkflow | undefined; readonly result: string | null; @@ -205,6 +208,7 @@ export function projectedSubagentsToRuntime( status: subagent.status, activationCount: 1, usage: workflow === undefined ? null : workflowUsage(workflow), + contextUsage: subagent.usage ?? null, progress: subagent.progress ?? null, lastToolName: null, result: subagent.result, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 94a9eca331d13..d33f73a5718d3 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -707,6 +707,13 @@ export const OrchestrationV2Subagent = Schema.Struct({ "interrupted", ]), progress: Schema.optional(Schema.String), + /** Latest context snapshot; may decrease after compaction and must not be summed. */ + usage: Schema.optional( + Schema.Struct({ + contextTokens: NonNegativeInt, + toolUses: NonNegativeInt, + }), + ), workflow: Schema.optional(OrchestrationV2SubagentWorkflow), result: Schema.NullOr(Schema.String), startedAt: Schema.NullOr(Schema.DateTimeUtc),