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

102 lines
3.3 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { computeNextSubtask, subtaskNotification } from "./subtask-update";
import type { Subtask } from "./types";
export interface SubtaskContextValue {
tasks: Record<string, Subtask>;
// Always mirrors the latest `tasks` (updated during render). `updateSubtask`
// reads/writes through this instead of a closure snapshot so async callers
// (e.g. a late-resolving backfill) merge into current state, not stale state.
tasksRef: React.RefObject<Record<string, Subtask>>;
setTasks: (tasks: Record<string, Subtask>) => void;
}
export const SubtaskContext = createContext<SubtaskContextValue>({
tasks: {},
tasksRef: { current: {} },
setTasks: () => {
/* noop */
},
});
export function SubtasksProvider({ children }: { children: React.ReactNode }) {
const [tasks, setTasks] = useState<Record<string, Subtask>>({});
const tasksRef = useRef(tasks);
// Keep the ref pointing at the freshest state on every render so reads in
// async callbacks (backfill `.then`) never see a stale map.
tasksRef.current = tasks;
return (
<SubtaskContext.Provider value={{ tasks, tasksRef, setTasks }}>
{children}
</SubtaskContext.Provider>
);
}
export function useSubtaskContext() {
const context = useContext(SubtaskContext);
if (context === undefined) {
throw new Error(
"useSubtaskContext must be used within a SubtaskContext.Provider",
);
}
return context;
}
export function useSubtask(id: string) {
const { tasks } = useSubtaskContext();
return tasks[id];
}
export function useUpdateSubtask() {
const { tasksRef, setTasks } = useSubtaskContext();
const shouldNotifyAfterRenderRef = useRef(false);
// No deps: must run after every render to check the ref set during render.
useEffect(() => {
if (!shouldNotifyAfterRenderRef.current) {
return;
}
shouldNotifyAfterRenderRef.current = false;
setTasks({ ...tasksRef.current });
});
const updateSubtask = useCallback(
(task: Partial<Subtask> & { id: string }) => {
// Read the *latest* state via the ref, never a `tasks` snapshot captured in
// this callback's closure. Without this, an in-flight
// fetchSubtaskSteps().then(updateSubtask) resolving late would write a stale
// map, clobbering SSE steps/status and sibling subtasks added meanwhile (#3779).
const current = tasksRef.current;
const { next, becameTerminal, changed } = computeNextSubtask(
current[task.id],
task,
);
current[task.id] = next;
// Gate on an actual state change, not mere field presence. The terminal
// ToolMessage is re-parsed on every MessageList render and always carries
// modelName/usage, so a presence check would setTasks({...}) with a fresh
// reference each render — an infinite loop. `subtaskNotification` routes a
// terminal transition through the deferred (after-render) path and skips
// no-op re-parses entirely.
const notify = subtaskNotification(task, { becameTerminal, changed });
if (notify === "eager") {
setTasks({ ...current });
} else if (notify === "deferred") {
shouldNotifyAfterRenderRef.current = true;
}
},
[tasksRef, setTasks],
);
return updateSubtask;
}