Nan Gao aafd5077b2
feat(subagents): show effective model and token usage on task cards (#4049)
* feat(subagents): show runtime metadata on task cards

* fix(subagents): stop task-card render loop and dedupe model fetches

Address code review on the runtime-metadata cards:

- P1 render loop: the terminal ToolMessage is re-parsed on every
  MessageList render and always carries modelName/usage, so the
  presence-based setTasks condition fired a fresh state object each
  render -> "Maximum update depth exceeded". computeNextSubtask now
  returns a value-compared `changed` flag and a pure subtaskNotification()
  routes terminal transitions through the deferred after-render path
  while skipping no-op re-parses.

- Per-card useModels refetch: add staleTime: Infinity to the ["models"]
  query so every subtask card shares one /api/models fetch instead of
  refetching on each mount.

* make format

* refactor(subagents): dedupe token-usage validators + tidy event narrowing

Address PR review follow-ups:

- DRY: extract one shared token-usage validator per side. Backend
  status_contract.normalize_token_usage() now backs both the terminal
  ToolMessage metadata and the subagent.step/.end run events
  (step_events.py), and frontend messages/usage.normalizeTokenUsage()
  backs both the live task_running event (lifecycle.ts) and the terminal
  ToolMessage metadata (subtask-result.ts). Prevents the input/output/
  total_tokens validation from drifting across the four former copies.

- Nit: onCustomEvent narrows event.type once instead of re-checking the
  object shape per branch; the redundant task_started early-return
  (already validated by taskEventToSubtaskUpdate) is dropped.
2026-07-11 15:41:57 +08:00

66 lines
1.6 KiB
TypeScript

import { normalizeTokenUsage } from "../messages/usage";
import type { Subtask } from "./types";
type TaskStartedEvent = {
type: "task_started";
task_id: string;
model_name?: unknown;
};
type TaskRunningEvent = {
type: "task_running";
task_id: string;
model_name?: unknown;
usage?: unknown;
};
/** Convert an additive task lifecycle event into a task-state update. */
export function taskEventToSubtaskUpdate(
event: unknown,
): (Partial<Subtask> & { id: string }) | null {
if (!isRecord(event)) {
return null;
}
const taskId = event.task_id;
if (typeof taskId !== "string" || !taskId.trim()) {
return null;
}
if (event.type === "task_started") {
const started = event as TaskStartedEvent;
const modelName =
typeof started.model_name === "string" && started.model_name.trim()
? started.model_name.trim()
: undefined;
return {
id: taskId,
...(modelName ? { modelName } : {}),
};
}
if (event.type === "task_running") {
const running = event as TaskRunningEvent;
const usage = normalizeTokenUsage(running.usage);
const modelName = normalizeModelName(running.model_name);
return usage || modelName
? {
id: taskId,
...(modelName ? { modelName } : {}),
...(usage ? { usage } : {}),
}
: null;
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function normalizeModelName(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}