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),