Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/mobile/src/features/threads/SubagentRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
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,
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";
Expand All @@ -25,6 +28,7 @@ type SubagentRowSubagent = Pick<
| "prompt"
| "status"
| "progress"
| "usage"
| "result"
>;

Expand Down Expand Up @@ -110,6 +114,7 @@ function SubagentMetadata(props: {
parent ? scopeProjectRef(environmentId, parent.projectId) : null,
);
const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null);
const usage = formatSubagentUsage(subagent.usage ?? null);
const { modelLabel, workspace } = resolveSubagentMetadata({
model: subagent.model,
provider,
Expand All @@ -129,6 +134,11 @@ function SubagentMetadata(props: {
{provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""}
{modelLabel}
</Text>
{usage ? (
<Text className="shrink-0 text-xs text-foreground-muted" numberOfLines={1}>
· {usage}
</Text>
) : null}
{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
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/thread-subagent-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9343,6 +9343,97 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("records subagent usage from task frames and clears it on resume", () =>
Effect.scoped(
Effect.gen(function* () {
const TASK_ID = "task-subagent-usage";
const TOOL_USE_ID = "toolu-subagent-usage";
const harness = yield* makeWakeHarness;
const now = yield* DateTime.now;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-subagent-usage"),
text: "Run an auditor.",
attachments: [],
}),
);
const frames = [
makeSubagentTaskStartedFrame({
taskId: TASK_ID,
toolUseId: TOOL_USE_ID,
uuid: "00000000-0000-4000-8000-000000000361",
}),
claudeSdkFrame({
type: "system",
subtype: "task_progress",
task_id: TASK_ID,
tool_use_id: TOOL_USE_ID,
description: "Reading files",
usage: { total_tokens: 12_000, tool_uses: 2, duration_ms: 900 },
uuid: "00000000-0000-4000-8000-000000000362",
session_id: WAKE_NATIVE_SESSION,
}),
claudeSdkFrame({
type: "system",
subtype: "task_progress",
task_id: TASK_ID,
tool_use_id: TOOL_USE_ID,
description: "",
usage: { total_tokens: 13_000, tool_uses: 3, duration_ms: 1000 },
uuid: "00000000-0000-4000-8000-000000000366",
session_id: WAKE_NATIVE_SESSION,
}),
claudeSdkFrame({
type: "system",
subtype: "task_notification",
task_id: TASK_ID,
tool_use_id: TOOL_USE_ID,
status: "completed",
output_file: "",
summary: "Audit done.",
usage: { total_tokens: 18_500, tool_uses: 5, duration_ms: 2_400 },
uuid: "00000000-0000-4000-8000-000000000363",
session_id: WAKE_NATIVE_SESSION,
}),
// A resume reports fresh usage on its own frames.
makeSubagentTaskStartedFrame({
taskId: TASK_ID,
toolUseId: TOOL_USE_ID,
uuid: "00000000-0000-4000-8000-000000000365",
}),
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000364",
result: "The auditor finished.",
}),
];
for (const frame of frames) {
yield* Queue.offer(harness.sdkMessages, frame);
}
yield* Queue.take(harness.terminalReceipts);

const usages = harness.events.flatMap((event) =>
event.type === "subagent.updated" ? [event.subagent.usage] : [],
);
const progressUpdates = harness.events.filter((event) => event.type === "subagent.updated");
assert.equal(progressUpdates[2]?.subagent.progress, "Reading files");
assert.deepEqual(usages, [
undefined,
{ contextTokens: 12_000, toolUses: 2 },
{ contextTokens: 13_000, toolUses: 3 },
{ contextTokens: 18_500, toolUses: 5 },
undefined,
]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

it.effect("extracts text from direct content-block subagent results", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
30 changes: 23 additions & 7 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4817,6 +4817,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
readonly effort?: string;
readonly owner?: ActiveClaudeSubagent;
readonly progress?: string;
readonly usage?: { readonly total_tokens: number; readonly tool_uses: number };
/** Raw SDK frame, folded into the coordinator's workflow snapshot. */
readonly workflowFrame?: unknown;
readonly workflowName?: string;
Expand Down Expand Up @@ -4900,16 +4901,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const turnItemOrdinal =
existingSubagent?.turnItemOrdinal ??
(yield* resolveItemOrdinal(input.context, `${nativeItemId}:subagent`));
// A resumed subagent's previous final answer and progress no longer
// represent its outcome; the next task_progress/task_notification
// A resumed subagent's previous final answer, progress, and usage no
// longer represent its outcome; the next task_progress/task_notification
// carry the new ones.
const priorTask =
existingSubagent === undefined
? undefined
: isReopen
? (({ progress: _staleProgress, ...rest }) => ({ ...rest, result: null }))(
existingSubagent.task,
)
? (({ progress: _staleProgress, usage: _staleUsage, ...rest }) => ({
...rest,
result: null,
}))(existingSubagent.task)
: existingSubagent.task;
// Workflow telemetry is cumulative: fold this frame into whatever the
// coordinator already carried, so a usage-only frame cannot blank the
Expand Down Expand Up @@ -4985,6 +4987,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
: {}),
...(input.progress === undefined ? {} : { progress: input.progress }),
...(input.usage === undefined
? {}
: {
usage: {
contextTokens: input.usage.total_tokens,
toolUses: input.usage.tool_uses,
},
}),
...(workflowWithThreads === undefined ? {} : { workflow: workflowWithThreads }),
...(input.result === undefined ? {} : { result: input.result }),
...(isReopen ? { startedAt: now } : {}),
Expand Down Expand Up @@ -6227,7 +6237,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (registered === undefined || registered.task.status === "running") {
return current;
}
const { progress: _staleProgress, ...priorTask } = registered.task;
const {
progress: _staleProgress,
usage: _staleUsage,
...priorTask
} = registered.task;
return new Map(current).set(message.task_id, {
...registered,
task: {
Expand Down Expand Up @@ -7170,7 +7184,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
(yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.workflow !==
undefined;
if (
(carriesWorkflowTelemetry || (progress.length > 0 && !isWorkflowCoordinator)) &&
(carriesWorkflowTelemetry || !isWorkflowCoordinator) &&
!context.ignoredTaskIds.has(message.task_id) &&
!isBackgroundTask
) {
Expand All @@ -7179,6 +7193,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
taskId: message.task_id,
...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
...(progress.length === 0 ? {} : { progress }),
usage: message.usage,
workflowFrame: message,
status: "running",
});
Expand Down Expand Up @@ -7233,6 +7248,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
context,
taskId: message.task_id,
...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
...(message.usage === undefined ? {} : { usage: message.usage }),
// Terminal usage totals arrive here, after the last snapshot.
workflowFrame: message,
result: message.summary,
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/chat/SubagentTooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
OrchestrationV2Subagent,
} from "@t3tools/contracts";
import {
formatSubagentUsage,
resolveSubagentMetadata,
subagentDetailPreview,
} from "@t3tools/client-runtime/state/subagent-display";
Expand All @@ -20,6 +21,7 @@ import {
CircleDashedIcon,
CircleXIcon,
FolderIcon,
GaugeIcon,
GitBranchIcon,
TerminalIcon,
} from "lucide-react";
Expand Down Expand Up @@ -50,9 +52,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)
Expand Down Expand Up @@ -180,6 +184,12 @@ export function SubagentTooltipContent(props: {
</div>
);
})}
{usage ? (
<div className="flex min-w-0 items-center gap-2">
<GaugeIcon aria-hidden className="size-3 shrink-0" />
<span className="min-w-0 truncate tabular-nums">{usage}</span>
</div>
) : null}
{preview ? (
<div className="flex min-w-0 items-center gap-2">
<TerminalIcon aria-hidden className="size-3 shrink-0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,34 @@ it("shows readable models and only differing workspace details in agent tooltips
expect(text()).not.toContain("Worktree");
expect(text()).not.toContain("Workspace");

state.projection = {
...projection,
subagents: [
{
...projection.subagents[0],
status: "running",
usage: { contextTokens: 41_100, toolUses: 2 },
},
],
};
await act(async () => renderer.update(cloneElement(panel)));
expect(text()).toContain("41.1K context · 2 tool uses");
state.projection = {
...projection,
subagents: [
{
...projection.subagents[0],
status: "completed",
usage: { contextTokens: 41_100, toolUses: 2 },
},
],
};
state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "running" } }];
await act(async () => renderer.update(cloneElement(panel)));
expect(text()).not.toContain("41.1K context");
state.projection = projection;
await rerender();

for (const [model, expected] of [
[null, "Not reported"],
["", "Not reported"],
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ThreadRelationshipsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent";
import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import {
isTerminalSubagentStatus,
deriveWorkflowGroups,
projectedSubagentsToRuntime,
type RuntimeSubagent,
Expand Down Expand Up @@ -215,6 +216,8 @@ function currentSubagent<Agent extends RuntimeSubagent>(
progress: null,
result: null,
error: null,
// A settled task's usage is from that run too; a follow-up reports none.
contextUsage: isTerminalSubagentStatus(agent.status) ? null : agent.contextUsage,
};
}

Expand Down Expand Up @@ -515,6 +518,7 @@ export function ThreadRelationshipsPanel(props: {
status={agent.status}
result={agent.result}
progress={agent.progress}
usage={agent.contextUsage}
parentThread={currentThread ?? undefined}
childThread={node?.thread ?? undefined}
parentProject={currentProject}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ function SubagentTimelineLink(props: {
status={liveStatus}
result={agent?.result ?? props.result}
progress={agent?.progress ?? props.progress}
usage={agent?.usage}
/>
</ThreadHoverCardPopup>
</Tooltip>
Expand All @@ -535,6 +536,7 @@ function SubagentTimelineLink(props: {
function SubagentTimelineTooltip(
props: Parameters<typeof SubagentTimelineLink>[0] & {
model: string | null;
usage: OrchestrationV2Subagent["usage"];
modelSelection: OrchestrationV2Subagent["modelSelection"];
elapsed: ReactNode;
},
Expand Down Expand Up @@ -562,6 +564,7 @@ function SubagentTimelineTooltip(
status={props.status}
result={props.result}
progress={props.progress}
usage={props.usage}
parentThread={parent}
childThread={child}
parentProject={parentProject ?? undefined}
Expand Down
9 changes: 9 additions & 0 deletions packages/client-runtime/src/state/subagentDisplay.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -130,3 +132,10 @@ export function subagentDetailPreview(input: {
const compact = detail.replace(/\s+/gu, " ");
return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null;
}

/** Context size, not a running total, so callers must not sum it across agents. */
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}`;
}
Loading
Loading