diff --git a/apps/mobile/src/features/threads/SubagentStopButton.tsx b/apps/mobile/src/features/threads/SubagentStopButton.tsx
new file mode 100644
index 000000000000..8cd7b89701ca
--- /dev/null
+++ b/apps/mobile/src/features/threads/SubagentStopButton.tsx
@@ -0,0 +1,65 @@
+import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts";
+import { useState } from "react";
+import { View } from "react-native";
+
+import * as Dialog from "../../components/ui/Dialog";
+import { IconButton } from "../../components/ui/IconButton";
+import { orchestrationEnvironment } from "../../state/orchestration";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import {
+ resolveSubagentRowPresentation,
+ resolveSubagentStopTarget,
+} from "./threadAgentsPresentation";
+
+export function SubagentStopButton(props: {
+ readonly environmentId: EnvironmentId;
+ readonly subagent: OrchestrationV2Subagent;
+ readonly className?: string;
+}) {
+ const target = resolveSubagentStopTarget(props.subagent);
+ const canStop = useAtomValue(
+ orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId),
+ );
+ const stopWorkflow = useAtomCommand(orchestrationEnvironment.stopWorkflow);
+ const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn);
+ const [stopping, setStopping] = useState(false);
+
+ if (target === null || !canStop) return null;
+
+ const workflow = target.kind === "workflow";
+ const label =
+ props.subagent.workflow?.name ?? resolveSubagentRowPresentation(props.subagent).title;
+ const stop = async () => {
+ setStopping(true);
+ const result = await (
+ target.kind === "workflow"
+ ? stopWorkflow({ environmentId: props.environmentId, input: target.input })
+ : interruptTurn({ environmentId: props.environmentId, input: target.input })
+ ).finally(() => setStopping(false));
+ if (result._tag === "Failure") {
+ Dialog.alert(workflow ? "Could not stop workflow" : "Could not stop agent");
+ }
+ };
+
+ return (
+
+
+ Dialog.confirm({
+ title: workflow ? "Stop workflow?" : "Stop agent?",
+ message: workflow ? `Every running agent in ${label} stops.` : `Stop ${label}?`,
+ confirmText: "Stop",
+ destructive: true,
+ onConfirm: () => void stop(),
+ })
+ }
+ />
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
index 2fbf4349ab97..72ecaeda4acf 100644
--- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
@@ -1,4 +1,8 @@
import { useAtomValue } from "@effect/atom-react";
+import {
+ deriveWorkflowGroups,
+ projectedSubagentsToRuntime,
+} from "@t3tools/client-runtime/state/subagentRuntime";
import type { ThreadTurnSubagents } from "@t3tools/client-runtime/state/thread-subagents";
import {
isOrchestrationV2WorkActive,
@@ -11,7 +15,7 @@ import { StackActions, useNavigation, type StaticScreenProps } from "@react-navi
import * as DateTime from "effect/DateTime";
import { Atom } from "effect/reactivity";
import * as Haptics from "expo-haptics";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Platform, Pressable, ScrollView, View } from "react-native";
import { ScreenStack } from "react-native-screens";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -22,6 +26,8 @@ import { environmentThreadDetails } from "../../state/threads";
import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack";
import { SubagentRow } from "./SubagentRow";
+import { SubagentStopButton } from "./SubagentStopButton";
+import { ThreadWorkflowGroup } from "./ThreadWorkflowGroup";
import { ScreenContent } from "../../components/ui/ScreenContent";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
@@ -49,6 +55,16 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) {
const turn = useThreadTurnSubagents(target);
const subagents = turn?.subagents ?? [];
const hasLiveAgent = (turn?.liveCount ?? 0) > 0;
+ const workflowGroups = useMemo(
+ () =>
+ new Map(
+ deriveWorkflowGroups(projectedSubagentsToRuntime(subagents)).map((group) => [
+ group.workflow.id,
+ group,
+ ]),
+ ),
+ [subagents],
+ );
const openChildThread = (childThreadId: ThreadId) => {
void Haptics.selectionAsync();
@@ -77,15 +93,27 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) {
No agents in this turn.
) : (
- subagents.map((subagent) => (
-
- ))
+ subagents.map((subagent) => {
+ const group = workflowGroups.get(subagent.id);
+ return group === undefined ? (
+
+
+
+ ) : (
+
+ );
+ })
)}
);
@@ -141,7 +169,7 @@ function AgentRow(props: {
const childThreadId = subagent.childThreadId;
const row = (
-
+
);
- if (childThreadId === null) {
- return (
+ const content =
+ childThreadId === null ? (
{row}
+ ) : (
+ props.onOpen(childThreadId)}
+ className="min-w-0 flex-1 active:opacity-70"
+ >
+ {row}
+
);
- }
return (
- props.onOpen(childThreadId)}
- className="active:opacity-70"
- >
- {row}
-
+
+ {content}
+
+
);
}
diff --git a/apps/mobile/src/features/threads/ThreadWorkflowGroup.tsx b/apps/mobile/src/features/threads/ThreadWorkflowGroup.tsx
new file mode 100644
index 000000000000..7f728f9cb1bf
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadWorkflowGroup.tsx
@@ -0,0 +1,302 @@
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
+ isActiveSubagentStatus,
+ type RuntimeSubagent,
+ type SubagentWorkflowGroup,
+} from "@t3tools/client-runtime/state/subagentRuntime";
+import {
+ ThreadId,
+ type EnvironmentId,
+ type OrchestrationV2Subagent,
+ type ServerProvider,
+} from "@t3tools/contracts";
+import { deriveSubagentElapsedMs, formatDuration } from "@t3tools/shared/orchestrationTiming";
+import { useState, type ReactNode } from "react";
+import { ScrollView, View } from "react-native";
+
+import { AppText as Text } from "../../components/ui/AppText";
+import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol";
+import { Chip } from "../../components/ui/Chip";
+import { IconButton } from "../../components/ui/IconButton";
+import { ListRowLayout } from "../../components/ui/ListRowLayout";
+import { ListSection } from "../../components/ui/ListSection";
+import { RowPressable } from "../../components/ui/RowPressable";
+import { cn } from "../../lib/cn";
+import { useEnvironmentServerConfig, useThreadShell } from "../../state/entities";
+import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot";
+import { SubagentStopButton } from "./SubagentStopButton";
+import {
+ nextWorkflowSelection,
+ resolveSubagentRowPresentation,
+ workflowMemberDetail,
+ workflowMemberMetadata,
+ workflowRail,
+ type WorkflowRailTone,
+} from "./threadAgentsPresentation";
+import { useVisibleSecondClock } from "./use-visible-second-clock";
+
+const RAIL_ICON = {
+ working: "circle.inset.filled",
+ completed: "checkmark.circle.fill",
+ failed: "xmark.circle.fill",
+ stopped: "circle",
+ queued: "circle.dashed",
+} as const satisfies Record;
+
+export function ThreadWorkflowGroup(props: {
+ readonly environmentId: EnvironmentId;
+ readonly subagent: OrchestrationV2Subagent;
+ readonly group: SubagentWorkflowGroup;
+ readonly onOpen: (threadId: ThreadId) => void;
+}) {
+ const { group } = props;
+ const provider = useEnvironmentServerConfig(props.environmentId)?.providers.find(
+ (candidate) => candidate.instanceId === props.subagent.providerInstanceId,
+ );
+ const [selected, setSelected] = useState(null);
+ const rail = workflowRail(group);
+ const open = rail.find((item) => item.key === selected);
+ const label = group.workflow.workflowName ?? group.workflow.title;
+ const statusLabel = resolveSubagentRowPresentation(props.subagent).statusLabel;
+ const coordinatorThreadId = props.subagent.childThreadId;
+ const coordinatorShell = useThreadShell(
+ coordinatorThreadId === null ? null : scopeThreadRef(props.environmentId, coordinatorThreadId),
+ );
+
+ return (
+
+
+
+ setSelected(nextWorkflowSelection(rail, selected))}
+ className="min-w-0 flex-1 active:opacity-70"
+ >
+ 0 ? (
+
+ ) : null
+ }
+ />
+
+ {coordinatorThreadId !== null && coordinatorShell !== null ? (
+
+ props.onOpen(coordinatorThreadId)}
+ />
+
+ ) : null}
+
+
+ {rail.length > 0 ? (
+
+ {rail.map((item) => {
+ const count =
+ item.tone === "working" || item.tone === "failed"
+ ? `${item.completedCount}/${item.members.length}`
+ : null;
+ return (
+ setSelected(nextWorkflowSelection(rail, selected, item.key))}
+ />
+ );
+ })}
+
+ ) : null}
+ {open === undefined ? null : open.members.length === 0 ? (
+
+ No agents yet
+
+ ) : (
+ open.members.map((member) => (
+
+ ))
+ )}
+
+
+ );
+}
+
+function WorkflowMemberRow(props: {
+ readonly environmentId: EnvironmentId;
+ readonly member: RuntimeSubagent;
+ readonly provider: ServerProvider | undefined;
+ readonly onOpen: (threadId: ThreadId) => void;
+}) {
+ const { member } = props;
+ const threadId = member.childThreadId === null ? null : ThreadId.make(member.childThreadId);
+ const shell = useThreadShell(
+ threadId === null ? null : scopeThreadRef(props.environmentId, threadId),
+ );
+ const canOpen = threadId !== null && shell !== null;
+ const presentation = resolveSubagentRowPresentation({
+ title: member.title,
+ prompt: "",
+ status: member.status,
+ result: null,
+ childThreadId: threadId,
+ });
+ const { detail, error } = workflowMemberDetail(member);
+ const metadata = workflowMemberMetadata(member, props.provider);
+ const labels = [metadata.model, metadata.effort].filter((value) => value !== null);
+ return (
+ threadId !== null && props.onOpen(threadId)}
+ className="min-h-11 flex-row gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70"
+ >
+
+
+
+
+
+
+
+ {presentation.title}
+
+
+ ·
+
+
+ {presentation.statusLabel}
+
+
+
+ {canOpen ? (
+
+ ) : null}
+
+ {labels.length > 0 || metadata.speed !== null ? (
+
+
+ {labels.join(" · ")}
+ {labels.length > 0 && metadata.speed !== null ? " · " : ""}
+
+ {metadata.fast ? (
+
+
+
+ {metadata.speed}
+
+
+ ) : metadata.speed !== null ? (
+
+ {metadata.speed}
+
+ ) : null}
+
+ ) : null}
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
+
+function WorkflowHeaderLayout(props: {
+ readonly label: string;
+ readonly statusLabel: string;
+ readonly timing: ElapsedTiming;
+ readonly accessory: ReactNode;
+}) {
+ const elapsed = useElapsedLabel(props.timing);
+ return (
+
+ );
+}
+
+function WorkflowElapsed(props: { readonly timing: ElapsedTiming }) {
+ const elapsed = useElapsedLabel(props.timing);
+ return elapsed === null ? null : (
+
+ {elapsed}
+
+ );
+}
+
+type ElapsedTiming = Parameters[0];
+
+function useElapsedLabel(timing: ElapsedTiming): string | null {
+ const nowMs = useVisibleSecondClock(isActiveSubagentStatus(timing.status));
+ const elapsedMs = deriveSubagentElapsedMs(timing, nowMs);
+ return elapsedMs === null || elapsedMs === 0 ? null : formatDuration(elapsedMs);
+}
diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts
new file mode 100644
index 000000000000..faaa3059a9b5
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts
@@ -0,0 +1,363 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ deriveWorkflowGroups,
+ projectedSubagentsToRuntime,
+} from "@t3tools/client-runtime/state/subagentRuntime";
+import {
+ NodeId,
+ ProviderDriverKind,
+ RunId,
+ ThreadId,
+ type OrchestrationV2SubagentWorkflow,
+} from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+
+import {
+ nextWorkflowSelection,
+ resolveSubagentRowPresentation,
+ resolveSubagentStopTarget,
+ workflowMemberDetail,
+ workflowMemberMetadata,
+ workflowRail,
+} from "./threadAgentsPresentation";
+
+const base = {
+ title: null,
+ prompt: "Audit the timestamps",
+ status: "running" as const,
+ result: null,
+ childThreadId: "thread-child" as never,
+};
+
+describe("resolveSubagentRowPresentation", () => {
+ it("leads with progress while the agent is working", () => {
+ const row = resolveSubagentRowPresentation({
+ ...base,
+ progress: "Reading files",
+ result: "stale result",
+ });
+
+ expect(row.detail).toBe("Reading files");
+ expect(row.tone).toBe("working");
+ expect(row.live).toBe(true);
+ });
+
+ it("leads with the result once the agent has settled", () => {
+ const row = resolveSubagentRowPresentation({
+ ...base,
+ status: "completed",
+ progress: "Reading files",
+ result: "Found two\n problems",
+ });
+
+ expect(row.detail).toBe("Found two problems");
+ expect(row.tone).toBe("completed");
+ expect(row.statusLabel).toBe("Completed");
+ });
+
+ it("shows a failure's text, since that is where the error lands", () => {
+ const row = resolveSubagentRowPresentation({ ...base, status: "failed", result: "Timed out" });
+
+ expect(row.detail).toBe("Timed out");
+ expect(row.tone).toBe("failed");
+ });
+
+ it("falls back to a trimmed prompt when the agent has no title", () => {
+ const long = resolveSubagentRowPresentation({ ...base, prompt: "x".repeat(200) });
+ const titled = resolveSubagentRowPresentation({ ...base, title: "Subagent: /root/my_worker" });
+
+ expect(long.title).toHaveLength(80);
+ expect(long.title.endsWith("...")).toBe(true);
+ expect(titled.title).toBe("My Worker");
+ });
+
+ it("only offers a thread to open when the agent has one", () => {
+ expect(resolveSubagentRowPresentation(base).canOpenThread).toBe(true);
+ expect(resolveSubagentRowPresentation({ ...base, childThreadId: null }).canOpenThread).toBe(
+ false,
+ );
+ });
+
+ it("uses the status label when there is nothing to report yet", () => {
+ const row = resolveSubagentRowPresentation({ ...base, status: "pending" });
+
+ expect(row.detail).toBeNull();
+ expect(row.statusLabel).toBe("Working");
+ });
+
+ it("bounds long result previews without dropping the agent's status", () => {
+ const row = resolveSubagentRowPresentation({
+ ...base,
+ status: "failed",
+ result: "x".repeat(400),
+ });
+ expect(row.detail).toBe(`${"x".repeat(280)}…`);
+ expect(row.statusLabel).toBe("Failed");
+ });
+});
+
+type WorkflowAgent = OrchestrationV2SubagentWorkflow["agents"][number];
+
+const workflowRailOf = (
+ agents: ReadonlyArray,
+ status: "running" | "completed" = "running",
+) =>
+ workflowRail(
+ deriveWorkflowGroups(
+ projectedSubagentsToRuntime([
+ {
+ id: "workflow",
+ title: "Mobile Stop review",
+ prompt: "",
+ model: null,
+ status,
+ result: null,
+ startedAt: DateTime.makeUnsafe("2026-08-01T10:00:00.000Z"),
+ completedAt: null,
+ updatedAt: DateTime.makeUnsafe("2026-08-01T10:00:05.000Z"),
+ workflow: {
+ phases: [
+ { index: 0, title: "Inspect" },
+ { index: 1, title: "Verify" },
+ { index: 2, title: "Report" },
+ ],
+ agents,
+ },
+ },
+ ]),
+ )[0]!,
+ );
+
+const agent = (
+ index: number,
+ phaseIndex: number | undefined,
+ state: WorkflowAgent["state"],
+ overrides: Partial = {},
+): WorkflowAgent => ({
+ index,
+ label: `agent ${index}`,
+ state,
+ ...(phaseIndex === undefined ? {} : { phaseIndex }),
+ ...overrides,
+});
+
+describe("workflowRail", () => {
+ it("keeps declared phases without agents and gives unphased agents a stop", () => {
+ const rail = workflowRailOf([
+ agent(1, 0, "completed"),
+ agent(2, 1, "running"),
+ agent(3, undefined, "queued"),
+ ]);
+
+ expect(rail.map((item) => [item.title, item.tone, item.members.length])).toEqual([
+ ["Inspect", "completed", 1],
+ ["Verify", "working", 1],
+ ["Report", "queued", 0],
+ ["Other", "queued", 1],
+ ]);
+ });
+
+ it("counts only completed agents toward a failed phase's progress", () => {
+ const [phase] = workflowRailOf([agent(1, 0, "failed"), agent(2, 0, "completed")], "completed");
+
+ expect([phase!.tone, phase!.completedCount, phase!.members.length]).toEqual(["failed", 1, 2]);
+ });
+});
+
+describe("nextWorkflowSelection", () => {
+ const running = workflowRailOf([agent(1, 0, "completed"), agent(2, 1, "running")]);
+
+ it("opens the running phase from the collapsed title, then collapses", () => {
+ expect(nextWorkflowSelection(running, null)).toBe("1");
+ expect(nextWorkflowSelection(running, "1")).toBeNull();
+ });
+
+ it("prefers the failed phase, then the latest phase with agents", () => {
+ const failed = workflowRailOf([agent(1, 0, "failed"), agent(2, 1, "completed")], "completed");
+ const done = workflowRailOf([agent(1, 0, "completed"), agent(2, 1, "completed")], "completed");
+
+ expect(nextWorkflowSelection(failed, null)).toBe("0");
+ expect(nextWorkflowSelection(done, null)).toBe("1");
+ expect(nextWorkflowSelection(workflowRailOf([]), null)).toBeNull();
+ });
+
+ it("keeps one phase open: another replaces it and the open one collapses the card", () => {
+ expect(nextWorkflowSelection(running, null, "0")).toBe("0");
+ expect(nextWorkflowSelection(running, "1", "0")).toBe("0");
+ expect(nextWorkflowSelection(running, "0", "0")).toBeNull();
+ });
+
+ it("keeps the chosen phase open after the run settles", () => {
+ const settled = workflowRailOf(
+ [agent(1, 0, "completed"), agent(2, 1, "completed")],
+ "completed",
+ );
+
+ expect(nextWorkflowSelection(settled, "0", "1")).toBe("1");
+ expect(nextWorkflowSelection(settled, "0", "0")).toBeNull();
+ expect(settled.find((item) => item.key === "0")?.members).toHaveLength(1);
+ });
+});
+
+describe("workflow member rows", () => {
+ it("keeps reported model, effort, and speed once an agent completes", () => {
+ const [completed] = workflowRailOf(
+ [
+ agent(1, 0, "completed", {
+ model: "claude-opus-5-5",
+ effort: "high",
+ speed: "fast",
+ result: "Checked controls",
+ }),
+ ],
+ "completed",
+ )[0]!.members;
+
+ expect(workflowMemberMetadata(completed!, undefined)).toEqual({
+ model: "Claude Opus 5.5",
+ effort: "High",
+ speed: "Fast",
+ fast: true,
+ });
+ expect(workflowMemberDetail(completed!)).toEqual({ detail: "Checked controls", error: null });
+ });
+
+ it("reports nothing the provider did not send", () => {
+ expect(workflowMemberMetadata({ model: null, effort: null }, undefined)).toEqual({
+ model: null,
+ effort: null,
+ speed: null,
+ fast: false,
+ });
+ });
+
+ it("labels effort from the catalog model a reported alias resolves to", () => {
+ expect(
+ workflowMemberMetadata(
+ { model: "opus", effort: "xhigh" },
+ {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ models: [
+ {
+ slug: "claude-opus-5-5",
+ name: "Claude Opus 5.5",
+ shortName: "Opus 5.5",
+ aliases: ["opus"],
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "effort",
+ label: "Reasoning",
+ type: "select",
+ options: [{ id: "xhigh", label: "Extra High" }],
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ),
+ ).toMatchObject({ model: "Opus 5.5", effort: "Extra High" });
+ });
+
+ it("shows a failed agent's error under its prompt", () => {
+ const [failed] = workflowRailOf([
+ agent(1, 0, "failed", { prompt: "Update session store", result: "Type error" }),
+ ])[0]!.members;
+
+ expect(workflowMemberDetail(failed!)).toEqual({
+ detail: "Update session store",
+ error: "Type error",
+ });
+ });
+});
+
+const stopAgent = {
+ id: NodeId.make("agent"),
+ threadId: ThreadId.make("parent"),
+ childThreadId: ThreadId.make("child"),
+ status: "running" as const,
+ origin: "app_owned" as const,
+ driver: ProviderDriverKind.make("codex"),
+ runId: RunId.make("run"),
+ nativeTaskRef: null,
+};
+
+describe("resolveSubagentStopTarget", () => {
+ it.each(["pending", "running", "waiting"] as const)(
+ "interrupts only the child of an app-owned %s agent",
+ (status) => {
+ expect(resolveSubagentStopTarget({ ...stopAgent, status })).toEqual({
+ kind: "subagent",
+ input: { threadId: stopAgent.childThreadId },
+ });
+ expect(resolveSubagentStopTarget({ ...stopAgent, status, childThreadId: null })).toBeNull();
+ },
+ );
+
+ it.each(["idle", "completed", "failed", "cancelled", "interrupted"] as const)(
+ "offers no Stop for a %s agent",
+ (status) => {
+ expect(resolveSubagentStopTarget({ ...stopAgent, status })).toBeNull();
+ },
+ );
+
+ const native = {
+ ...stopAgent,
+ origin: "provider_native" as const,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ childThreadId: null,
+ nativeTaskRef: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ strength: "strong" as const,
+ nativeId: "native-task",
+ },
+ };
+
+ it("targets a supported native task without interrupting its parent run", () => {
+ expect(resolveSubagentStopTarget(native)).toEqual({
+ kind: "subagent",
+ input: { threadId: native.threadId, subagentId: native.id },
+ });
+ });
+
+ it("hides native Stop when the task cannot be identified or stopped", () => {
+ expect(resolveSubagentStopTarget({ ...native, nativeTaskRef: null })).toBeNull();
+ expect(
+ resolveSubagentStopTarget({
+ ...native,
+ nativeTaskRef: { ...native.nativeTaskRef, strength: "weak" },
+ }),
+ ).toBeNull();
+ expect(
+ resolveSubagentStopTarget({
+ ...native,
+ nativeTaskRef: { ...native.nativeTaskRef, nativeId: null },
+ }),
+ ).toBeNull();
+ expect(resolveSubagentStopTarget({ ...native, runId: null })).toBeNull();
+ expect(resolveSubagentStopTarget({ ...native, status: "completed" })).toBeNull();
+ expect(
+ resolveSubagentStopTarget({ ...native, driver: ProviderDriverKind.make("codex") }),
+ ).toBeNull();
+ });
+
+ it("stops the whole workflow through its own command", () => {
+ const workflow = { name: "Review", phases: [], agents: [] };
+ expect(resolveSubagentStopTarget({ ...native, workflow })).toEqual({
+ kind: "workflow",
+ input: { threadId: native.threadId, subagentId: native.id },
+ });
+ expect(resolveSubagentStopTarget({ ...native, workflow, status: "waiting" })).toBeNull();
+ expect(resolveSubagentStopTarget({ ...stopAgent, workflow })).toBeNull();
+ expect(resolveSubagentStopTarget({ ...native, workflow, nativeTaskRef: null })).toBeNull();
+ expect(
+ resolveSubagentStopTarget({
+ ...native,
+ workflow,
+ nativeTaskRef: { ...native.nativeTaskRef, nativeId: "" },
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts
index 96981ab3a38a..4f36eff03883 100644
--- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts
+++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts
@@ -1,9 +1,17 @@
import {
formatSubagentDisplayTitle,
+ resolveSubagentMetadata,
subagentDetailPreview,
} from "@t3tools/client-runtime/state/subagent-display";
-import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
-import type { OrchestrationV2Subagent } from "@t3tools/contracts";
+import {
+ isActiveSubagentStatus,
+ type RuntimeSubagent,
+ type SubagentWorkflowGroup,
+} from "@t3tools/client-runtime/state/subagentRuntime";
+import type { OrchestrationV2Subagent, ServerProvider } from "@t3tools/contracts";
+import { resolveSelectableModel } from "@t3tools/shared/model";
+
+import { subagentCardDetail } from "./subagent-card-presentation";
const PROMPT_TITLE_LIMIT = 80;
@@ -71,3 +79,145 @@ export function resolveSubagentRowPresentation(
canOpenThread: subagent.childThreadId !== null,
};
}
+
+export type WorkflowRailTone = SubagentRowTone | "queued";
+
+export interface WorkflowRailItem {
+ readonly key: string;
+ readonly title: string;
+ readonly members: ReadonlyArray;
+ readonly completedCount: number;
+ readonly tone: WorkflowRailTone;
+}
+
+function railTone(members: ReadonlyArray): WorkflowRailTone {
+ if (members.every((member) => member.status === "pending")) return "queued";
+ if (members.some((member) => isActiveSubagentStatus(member.status) || member.status === "idle")) {
+ return "working";
+ }
+ if (members.some((member) => member.status === "failed")) return "failed";
+ return members.every((member) => member.status === "completed") ? "completed" : "stopped";
+}
+
+export function workflowRail(group: SubagentWorkflowGroup): ReadonlyArray {
+ const items = group.phases.map((phase) => ({
+ key: String(phase.index),
+ title: phase.title,
+ members: phase.members,
+ }));
+ if (group.unphasedMembers.length > 0) {
+ items.push({
+ key: "unphased",
+ title: items.length === 0 ? "Agents" : "Other",
+ members: group.unphasedMembers,
+ });
+ }
+ return items.map((item) => ({
+ ...item,
+ completedCount: item.members.filter((member) => member.status === "completed").length,
+ tone: railTone(item.members),
+ }));
+}
+
+export function nextWorkflowSelection(
+ rail: ReadonlyArray,
+ selected: string | null,
+ tapped?: string,
+): string | null {
+ const open = rail.some((item) => item.key === selected) ? selected : null;
+ if (tapped !== undefined) return tapped === open ? null : tapped;
+ if (open !== null) return null;
+ const filled = rail.filter((item) => item.members.length > 0);
+ return (
+ (
+ filled.find((item) => item.tone === "working") ??
+ filled.find((item) => item.tone === "failed") ??
+ filled.at(-1)
+ )?.key ?? null
+ );
+}
+
+const EFFORT_OPTION_IDS = new Set(["reasoningEffort", "effort", "reasoning", "variant"]);
+
+function capitalize(value: string): string {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+export function workflowMemberMetadata(
+ member: Pick,
+ provider: Pick | undefined,
+) {
+ const model = member.model?.trim()
+ ? resolveSubagentMetadata({ model: member.model, provider }).modelLabel
+ : null;
+ const slug = provider
+ ? resolveSelectableModel(provider.driver, member.model, provider.models)
+ : null;
+ const effortChoice = provider?.models
+ .find((candidate) => candidate.slug === slug)
+ ?.capabilities?.optionDescriptors?.flatMap((descriptor) =>
+ descriptor.type === "select" && EFFORT_OPTION_IDS.has(descriptor.id)
+ ? descriptor.options
+ : [],
+ )
+ .find((option) => option.id === member.effort);
+ return {
+ model,
+ effort: member.effort ? (effortChoice?.label ?? capitalize(member.effort)) : null,
+ speed: member.speed ? capitalize(member.speed) : null,
+ fast: member.speed === "fast",
+ };
+}
+
+export function workflowMemberDetail(
+ member: Pick,
+) {
+ return {
+ detail: subagentCardDetail(subagentDetailPreview(member)),
+ error: member.status === "failed" ? subagentCardDetail(member.error) : null,
+ };
+}
+
+export function resolveSubagentStopTarget(
+ subagent: Pick<
+ OrchestrationV2Subagent,
+ | "id"
+ | "threadId"
+ | "childThreadId"
+ | "status"
+ | "origin"
+ | "driver"
+ | "runId"
+ | "nativeTaskRef"
+ | "workflow"
+ >,
+) {
+ if (!isActiveSubagentStatus(subagent.status)) return null;
+ if (subagent.workflow !== undefined) {
+ return subagent.status === "running" &&
+ subagent.origin === "provider_native" &&
+ subagent.driver === "claudeAgent" &&
+ Boolean(subagent.nativeTaskRef?.nativeId)
+ ? {
+ kind: "workflow" as const,
+ input: { threadId: subagent.threadId, subagentId: subagent.id },
+ }
+ : null;
+ }
+ if (subagent.origin === "app_owned" && subagent.childThreadId !== null) {
+ return { kind: "subagent" as const, input: { threadId: subagent.childThreadId } };
+ }
+ if (
+ subagent.origin === "provider_native" &&
+ subagent.driver === "claudeAgent" &&
+ subagent.runId !== null &&
+ subagent.nativeTaskRef?.strength === "strong" &&
+ subagent.nativeTaskRef.nativeId !== null
+ ) {
+ return {
+ kind: "subagent" as const,
+ input: { threadId: subagent.threadId, subagentId: subagent.id },
+ };
+ }
+ return null;
+}
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index b231fbfca9d9..3ccd6311062b 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -11206,7 +11206,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
yield* fileSystem.makeDirectory(transcriptDir, { recursive: true });
yield* fileSystem.writeFileString(
NodePath.join(transcriptDir, "agent-a1.jsonl"),
- `{"type":"assistant","uuid":"wf-line-1-${options?.nativeSessionId ?? "default"}","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a1","content":[{"type":"text","text":"A1 from the transcript"}]}}\n`,
+ `{"type":"assistant","effort":"xhigh","uuid":"wf-line-1-${options?.nativeSessionId ?? "default"}","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a1","usage":{"speed":"standard"},"content":[{"type":"text","text":"A1 from the transcript"}]}}\n`,
);
const harness = yield* makeWakeHarnessWithOptions({
...options,
@@ -11559,6 +11559,167 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect.each([false, true])(
+ "scopes workflow member state to each run, late launch handles=%s",
+ (lateHandles) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeWorkflowHarness;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const nextFirstMember = Effect.fnUntraced(function* () {
+ let event = yield* Queue.take(harness.subagentReceipts);
+ while (event.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:1") !== true) {
+ event = yield* Queue.take(harness.subagentReceipts);
+ }
+ return event.subagent;
+ });
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make(`attempt-workflow-run-state-${lateHandles}`),
+ text: "Run the workflow twice.",
+ attachments: [],
+ }),
+ );
+ yield* harness.offerAndWait(workflowToolUse);
+ yield* harness.offerAndWait(workflowTaskStarted);
+ const firstSnapshot = workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000004001",
+ state: "start",
+ attempt: 3,
+ startedAt: 1_000,
+ });
+ if (lateHandles) yield* harness.offerAndWait(firstSnapshot);
+ yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
+ if (!lateHandles) yield* harness.offerAndWait(firstSnapshot);
+ const first = yield* nextFirstMember();
+ assert.equal(first.status, "running");
+ assert.equal(
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]
+ ?.childThreadId,
+ first.childThreadId,
+ );
+ assert.lengthOf(workflowMemberEvents(harness.events, 1), 1);
+ yield* harness.until(
+ () => threadMessages(harness.events, first.childThreadId).length === 2,
+ );
+ yield* harness.offerAndWait(
+ workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000004002",
+ state: "done",
+ attempt: 4,
+ }),
+ );
+ const firstCompleted = yield* nextFirstMember();
+ assert.equal(firstCompleted.id, first.id);
+ assert.equal(firstCompleted.childThreadId, first.childThreadId);
+ yield* harness.offerAndWait(
+ workflowNotification({
+ uuid: "00000000-0000-4000-8000-000000004003",
+ status: "completed",
+ }),
+ );
+ const firstMessages = threadMessages(harness.events, first.childThreadId);
+ assert.equal(firstMessages.at(-1)?.message.text, "A1 from the transcript");
+ const firstThread = harness.events.find(
+ (event) =>
+ event.type === "app_thread.created" && event.appThread.id === first.childThreadId,
+ );
+ assert.isTrue(firstThread?.type === "app_thread.created");
+ yield* TestClock.adjust("60 seconds");
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ ...workflowTaskStarted,
+ uuid: "00000000-0000-4000-8000-000000004004",
+ }),
+ );
+ const secondTranscriptDir = NodePath.join(harness.transcriptDir, "second-run");
+ yield* fileSystem.makeDirectory(secondTranscriptDir);
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ ...workflowLaunchAck(secondTranscriptDir),
+ uuid: "00000000-0000-4000-8000-000000004005",
+ tool_use_result: {
+ taskType: "local_workflow",
+ runId: "wf_second",
+ transcriptDir: secondTranscriptDir,
+ },
+ }),
+ );
+ yield* harness.offerAndWait(
+ workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000004006",
+ state: "queued",
+ }),
+ );
+ const second = yield* nextFirstMember();
+ assert.equal(second.status, "pending");
+ assert.notEqual(second.id, first.id);
+ assert.notEqual(second.childThreadId, first.childThreadId);
+ assert.isNull(second.startedAt);
+ assert.isNull(second.completedAt);
+ assert.isNull(second.result);
+ const secondThread = harness.events.find(
+ (event) =>
+ event.type === "app_thread.created" && event.appThread.id === second.childThreadId,
+ );
+ assert.isTrue(secondThread?.type === "app_thread.created");
+ if (
+ firstThread?.type === "app_thread.created" &&
+ secondThread?.type === "app_thread.created"
+ ) {
+ assert.notDeepEqual(secondThread.appThread.createdAt, firstThread.appThread.createdAt);
+ }
+ assert.deepEqual(
+ threadMessages(harness.events, second.childThreadId).map((event) => event.message.text),
+ ["Reply with exactly: A1"],
+ );
+ yield* harness.offerAndWait(
+ workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000004007",
+ state: "done",
+ omitResult: true,
+ }),
+ );
+ const secondCompleted = yield* nextFirstMember();
+ assert.isNull(secondCompleted.startedAt);
+ assert.notDeepEqual(secondCompleted.completedAt, firstCompleted.completedAt);
+ assert.lengthOf(threadMessages(harness.events, second.childThreadId), 1);
+ yield* fileSystem.writeFileString(
+ NodePath.join(secondTranscriptDir, "agent-a1.jsonl"),
+ '{"type":"assistant","uuid":"wf-second-run-answer","message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"A1 from the transcript"}]}}\n',
+ );
+ yield* harness.offerAndWait(
+ workflowNotification({
+ uuid: "00000000-0000-4000-8000-000000004008",
+ status: "completed",
+ }),
+ );
+ yield* harness.offerAndWait(
+ makeResultFrame({ uuid: "00000000-0000-4000-8000-000000004009", result: "Done." }),
+ );
+ yield* Queue.take(harness.terminalReceipts);
+ const secondMessages = threadMessages(harness.events, second.childThreadId);
+ assert.deepEqual(
+ secondMessages.map((event) => event.message.text),
+ ["Reply with exactly: A1", "A1 from the transcript"],
+ );
+ assert.isTrue(
+ secondMessages.every((event) =>
+ firstMessages.every((prior) => prior.message.id !== event.message.id),
+ ),
+ );
+ assert.deepEqual(threadMessages(harness.events, first.childThreadId), firstMessages);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ ),
+ );
+
it.effect("stops only a live workflow task and waits for its stopped notification", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -12466,6 +12627,461 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect.each([
+ ["stopped", false, false, false],
+ ["failed", false, false, false],
+ ["completed", false, false, false],
+ ["stopped", true, false, false],
+ ["failed", true, false, false],
+ ["completed", true, false, false],
+ ["interrupted", false, false, false],
+ ["interrupted", true, false, false],
+ ["interrupt_timeout", true, false, false],
+ ["stop_buffered", true, false, false],
+ ["stop_buffered", true, false, "buffered"],
+ ["stop_buffered", true, false, "live"],
+ ["drain_buffered", true, false, "live"],
+ ["drain_buffered", true, false, "live_after_buffered"],
+ ["query_failed", false, false, false],
+ ["query_failed", true, false, false],
+ ["query_failed", true, false, "buffered"],
+ ["query_failed", true, true, false],
+ ] as const)(
+ "projects workflow members on %s, between turns=%s, after an unrelated failed turn=%s, resume=%s",
+ ([status, betweenTurns, afterFailedTurn, resumed]) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const interrupted = status === "interrupted" || status === "interrupt_timeout";
+ const liveResume = resumed === "live" || resumed === "live_after_buffered";
+ const expected =
+ status === "drain_buffered"
+ ? resumed === "live_after_buffered"
+ ? "completed"
+ : "running"
+ : status === "stopped" || interrupted || (status === "stop_buffered" && resumed)
+ ? "cancelled"
+ : status === "query_failed"
+ ? "failed"
+ : status === "stop_buffered"
+ ? "completed"
+ : status;
+ const harness = yield* makeWorkflowHarnessWithOptions({
+ close: status === "interrupt_timeout" ? () => Effect.void : Queue.shutdown,
+ });
+ const now = yield* DateTime.now;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-workflow-terminal"),
+ text: "Run the workflow.",
+ attachments: [],
+ }),
+ );
+ yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001010" });
+ if (betweenTurns) {
+ yield* Queue.offer(
+ harness.sdkMessages,
+ makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000001013",
+ result: "Launched.",
+ }),
+ );
+ yield* Queue.take(harness.terminalReceipts);
+ if (afterFailedTurn || status === "interrupt_timeout") {
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-unrelated-failed"),
+ providerTurnOrdinal: 2,
+ text: "Unrelated follow-up.",
+ attachments: [],
+ }),
+ );
+ if (afterFailedTurn) {
+ yield* Queue.offer(
+ harness.sdkMessages,
+ makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000001015",
+ result: "Unrelated follow-up failed.",
+ terminalReason: "api_error",
+ isError: true,
+ }),
+ );
+ yield* Queue.take(harness.terminalReceipts);
+ }
+ }
+ if (status === "query_failed") {
+ yield* harness.offerAndWait(
+ workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000001014",
+ state: "start",
+ label: "Updated between turns",
+ }),
+ );
+ }
+ }
+ const bufferedCompletion =
+ status === "stop_buffered" || status === "drain_buffered" || resumed;
+ if ((!interrupted && status !== "query_failed") || resumed)
+ yield* Queue.offer(
+ harness.sdkMessages,
+ workflowNotification({
+ uuid: "00000000-0000-4000-8000-000000001011",
+ status: bufferedCompletion ? "completed" : status,
+ }),
+ );
+ if (betweenTurns && ((status !== "query_failed" && !interrupted) || resumed)) {
+ yield* Queue.take(harness.continuationReceipts);
+ }
+ if (resumed) {
+ if (resumed === "live_after_buffered") {
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ ...workflowTaskStarted,
+ tool_use_id: "toolu-workflow-earlier-resume",
+ prompt: "Earlier buffered resume.",
+ uuid: "00000000-0000-4000-8000-000000001019",
+ }),
+ );
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ ...workflowNotification({
+ uuid: "00000000-0000-4000-8000-000000001020",
+ status: "completed",
+ }),
+ tool_use_id: "toolu-workflow-earlier-resume",
+ }),
+ );
+ }
+ if (liveResume) {
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-workflow-live-resume"),
+ providerTurnOrdinal: 2,
+ text: "Continue the workflow.",
+ attachments: [],
+ }),
+ );
+ }
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ ...workflowTaskStarted,
+ tool_use_id: "toolu-workflow-resume",
+ prompt: "Continue the workflow.",
+ uuid: "00000000-0000-4000-8000-000000001016",
+ }),
+ );
+ yield* harness.offerAndWait(
+ workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000001017",
+ state: "start",
+ attempt: 2,
+ label: "Updated between turns",
+ }),
+ );
+ if (liveResume) {
+ if (resumed === "live_after_buffered") {
+ yield* harness.offerAndWait(
+ claudeSdkFrame({
+ ...workflowNotification({
+ uuid: "00000000-0000-4000-8000-000000001021",
+ status: "completed",
+ }),
+ tool_use_id: "toolu-workflow-resume",
+ summary: "Latest workflow result.",
+ }),
+ );
+ }
+ yield* harness.offerAndWait(
+ makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000001018",
+ result: "Resumed the workflow.",
+ }),
+ );
+ yield* Queue.take(harness.terminalReceipts);
+ }
+ }
+ if (betweenTurns && status !== "query_failed" && !interrupted && !bufferedCompletion) {
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"),
+ text: "Workflow finished.",
+ attachments: [],
+ providerTurnOrdinal: 2,
+ messageCreatedBy: "agent",
+ messageCreationSource: "provider",
+ }),
+ );
+ }
+ if (interrupted || status === "stop_buffered") {
+ const interrupt = harness.runtime.interruptTurn({
+ providerThread: harness.providerThread,
+ providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
+ driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
+ nativeTurnId:
+ status === "interrupt_timeout"
+ ? "turn:attempt-unrelated-failed"
+ : liveResume
+ ? "turn:attempt-workflow-live-resume"
+ : "turn:attempt-workflow-terminal",
+ }),
+ ...(betweenTurns ? { requestRuntimeRestart: true } : {}),
+ });
+ if (status === "interrupt_timeout") {
+ const stop = yield* interrupt.pipe(Effect.forkScoped);
+ yield* TestClock.adjust("10 seconds");
+ yield* Fiber.join(stop);
+ assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted");
+ assert.isFalse(yield* harness.hasPendingBackgroundWork);
+ } else {
+ yield* interrupt;
+ }
+ } else if (status === "query_failed") {
+ yield* Queue.shutdown(harness.sdkMessages);
+ } else if (status === "drain_buffered") {
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"),
+ text: "Workflow finished.",
+ attachments: [],
+ providerTurnOrdinal: 3,
+ messageCreatedBy: "agent",
+ messageCreationSource: "provider",
+ }),
+ );
+ assert.equal(
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
+ expected,
+ );
+ } else {
+ yield* Queue.offer(
+ harness.sdkMessages,
+ makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000001012",
+ result: "Finished.",
+ }),
+ );
+ }
+ if (
+ betweenTurns &&
+ (status === "query_failed" || interrupted || status === "stop_buffered")
+ ) {
+ const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id;
+ while (true) {
+ const update = yield* Queue.take(harness.subagentReceipts);
+ if (update.subagent.id === lastMemberId && update.subagent.status !== "running")
+ break;
+ }
+ assert.isFalse(yield* harness.hasPendingBackgroundWork);
+ if (status === "query_failed") {
+ assert.equal(
+ workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title,
+ "Updated between turns",
+ );
+ }
+ } else {
+ yield* Queue.take(harness.terminalReceipts);
+ }
+ assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, expected);
+ if (status === "drain_buffered")
+ assert.equal(yield* harness.hasPendingBackgroundWork, expected === "running");
+ if (resumed === "live_after_buffered") {
+ assert.equal(
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result,
+ "Latest workflow result.",
+ );
+ }
+ if (resumed) {
+ assert.notEqual(
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result,
+ "Workflow finished.",
+ );
+ }
+ for (const index of [1, 2]) {
+ const updates = workflowMemberEvents(harness.events, index);
+ const member = updates.at(-1)?.subagent;
+ assert.equal(member?.status, expected);
+ assert.deepEqual(member?.startedAt, updates[0]?.subagent.startedAt);
+ const root = harness.events.findLast(
+ (event) =>
+ event.type === "node.updated" && event.node.threadId === member?.childThreadId,
+ );
+ assert.equal(root?.type === "node.updated" ? root.node.status : null, expected);
+ }
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ ),
+ );
+
+ it.effect("reads a transcript that appears after a terminal workflow notification", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeWorkflowHarness;
+ const now = yield* DateTime.now;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-claude-workflow-late-terminal-answer"),
+ text: "Run the alpha workflow.",
+ attachments: [],
+ }),
+ );
+ yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001030" });
+ const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId;
+ yield* Queue.offer(
+ harness.sdkMessages,
+ workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001031", state: "done" }),
+ );
+ yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 2);
+ const notification = workflowNotification({
+ uuid: "00000000-0000-4000-8000-000000001032",
+ status: "completed",
+ });
+ yield* Queue.offer(harness.sdkMessages, notification);
+ yield* harness.until(() =>
+ harness.events.some(
+ (event) =>
+ event.type === "turn_item.updated" &&
+ event.turnItem.type === "subagent" &&
+ event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID &&
+ event.turnItem.status === "completed",
+ ),
+ );
+ NodeFS.writeFileSync(
+ NodePath.join(harness.transcriptDir, "agent-a2.jsonl"),
+ '{"type":"assistant","uuid":"wf-late-answer","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_late","content":[{"type":"text","text":"A2 final answer"}]}}\n',
+ );
+ yield* Queue.offer(
+ harness.sdkMessages,
+ claudeSdkFrame({ ...notification, uuid: "00000000-0000-4000-8000-000000001033" }),
+ );
+ yield* harness.until(
+ () =>
+ threadMessages(harness.events, secondThreadId).at(-1)?.message.text ===
+ "A2 final answer",
+ );
+ yield* Queue.offer(
+ harness.sdkMessages,
+ makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001034", result: "Done." }),
+ );
+ yield* Queue.take(harness.terminalReceipts);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ ),
+ );
+
+ it.effect("clears workflow member metadata only after a successful transcript read", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const harness = yield* makeWorkflowHarness;
+ const now = yield* DateTime.now;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-claude-workflow-metadata"),
+ text: "Run the alpha workflow in the background.",
+ attachments: [],
+ }),
+ );
+ yield* harness.offerAndWait(workflowToolUse);
+ yield* harness.offerAndWait(workflowTaskStarted);
+ yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
+ yield* harness.offerAndWait(
+ workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "done" }),
+ );
+ yield* harness.until(
+ () =>
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]
+ ?.effort === "xhigh",
+ );
+ let coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent;
+ assert.equal(coordinator.workflow?.agents[0]?.speed, "standard");
+
+ const transcriptPath = NodePath.join(harness.transcriptDir, "agent-a1.jsonl");
+ NodeFS.renameSync(transcriptPath, `${transcriptPath}.saved`);
+ yield* harness.offerAndWait(
+ workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000001041",
+ state: "done",
+ label: "alpha:unreadable",
+ }),
+ );
+ yield* harness.until(
+ () =>
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]
+ ?.label === "alpha:unreadable",
+ );
+ coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent;
+ assert.equal(coordinator.workflow?.agents[0]?.effort, "xhigh");
+ assert.equal(coordinator.workflow?.agents[0]?.speed, "standard");
+
+ NodeFS.renameSync(`${transcriptPath}.saved`, transcriptPath);
+ NodeFS.appendFileSync(
+ transcriptPath,
+ '{"type":"assistant","uuid":"wf-metadata-cleared","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_new","content":[{"type":"text","text":"New answer without metadata"}]}}\n',
+ );
+ yield* harness.offerAndWait(
+ workflowSnapshot({
+ uuid: "00000000-0000-4000-8000-000000001042",
+ state: "done",
+ label: "alpha:new-response",
+ }),
+ );
+ yield* harness.until(() => {
+ const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow
+ ?.agents[0];
+ return (
+ member?.label === "alpha:new-response" &&
+ member.effort === undefined &&
+ member.speed === undefined
+ );
+ });
+ yield* harness.offerAndWait(
+ makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001043", result: "Done." }),
+ );
+ yield* Queue.take(harness.terminalReceipts);
+ const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow
+ ?.agents[0];
+ assert.isUndefined(member?.effort);
+ assert.isUndefined(member?.speed);
+ const childThreadId = member?.childThreadId;
+ assert.deepEqual(
+ threadMessages(harness.events, childThreadId)
+ .filter((event) => event.message.role === "assistant")
+ .map((event) => event.message.text),
+ ["A1 from the transcript", "New answer without metadata"],
+ );
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ ),
+ );
+
it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -12530,11 +13146,19 @@ describe("ClaudeAdapterV2 background wake turns", () => {
);
yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 2);
- const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent;
+ yield* harness.until(
+ () =>
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]
+ ?.effort === "xhigh",
+ );
+ const coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent;
assert.deepEqual(
- coordinator?.workflow?.agents.map((member) => member.state),
+ coordinator.workflow?.agents.map((member) => member.state),
["completed", "completed"],
);
+ assert.equal(coordinator.workflow?.agents[0]?.speed, "standard");
+ assert.isUndefined(coordinator.workflow?.agents[1]?.effort);
+ assert.isUndefined(coordinator.workflow?.agents[1]?.speed);
assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed");
assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed");
assert.deepEqual(
@@ -12610,6 +13234,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
completedMember?.completedAt && DateTime.toEpochMillis(completedMember.completedAt),
4432,
);
+ assert.isUndefined(
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.effort,
+ );
+ assert.isUndefined(
+ workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.speed,
+ );
assert.notDeepEqual(
workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt,
previousStartedAt,
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 49b344961b3f..303a165d285e 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -6,6 +6,7 @@ import {
formatSearchToolLabel,
} from "@t3tools/shared/toolActivity";
import { field, text } from "../unknownField.ts";
+import { mergeWorkflowAgentMetadata, type WorkflowAgentMetadata } from "../workflowAgentAnswers.ts";
import {
initialWorkflowAgentTranscriptCursor,
readWorkflowAgentTranscript,
@@ -4043,6 +4044,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
// line before it is already projected into the member's thread.
readonly agentId: string | undefined;
readonly transcriptCursor: WorkflowAgentTranscriptCursor;
+ readonly transcriptMetadata: WorkflowAgentMetadata;
readonly nextItemOrdinal: number;
readonly toolCalls: Map;
readonly textShown: boolean;
@@ -5115,16 +5117,31 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const withWorkflowMemberThreadIds = (
workflow: OrchestrationV2SubagentWorkflow,
coordinatorChildThreadId: ThreadId,
- ): OrchestrationV2SubagentWorkflow => ({
- ...workflow,
- agents: workflow.agents.map((member) => ({
- ...member,
- childThreadId: idAllocator.derive.threadFromProviderThread({
- driver: CLAUDE_PROVIDER,
- nativeThreadId: `${coordinatorChildThreadId}:agent:${member.index}`,
- }),
- })),
- });
+ previous: OrchestrationV2SubagentWorkflow | undefined,
+ ): OrchestrationV2SubagentWorkflow => {
+ const runChanged =
+ previous?.runHandles?.runId !== undefined &&
+ workflow.runHandles?.runId !== undefined &&
+ previous.runHandles.runId !== workflow.runHandles.runId;
+ const previousThreads = new Map(
+ (runChanged ? [] : (previous?.agents ?? [])).map((member) => [
+ member.index,
+ member.childThreadId,
+ ]),
+ );
+ return {
+ ...workflow,
+ agents: workflow.agents.map((member) => ({
+ ...member,
+ childThreadId:
+ previousThreads.get(member.index) ??
+ idAllocator.derive.threadFromProviderThread({
+ driver: CLAUDE_PROVIDER,
+ nativeThreadId: `${coordinatorChildThreadId}${workflow.runHandles?.runId === undefined ? "" : `:run:${workflow.runHandles.runId}`}:agent:${member.index}`,
+ }),
+ })),
+ };
+ };
const emitSubagentConversationMessage = Effect.fnUntraced(function* (input: {
readonly nativeItemId: string;
@@ -5184,11 +5201,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const now = yield* DateTime.now;
const seen = yield* Ref.get(workflowMemberStates);
const transcriptDir = input.workflow.runHandles?.transcriptDir;
+ const membersWithMetadata = input.workflow.agents.slice();
- for (const member of input.workflow.agents) {
+ for (const [memberIndex, member] of input.workflow.agents.entries()) {
if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator)
return;
- const memberKey = `${input.coordinator.childThreadId}:agent:${member.index}`;
+ const childThreadId = member.childThreadId;
+ if (childThreadId === undefined) continue;
+ const memberKey = `${childThreadId}:agent:${member.index}`;
const previous = seen.get(memberKey);
const status =
(member.state === "queued" || member.state === "running") &&
@@ -5246,9 +5266,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
driver: CLAUDE_PROVIDER,
nativeItemId: `${memberKey}:thread-root`,
});
- const childThreadId = member.childThreadId;
- if (childThreadId === undefined) continue;
-
if (
previous === undefined ||
previous.label !== member.label ||
@@ -5374,6 +5391,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
previous === undefined || restarted || previous.agentId !== agentId
? initialWorkflowAgentTranscriptCursor
: previous.transcriptCursor;
+ let transcriptMetadata =
+ previous === undefined || restarted || previous.agentId !== agentId
+ ? {}
+ : previous.transcriptMetadata;
let textShown = !restarted && previous?.textShown === true;
let resultShown = !restarted && previous?.resultShown === true;
let turnEnded = !restarted && previous?.turnEnded === true;
@@ -5422,6 +5443,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
transcriptRead = true;
const advanced = read.value.cursor.offset !== transcriptCursor.offset;
transcriptCursor = read.value.cursor;
+ transcriptMetadata = mergeWorkflowAgentMetadata(
+ read.value.lines,
+ transcriptMetadata,
+ );
for (const line of read.value.lines) {
if (yield* projectSubagentTranscriptLine(target, line)) textShown = true;
// A later line (a retry, or a resumed member) reopens the turn.
@@ -5446,6 +5471,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (!(yield* drain())) return;
}
}
+ if (
+ transcriptRead &&
+ (transcriptMetadata.effort !== member.effort ||
+ transcriptMetadata.speed !== member.speed)
+ ) {
+ const { effort: _effort, speed: _speed, ...memberWithoutMetadata } = member;
+ membersWithMetadata[memberIndex] = {
+ ...memberWithoutMetadata,
+ ...(transcriptMetadata.effort === undefined
+ ? {}
+ : { effort: transcriptMetadata.effort }),
+ ...(transcriptMetadata.speed === undefined
+ ? {}
+ : { speed: transcriptMetadata.speed }),
+ };
+ }
if (settled) {
// As for an ordinary subagent, a call still open once the member
// settles will never get its result.
@@ -5491,6 +5532,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
textShown &&
input.coordinator.task.status !== "running"),
transcriptCursor,
+ transcriptMetadata,
nextItemOrdinal,
toolCalls,
textShown,
@@ -5502,6 +5544,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}),
);
}
+ if (
+ membersWithMetadata.some((member, index) => member !== input.workflow.agents[index])
+ ) {
+ if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator)
+ return;
+ input.coordinator.task = {
+ ...input.coordinator.task,
+ workflow: { ...input.workflow, agents: membersWithMetadata },
+ };
+ yield* emitProviderEvent({
+ type: "subagent.updated",
+ driver: CLAUDE_PROVIDER,
+ subagent: input.coordinator.task,
+ });
+ }
});
const confirmedSubagentOutcome = (task: OrchestrationV2Subagent | undefined) => {
@@ -5646,7 +5703,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const workflowWithThreads =
workflow === undefined
? undefined
- : withWorkflowMemberThreadIds(workflow, childThreadId);
+ : withWorkflowMemberThreadIds(workflow, childThreadId, priorTask?.workflow);
const effort =
existingSubagent === undefined
? (input.effort ??
@@ -7044,6 +7101,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const workflowWithThreads = withWorkflowMemberThreadIds(
workflow,
registered.childThreadId,
+ registered.task.workflow,
);
const task = {
...registered.task,
diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts
index baea36ca738d..23daf92c01e6 100644
--- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vite-plus/test";
-import { mergeClaudeWorkflowProgress } from "./claudeWorkflowProgress.ts";
+import {
+ mergeClaudeWorkflowProgress,
+ parseClaudeWorkflowRunHandles,
+} from "./claudeWorkflowProgress.ts";
const snapshotFrame = (
entries: ReadonlyArray>,
@@ -36,6 +39,7 @@ describe("mergeClaudeWorkflowProgress", () => {
state: "done",
agentId: "a1",
model: "claude-opus-5[1m]",
+ fallbackModel: "claude-opus-4-8",
attempt: 2,
tokens: 71_141,
toolCalls: 3,
@@ -58,7 +62,7 @@ describe("mergeClaudeWorkflowProgress", () => {
state: "completed",
phaseIndex: 1,
phaseTitle: "Alpha",
- model: "claude-opus-5[1m]",
+ model: "claude-opus-4-8",
attempt: 2,
totalTokens: 71_141,
toolCalls: 3,
@@ -105,6 +109,82 @@ describe("mergeClaudeWorkflowProgress", () => {
expect(second?.totalTokens).toBe(250);
});
+ it("harvests run handles from the launch acknowledgement", () => {
+ const workflow = mergeClaudeWorkflowProgress({
+ previous: undefined,
+ runHandles: parseClaudeWorkflowRunHandles({
+ status: "async_launched",
+ taskType: "local_workflow",
+ runId: "wf_abc",
+ transcriptDir: "/root/.claude/projects/p/s/subagents/workflows/wf_abc",
+ scriptPath: "/root/.claude/projects/p/s/workflows/scripts/x.js",
+ }),
+ message: snapshotFrame([]),
+ });
+ expect(workflow?.runHandles?.transcriptDir).toBe(
+ "/root/.claude/projects/p/s/subagents/workflows/wf_abc",
+ );
+ expect(workflow?.runHandles?.runId).toBe("wf_abc");
+ });
+
+ it("ignores a non-workflow tool acknowledgement", () => {
+ expect(
+ parseClaudeWorkflowRunHandles({ status: "async_launched", taskType: "local_agent" }),
+ ).toBeUndefined();
+ });
+
+ it("keeps a phase the plan reached at runtime even if a later frame omits it", () => {
+ const first = mergeClaudeWorkflowProgress({
+ previous: undefined,
+ message: snapshotFrame([phase(1, "Alpha"), phase(2, "Beta"), phase(3, "Gamma")]),
+ });
+ const second = mergeClaudeWorkflowProgress({
+ previous: first,
+ message: snapshotFrame([phase(1, "Alpha")]),
+ });
+ expect(second?.phases.map((entry) => entry.title)).toEqual(["Alpha", "Beta", "Gamma"]);
+ });
+
+ it("resets run state when a coordinator starts a different run", () => {
+ const first = mergeClaudeWorkflowProgress({
+ previous: undefined,
+ runHandles: {
+ runId: "first-run",
+ transcriptDir: "/workflows/first-run",
+ scriptPath: "/scripts/first-run.js",
+ },
+ message: snapshotFrame(
+ [
+ phase(1, "Alpha"),
+ phase(2, "Old phase"),
+ agent({ state: "done", resultPreview: "Old result", durationMs: 500 }),
+ agent({ index: 2, state: "done" }),
+ ],
+ { total_tokens: 500, tool_uses: 2, duration_ms: 900 },
+ ),
+ });
+ const second = mergeClaudeWorkflowProgress({
+ previous: first,
+ runHandles: { runId: "second-run" },
+ message: snapshotFrame([phase(1, "New phase"), agent({ phaseTitle: "New phase" })]),
+ });
+ expect(second?.phases).toEqual([{ index: 1, title: "New phase" }]);
+ expect(second?.agents).toEqual([
+ { index: 1, label: "alpha:one", state: "running", phaseIndex: 1, phaseTitle: "New phase" },
+ ]);
+ expect(second?.runHandles).toEqual({ runId: "second-run" });
+ expect(second?.totalTokens).toBeUndefined();
+ expect(second?.toolCalls).toBeUndefined();
+ expect(second?.durationMs).toBeUndefined();
+ const sparse = mergeClaudeWorkflowProgress({
+ previous: second,
+ runHandles: { runId: "second-run" },
+ message: snapshotFrame([]),
+ });
+ expect(sparse?.phases).toEqual(second?.phases);
+ expect(sparse?.agents).toEqual(second?.agents);
+ });
+
it("keeps other members and phase links when a sparse frame starts a new attempt", () => {
const first = mergeClaudeWorkflowProgress({
previous: undefined,
@@ -113,6 +193,7 @@ describe("mergeClaudeWorkflowProgress", () => {
agent({
state: "error",
attempt: 1,
+ model: "claude-opus-5[1m]",
promptPreview: "First prompt",
resultPreview: "First result",
tokens: 100,
@@ -128,7 +209,10 @@ describe("mergeClaudeWorkflowProgress", () => {
expect(first?.agents[0]?.completedAt).toBe(2010);
expect(first?.agents[1]).not.toHaveProperty("completedAt");
const second = mergeClaudeWorkflowProgress({
- previous: first,
+ previous: first && {
+ ...first,
+ agents: first.agents.map((member) => ({ ...member, effort: "max", speed: "standard" })),
+ },
message: snapshotFrame([
{ type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 },
]),
@@ -142,7 +226,7 @@ describe("mergeClaudeWorkflowProgress", () => {
phaseIndex: 1,
phaseTitle: "Alpha",
},
- first?.agents[1],
+ { ...first?.agents[1], effort: "max", speed: "standard" },
]);
expect(second?.phases).toEqual(first?.phases);
});
@@ -179,6 +263,80 @@ describe("mergeClaudeWorkflowProgress", () => {
expect(second?.agents).toEqual(first?.agents);
});
+ it("retains member fields omitted by a later frame in the same attempt", () => {
+ const first = mergeClaudeWorkflowProgress({
+ previous: undefined,
+ message: snapshotFrame([
+ agent({
+ attempt: 2,
+ model: "claude-opus-5[1m]",
+ promptPreview: "Current prompt",
+ tokens: 100,
+ }),
+ ]),
+ });
+ const second = mergeClaudeWorkflowProgress({
+ previous: first && {
+ ...first,
+ agents: first.agents.map((member) => ({ ...member, effort: "xhigh", speed: "standard" })),
+ },
+ message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]),
+ });
+ expect(second?.agents[0]).toMatchObject({
+ state: "completed",
+ attempt: 2,
+ prompt: "Current prompt",
+ result: "Current result",
+ totalTokens: 100,
+ model: "claude-opus-5[1m]",
+ effort: "xhigh",
+ speed: "standard",
+ });
+ });
+
+ it("keeps existing run handles when a later acknowledgement supplies only a script path", () => {
+ const first = mergeClaudeWorkflowProgress({
+ previous: undefined,
+ runHandles: { runId: "wf_abc", transcriptDir: "/workflows/wf_abc" },
+ message: snapshotFrame([]),
+ });
+ const second = mergeClaudeWorkflowProgress({
+ previous: first,
+ runHandles: { scriptPath: "/scripts/workflow.js" },
+ message: snapshotFrame([]),
+ });
+ expect(second?.runHandles).toEqual({
+ runId: "wf_abc",
+ transcriptDir: "/workflows/wf_abc",
+ scriptPath: "/scripts/workflow.js",
+ });
+ });
+
+ it("preserves prompt and result whitespace", () => {
+ const workflow = mergeClaudeWorkflowProgress({
+ previous: undefined,
+ message: snapshotFrame([
+ agent({ promptPreview: " indented\n", resultPreview: "\n answer\n" }),
+ ]),
+ });
+ expect(workflow?.agents[0]?.prompt).toBe(" indented\n");
+ expect(workflow?.agents[0]?.result).toBe("\n answer\n");
+ });
+
+ it("drops timestamps outside the DateTime range", () => {
+ const workflow = mergeClaudeWorkflowProgress({
+ previous: undefined,
+ message: snapshotFrame([
+ agent({ startedAt: 8_640_000_000_000_001, queuedAt: Number.MAX_SAFE_INTEGER }),
+ agent({ index: 2, startedAt: 8_640_000_000_000_000, queuedAt: 0 }),
+ ]),
+ });
+ expect(workflow?.agents[0]).not.toHaveProperty("startedAt");
+ expect(workflow?.agents[0]).not.toHaveProperty("queuedAt");
+ expect(workflow?.agents[1]?.startedAt).toBe(8_640_000_000_000_000);
+ expect(workflow?.agents[1]?.queuedAt).toBe(0);
+ });
+
it("drops malformed entries instead of failing the frame", () => {
const workflow = mergeClaudeWorkflowProgress({
previous: undefined,
diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts
index 2bb43e53ca6b..49d5e01bd122 100644
--- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts
+++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts
@@ -63,7 +63,7 @@ function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null {
...optional("agentId", text(field(entry, "agentId"))),
...optional("phaseIndex", count(field(entry, "phaseIndex"))),
...optional("phaseTitle", text(field(entry, "phaseTitle"))),
- ...optional("model", text(field(entry, "model"))),
+ ...optional("model", text(field(entry, "fallbackModel")) ?? text(field(entry, "model"))),
...optional("attempt", count(field(entry, "attempt"))),
...optional("totalTokens", count(field(entry, "tokens"))),
...optional("toolCalls", count(field(entry, "toolCalls"))),
@@ -142,12 +142,17 @@ export function mergeClaudeWorkflowProgress(input: {
return undefined;
}
const reported = field(input.message, "usage");
+ const runChanged =
+ input.runHandles?.runId !== undefined &&
+ previous?.runHandles?.runId !== undefined &&
+ input.runHandles.runId !== previous.runHandles.runId;
+ const previousProgress = runChanged ? undefined : previous;
const phasesByIndex = new Map(
- (previous?.phases ?? []).map((phase) => [phase.index, phase] as const),
+ (previousProgress?.phases ?? []).map((phase) => [phase.index, phase] as const),
);
for (const phase of snapshot?.phases ?? []) phasesByIndex.set(phase.index, phase);
const agentsByIndex = new Map(
- (previous?.agents ?? []).map((agent) => [agent.index, agent] as const),
+ (previousProgress?.agents ?? []).map((agent) => [agent.index, agent] as const),
);
for (const agent of snapshot?.agents ?? []) {
const prior = agentsByIndex.get(agent.index);
@@ -176,15 +181,21 @@ export function mergeClaudeWorkflowProgress(input: {
}
const runHandles =
input.runHandles === undefined
- ? previous?.runHandles
- : { ...previous?.runHandles, ...input.runHandles };
+ ? previousProgress?.runHandles
+ : { ...previousProgress?.runHandles, ...input.runHandles };
return {
...optional("name", input.name ?? previous?.name),
...optional("runHandles", runHandles),
phases: Array.from(phasesByIndex.values()).sort((left, right) => left.index - right.index),
agents: Array.from(agentsByIndex.values()).sort((left, right) => left.index - right.index),
- ...optional("totalTokens", count(field(reported, "total_tokens")) ?? previous?.totalTokens),
- ...optional("toolCalls", count(field(reported, "tool_uses")) ?? previous?.toolCalls),
- ...optional("durationMs", count(field(reported, "duration_ms")) ?? previous?.durationMs),
+ ...optional(
+ "totalTokens",
+ count(field(reported, "total_tokens")) ?? previousProgress?.totalTokens,
+ ),
+ ...optional("toolCalls", count(field(reported, "tool_uses")) ?? previousProgress?.toolCalls),
+ ...optional(
+ "durationMs",
+ count(field(reported, "duration_ms")) ?? previousProgress?.durationMs,
+ ),
};
}
diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts
new file mode 100644
index 000000000000..8a24b9502887
--- /dev/null
+++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { mergeWorkflowAgentMetadata } from "./workflowAgentAnswers.ts";
+
+describe("mergeWorkflowAgentMetadata", () => {
+ it("keeps effort and speed across reads of a split assistant message", () => {
+ const metadata = mergeWorkflowAgentMetadata([
+ {
+ type: "assistant",
+ effort: "xhigh",
+ message: { id: "a", content: [], usage: { speed: "standard" } },
+ },
+ ]);
+ expect(metadata).toEqual({ messageId: "a", effort: "xhigh", speed: "standard" });
+ expect(
+ mergeWorkflowAgentMetadata(
+ [{ type: "assistant", message: { id: "a", content: [{ type: "text", text: "Done" }] } }],
+ metadata,
+ ),
+ ).toEqual(metadata);
+ expect(mergeWorkflowAgentMetadata([], metadata)).toEqual(metadata);
+ });
+
+ it("ignores bookkeeping and API errors without clearing prior metadata", () => {
+ const metadata = { messageId: "a", effort: "max", speed: "standard" };
+ expect(
+ mergeWorkflowAgentMetadata(
+ [
+ { type: "user", effort: "high", message: { usage: { speed: "fast" } } },
+ { type: "assistant", isMeta: true, effort: "high" },
+ { type: "assistant", isApiErrorMessage: true, message: { id: "error" } },
+ { type: "assistant", effort: 1, message: { id: "a", usage: { speed: false } } },
+ ],
+ metadata,
+ ),
+ ).toEqual(metadata);
+ });
+
+ it.each(["b", undefined])("clears omitted metadata for a new response with id %s", (id) => {
+ expect(
+ mergeWorkflowAgentMetadata([{ type: "assistant", message: { id, content: [] } }], {
+ messageId: "a",
+ effort: "max",
+ speed: "standard",
+ }),
+ ).toEqual(id === undefined ? {} : { messageId: id });
+ });
+});
diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts
new file mode 100644
index 000000000000..d96f3684a4e2
--- /dev/null
+++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts
@@ -0,0 +1,33 @@
+import type { OrchestrationV2WorkflowAgent } from "@t3tools/contracts";
+
+import { field, text } from "./unknownField.ts";
+
+export type WorkflowAgentMetadata = Pick & {
+ readonly messageId?: string;
+};
+
+export function mergeWorkflowAgentMetadata(
+ lines: ReadonlyArray,
+ previous: WorkflowAgentMetadata = {},
+): WorkflowAgentMetadata {
+ let metadata = previous;
+ for (const entry of lines) {
+ if (
+ field(entry, "type") !== "assistant" ||
+ field(entry, "isMeta") === true ||
+ field(entry, "isApiErrorMessage") === true
+ )
+ continue;
+ const message = field(entry, "message");
+ const messageId = text(field(message, "id"));
+ const effort = text(field(entry, "effort"));
+ const speed = text(field(field(message, "usage"), "speed"));
+ metadata = {
+ ...(messageId !== undefined && messageId === metadata.messageId ? metadata : {}),
+ ...(messageId === undefined ? {} : { messageId }),
+ ...(effort === undefined ? {} : { effort }),
+ ...(speed === undefined ? {} : { speed }),
+ };
+ }
+ return metadata;
+}
diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx
index 2e00e47ee7da..d7eafba13376 100644
--- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx
+++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx
@@ -1,6 +1,8 @@
-import type {
- SubagentWorkflowGroup,
- RuntimeSubagent,
+import {
+ workflowPhaseElapsed,
+ workflowPhaseStatus,
+ type SubagentWorkflowGroup,
+ type RuntimeSubagent,
} from "@t3tools/client-runtime/state/subagentRuntime";
import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react";
@@ -19,33 +21,6 @@ import {
THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
} from "./threadDetailsPanelStyles";
-function phaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) {
- const instants = (key: "startedAt" | "completedAt") =>
- phase.members
- .map((member) => member[key])
- .filter((value) => value !== null)
- .sort();
- const running = phase.state === "running";
- const startedAt = instants("startedAt")[0] ?? null;
- const completedAt = running ? null : (instants("completedAt").at(-1) ?? null);
- return {
- status: running ? ("running" as const) : ("completed" as const),
- startedAt: running || completedAt !== null ? startedAt : null,
- completedAt,
- };
-}
-
-function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) {
- if (phase.state === "running") return "running";
- if (phase.members.some((member) => member.status === "failed")) return "failed";
- if (
- phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted")
- ) {
- return "stopped";
- }
- return phase.state === "done" ? "done" : "not started";
-}
-
function WorkflowMemberRow({
member,
providerInstanceId,
@@ -189,7 +164,7 @@ export function ThreadLineageWorkflowRow({
{phases.map((phase) => {
const open = phaseOpen.get(phase.index) ?? phase.state === "running";
- const status = phaseStatusLabel(phase);
+ const status = workflowPhaseStatus(phase);
return (
-
-
+
>
}
diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts
index 09927e38398a..77d23698be14 100644
--- a/packages/client-runtime/src/state/commandPermissions.test.ts
+++ b/packages/client-runtime/src/state/commandPermissions.test.ts
@@ -14,6 +14,7 @@ import {
AuthSourceControlWriteScope,
ThreadId,
EnvironmentId,
+ ORCHESTRATION_V2_WS_METHODS,
ScheduledTaskId,
WS_METHODS,
type AuthSessionState,
@@ -115,21 +116,45 @@ describe("command permissions", () => {
),
);
- it.effect("uses the target grant for both availability and execution", () =>
+ it.effect("requires the destination settings grant to check OTLP endpoints", () =>
Effect.scoped(
Effect.gen(function* () {
const registry = yield* setup;
+ const check = createCommandPermissions(runtime, WS_METHODS.serverCheckOtlpEndpoint);
registry.set(sessions(env), AsyncResult.success(grant(true)));
- expect(registry.get(permissions.permissionAtom(env))).toBe(true);
- expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env));
- yield* permissions.authorize(registry, env);
- registry.set(sessions(other), AsyncResult.success(grant(false)));
- expect(registry.get(permissions.permissionAtom(other))).toBe(false);
- const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip);
- expect(denied._tag).toBe("EnvironmentAuthorizationError");
+ expect(registry.get(check.permissionAtom(env))).toBe(false);
+ registry.set(
+ sessions(env),
+ AsyncResult.success({
+ ...grant(false),
+ scopes: [AuthSettingsWriteScope],
+ permissions: [AuthSettingsWriteScope],
+ }),
+ );
+ expect(registry.get(check.permissionAtom(env))).toBe(true);
+ yield* check.authorize(registry, env);
}),
),
);
+
+ it.effect.each([WS_METHODS.scheduledTasksDelete, ORCHESTRATION_V2_WS_METHODS.stopWorkflow])(
+ "uses the target grant for both availability and execution of %s",
+ (method) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const registry = yield* setup;
+ const permissions = createCommandPermissions(runtime, method);
+ registry.set(sessions(env), AsyncResult.success(grant(true)));
+ expect(registry.get(permissions.permissionAtom(env))).toBe(true);
+ expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env));
+ yield* permissions.authorize(registry, env);
+ registry.set(sessions(other), AsyncResult.success(grant(false)));
+ expect(registry.get(permissions.permissionAtom(other))).toBe(false);
+ const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip);
+ expect(denied._tag).toBe("EnvironmentAuthorizationError");
+ }),
+ ),
+ );
it.effect("waits for an initial grant", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts
index 20e334288ab0..fd4fa096777d 100644
--- a/packages/client-runtime/src/state/subagentRuntime.test.ts
+++ b/packages/client-runtime/src/state/subagentRuntime.test.ts
@@ -13,6 +13,8 @@ import {
isWorkflowScriptMessage,
projectedSubagentsToRuntime,
resolveWorkflowOpening,
+ workflowPhaseElapsed,
+ workflowPhaseStatus,
} from "./subagentRuntime.ts";
const at = (iso: string) => DateTime.makeUnsafe(iso);
@@ -59,6 +61,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => {
agents: [
member({
state: "completed",
+ model: "claude-opus-5[1m]",
+ effort: "xhigh",
+ speed: "standard",
startedAt: Date.UTC(2026, 7, 1, 10, 0, 0),
durationMs: 1500,
completedAt: Date.UTC(2026, 7, 1, 10, 0, 4, 432),
@@ -74,6 +79,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => {
expect(settled?.status).toBe("completed");
expect(settled?.completedAt).toBe("2026-08-01T10:00:04.432Z");
expect(settled?.result).toBe("A1");
+ expect(settled?.model).toBe("claude-opus-5[1m]");
+ expect(settled?.effort).toBe("xhigh");
+ expect(settled?.speed).toBe("standard");
expect(settled?.progress).toBe("Reply with exactly: A1");
expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 });
});
@@ -108,11 +116,84 @@ describe("projectedSubagentsToRuntime workflow expansion", () => {
"2026-08-01T10:00:09.000Z",
"2026-08-01T10:00:01.500Z",
]);
+ expect(members?.[0]?.model).toBeNull();
+ expect(members?.[0]?.effort).toBeNull();
+ expect(members?.[0]?.speed).toBeUndefined();
},
);
+
+ it("surfaces a retried member through the row's activation count", () => {
+ const [, retried] = projectedSubagentsToRuntime([
+ subagent({ workflow: { phases: [], agents: [member({ attempt: 3 })] } }),
+ ]);
+ expect(retried?.activationCount).toBe(3);
+ expect(retried?.attempt).toBe(3);
+ });
+
+ it("preserves separate coordinator and member navigation when work settles", () => {
+ const rows = projectedSubagentsToRuntime([
+ subagent({ id: "ordinary", childThreadId: "ordinary-thread" }),
+ subagent({
+ childThreadId: "coordinator-thread",
+ status: "completed",
+ workflow: {
+ phases: [],
+ agents: [
+ member({
+ state: "completed",
+ childThreadId: ThreadId.make("member-thread"),
+ result: "Done",
+ }),
+ member({ index: 2, state: "failed", result: "Failed" }),
+ ],
+ },
+ }),
+ ]);
+
+ expect(rows.map((row) => [row.kind, row.childThreadId])).toEqual([
+ ["subagent", "ordinary-thread"],
+ ["workflow", "coordinator-thread"],
+ ["workflow_agent", "member-thread"],
+ ["workflow_agent", null],
+ ]);
+ const members = deriveWorkflowGroups(rows)[0]?.phases[0]?.members;
+ expect(members?.[0]?.result).toBe("Done");
+ expect(members?.[1]?.error).toBe("Failed");
+ });
});
describe("deriveWorkflowGroups over a dynamic workflow", () => {
+ it("uses the parallel phase span and hides unknown settled timing", () => {
+ const [group] = groupsOf([
+ subagent({
+ workflow: {
+ phases: [],
+ agents: [
+ member({ state: "completed", startedAt: Date.UTC(2026, 7, 1, 10), durationMs: 20000 }),
+ member({
+ index: 2,
+ state: "completed",
+ startedAt: Date.UTC(2026, 7, 1, 10, 0, 5),
+ durationMs: 5000,
+ }),
+ ],
+ },
+ }),
+ ]);
+ const phase = group!.phases[0]!;
+ expect(workflowPhaseElapsed(phase)).toEqual({
+ status: "completed",
+ startedAt: "2026-08-01T10:00:00.000Z",
+ completedAt: "2026-08-01T10:00:20.000Z",
+ });
+ expect(
+ workflowPhaseElapsed({
+ ...phase,
+ members: phase.members.map((agent) => ({ ...agent, completedAt: null })),
+ }),
+ ).toEqual({ status: "completed", startedAt: null, completedAt: null });
+ });
+
it("groups members under their phases and counts the run", () => {
const groups = groupsOf([
subagent({
@@ -143,6 +224,7 @@ describe("deriveWorkflowGroups over a dynamic workflow", () => {
["Beta", "running"],
]);
expect(group?.phases.map((phase) => phase.settledCount)).toEqual([2, 0]);
+ expect(group?.phases.map(workflowPhaseStatus)).toEqual(["failed", "running"]);
expect(group?.phases.flatMap((phase) => phase.members)).toHaveLength(4);
expect(group?.unphasedMembers).toEqual([]);
});
diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts
index c007bc56b974..cc5ea170e735 100644
--- a/packages/client-runtime/src/state/subagentRuntime.ts
+++ b/packages/client-runtime/src/state/subagentRuntime.ts
@@ -52,6 +52,7 @@ export interface RuntimeSubagent {
readonly role: string | null;
readonly model: string | null;
readonly effort: string | null;
+ readonly speed?: string | undefined;
readonly status: RuntimeSubagentStatus;
readonly activationCount: number;
readonly usage: SubagentUsage | null;
@@ -107,6 +108,40 @@ export interface SubagentWorkflowGroup {
readonly unphasedMembers: ReadonlyArray;
}
+/**
+ * Wall clock for the phase: its members run in parallel, so the span from the
+ * first start to the last finish is the time the phase actually took. Shaped
+ * for elapsed timers, which tick it while the phase is still running.
+ */
+export function workflowPhaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) {
+ const instants = (key: "startedAt" | "completedAt") =>
+ phase.members
+ .map((member) => member[key])
+ .filter((value) => value !== null)
+ .sort();
+ const running = phase.state === "running";
+ const startedAt = instants("startedAt")[0] ?? null;
+ const completedAt = running ? null : (instants("completedAt").at(-1) ?? null);
+ return {
+ status: running ? ("running" as const) : ("completed" as const),
+ // A settled phase whose members never reported an end has no span to show.
+ startedAt: running || completedAt !== null ? startedAt : null,
+ completedAt,
+ };
+}
+
+/** Phase state for the header's label; the tone and member badges show it visually. */
+export function workflowPhaseStatus(phase: SubagentWorkflowGroup["phases"][number]) {
+ if (phase.state === "running") return "running";
+ if (phase.members.some((member) => member.status === "failed")) return "failed";
+ if (
+ phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted")
+ ) {
+ return "stopped";
+ }
+ return phase.state === "done" ? "done" : "not started";
+}
+
function workflowUsage(source: {
readonly totalTokens?: number | undefined;
readonly toolCalls?: number | undefined;
@@ -150,6 +185,8 @@ function workflowMembersToRuntime(
kind: "workflow_agent" as const,
title: agent.label,
model: agent.model ?? null,
+ effort: agent.effort ?? null,
+ ...(agent.speed === undefined ? {} : { speed: agent.speed }),
status,
activationCount: agent.attempt ?? 1,
usage: workflowUsage(agent),
diff --git a/packages/client-runtime/src/state/threadSubagents.test.ts b/packages/client-runtime/src/state/threadSubagents.test.ts
index e5e7d8d6dac2..95ea2f87b6a7 100644
--- a/packages/client-runtime/src/state/threadSubagents.test.ts
+++ b/packages/client-runtime/src/state/threadSubagents.test.ts
@@ -182,4 +182,126 @@ describe("deriveThreadTurnSubagents", () => {
expect(turn?.settledCount).toBe(1);
expect(turn?.subagents).toHaveLength(3);
});
+
+ it("keeps earlier workflows alongside the active run's ordinary agents", () => {
+ const workflow = subagent({
+ id: "workflow",
+ runId: RunId.make("run-0"),
+ workflow: {
+ phases: [],
+ agents: [
+ {
+ index: 0,
+ label: "Worker",
+ state: "running",
+ childThreadId: ThreadId.make("member-thread"),
+ },
+ ],
+ },
+ });
+ const turn = deriveThreadTurnSubagents({
+ runs: [run("run-0", "completed"), activeRun],
+ subagents: [
+ workflow,
+ subagent({ id: "old", runId: RunId.make("run-0"), status: "completed" }),
+ subagent({ id: "current", startedAt: at("2026-06-20T00:00:02.000Z") }),
+ ],
+ });
+
+ expect(turn?.runId).toBe("run-1");
+ expect(turn?.subagents.map((entry) => entry.id)).toEqual(["workflow", "current"]);
+ expect(turn?.subagents[0]).toBe(workflow);
+ expect(turn?.liveCount).toBe(2);
+ });
+});
+
+describe("resolveSubagentPillSegment", () => {
+ it("shows how many of the turn's agents are still working", () => {
+ const turn = deriveThreadTurnSubagents({
+ runs: [activeRun],
+ subagents: [
+ subagent({ id: "a", status: "running" }),
+ subagent({ id: "b", status: "waiting" }),
+ subagent({ id: "c", status: "completed" }),
+ ],
+ });
+
+ expect(resolveSubagentPillSegment(turn)).toEqual({
+ label: "2/3",
+ accessibilityLabel: "2 of 3 agents working",
+ });
+ });
+
+ it("reports the finished roster while the turn is still running", () => {
+ const turn = deriveThreadTurnSubagents({
+ runs: [activeRun],
+ subagents: [subagent({ id: "a", status: "completed" })],
+ });
+
+ expect(resolveSubagentPillSegment(turn)).toEqual({
+ label: "1 done",
+ accessibilityLabel: "1 agent done",
+ });
+ });
+
+ it("hides itself once the turn is over and nothing is working", () => {
+ const turn = deriveThreadTurnSubagents({
+ runs: [finishedRun],
+ subagents: [subagent({ id: "a", status: "completed" })],
+ });
+
+ expect(resolveSubagentPillSegment(turn)).toBeNull();
+ expect(resolveSubagentPillSegment(null)).toBeNull();
+ });
+
+ it("stays visible after the turn ends while an agent is still working", () => {
+ const turn = deriveThreadTurnSubagents({
+ runs: [finishedRun],
+ subagents: [subagent({ id: "a", status: "running" })],
+ });
+
+ expect(resolveSubagentPillSegment(turn)?.label).toBe("1/1");
+ });
+
+ it("keeps settled workflows reachable after newer root runs finish", () => {
+ const turn = deriveThreadTurnSubagents({
+ runs: [finishedRun, run("run-2", "completed")],
+ subagents: [
+ subagent({ id: "workflow", status: "completed", workflow: { phases: [], agents: [] } }),
+ subagent({
+ id: "recent",
+ runId: RunId.make("run-2"),
+ status: "completed",
+ startedAt: at("2026-06-20T00:00:02.000Z"),
+ updatedAt: at("2026-06-20T00:00:09.000Z"),
+ }),
+ ],
+ });
+
+ expect(turn?.subagents.map((entry) => entry.id)).toEqual(["workflow", "recent"]);
+ expect(resolveSubagentPillSegment(turn)).toEqual({
+ label: "2 done",
+ accessibilityLabel: "2 agents done",
+ });
+ });
+
+ it("does not count idle agents as done alongside settled workflows", () => {
+ const turn = deriveThreadTurnSubagents({
+ runs: [finishedRun, run("run-2", "completed")],
+ subagents: [
+ subagent({ id: "workflow", status: "completed", workflow: { phases: [], agents: [] } }),
+ subagent({
+ id: "idle",
+ runId: RunId.make("run-2"),
+ status: "idle",
+ updatedAt: at("2026-06-20T00:00:09.000Z"),
+ }),
+ ],
+ });
+
+ expect(resolveSubagentPillSegment(turn)).toEqual({
+ label: "1 done",
+ accessibilityLabel: "1 agent done",
+ });
+ });
});
diff --git a/packages/client-runtime/src/state/threadSubagents.ts b/packages/client-runtime/src/state/threadSubagents.ts
index 1d6bdafb2e63..dd95a055056e 100644
--- a/packages/client-runtime/src/state/threadSubagents.ts
+++ b/packages/client-runtime/src/state/threadSubagents.ts
@@ -55,7 +55,10 @@ export function deriveThreadTurnSubagents(
const runId = activeRun?.id ?? latestUpdated.runId;
const subagents = copySorted(
eligible.filter(
- (subagent) => subagent.runId === runId || isActiveSubagentStatus(subagent.status),
+ (subagent) =>
+ subagent.runId === runId ||
+ isActiveSubagentStatus(subagent.status) ||
+ subagent.workflow !== undefined,
),
(left, right) => orderKey(left) - orderKey(right) || left.id.localeCompare(right.id),
);
@@ -74,7 +77,13 @@ export function resolveSubagentPillSegment(
turn: ThreadTurnSubagents | null,
): SubagentPillSegment | null {
if (turn === null) return null;
- if (!turn.turnActive && turn.liveCount === 0) return null;
+ if (
+ !turn.turnActive &&
+ turn.liveCount === 0 &&
+ !turn.subagents.some((subagent) => subagent.workflow !== undefined)
+ ) {
+ return null;
+ }
const total = turn.subagents.length;
if (turn.liveCount > 0) {
return {
@@ -83,7 +92,7 @@ export function resolveSubagentPillSegment(
};
}
return {
- label: `${total} done`,
- accessibilityLabel: `${total} ${total === 1 ? "agent" : "agents"} done`,
+ label: `${turn.settledCount} done`,
+ accessibilityLabel: `${turn.settledCount} ${turn.settledCount === 1 ? "agent" : "agents"} done`,
};
}
diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts
index da7ddc41385a..18504f3b658e 100644
--- a/packages/contracts/src/orchestrationV2.test.ts
+++ b/packages/contracts/src/orchestrationV2.test.ts
@@ -339,6 +339,9 @@ describe("orchestration V2 contracts", () => {
index: 1,
label: "Member",
state: "completed" as const,
+ model: "claude-opus-5[1m]",
+ effort: "xhigh",
+ speed: "standard",
prompt: preview,
result: preview,
},
@@ -360,10 +363,16 @@ describe("orchestration V2 contracts", () => {
expect(decode(json).workflow?.agents[0]).toMatchObject({
prompt: preview,
result: preview,
+ model: "claude-opus-5[1m]",
+ effort: "xhigh",
+ speed: "standard",
});
expect(decode(incoming).workflow?.agents[0]).toMatchObject({
prompt: preview,
result: preview,
+ model: "claude-opus-5[1m]",
+ effort: "xhigh",
+ speed: "standard",
});
}
},
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index 881a316f59ae..a4e62f44e84b 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -666,6 +666,8 @@ export const OrchestrationV2WorkflowAgent = Schema.Struct({
phaseIndex: Schema.optional(NonNegativeInt),
phaseTitle: Schema.optional(TrimmedNonEmptyString),
model: Schema.optional(TrimmedNonEmptyString),
+ effort: Schema.optional(TrimmedNonEmptyString),
+ speed: Schema.optional(TrimmedNonEmptyString),
attempt: Schema.optional(NonNegativeInt),
totalTokens: Schema.optional(NonNegativeInt),
toolCalls: Schema.optional(NonNegativeInt),