From 6f169112fda1d7f3f66433f78d44a7d6f8126fc7 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 17 Jul 2026 03:28:38 -0700 Subject: [PATCH 1/2] feat(workflows): display live workflow agent chats --- .../thread/ChatPane/chatPaneSelectors.test.ts | 24 +++ .../parts/items/WorkflowAgentChat.tsx | 93 ++++++++++ .../parts/items/WorkflowOverlayBody.tsx | 67 ++++++- .../parts/items/workflowDisplay.test.ts | 23 ++- .../ChatPane/parts/items/workflowDisplay.ts | 53 +++++- src/renderer/state/subAgentSelectors.ts | 9 +- src/shared/contracts/runtimeEvent.ts | 29 +++ src/shared/ipc/procedures/thread.ts | 8 + src/shared/ipc/schemas.ts | 14 ++ src/shared/remote/gitProcedures.ts | 1 + .../claude/canonicalMapping/dispatch.ts | 7 + .../claude/canonicalMapping/taskLifecycle.ts | 18 ++ .../claude/canonicalMapping/toolPayload.ts | 1 + .../claude/canonicalMapping/workflowOutput.ts | 32 ++++ .../agents/claude/sdkCanonicalMapping.test.ts | 94 ++++++++++ .../agents/claude/sdkCanonicalMappingState.ts | 10 +- src/supervisor/ipcHandlers.ts | 4 + .../workflows/agentChatEvents.test.ts | 135 ++++++++++++++ src/supervisor/workflows/agentChatEvents.ts | 170 ++++++++++++++++++ 19 files changed, 773 insertions(+), 19 deletions(-) create mode 100644 src/renderer/components/thread/ChatPane/parts/items/WorkflowAgentChat.tsx create mode 100644 src/supervisor/agents/claude/canonicalMapping/workflowOutput.ts create mode 100644 src/supervisor/workflows/agentChatEvents.test.ts create mode 100644 src/supervisor/workflows/agentChatEvents.ts diff --git a/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts b/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts index c0bfbd180..34cac48ca 100644 --- a/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts +++ b/src/renderer/components/thread/ChatPane/chatPaneSelectors.test.ts @@ -580,6 +580,7 @@ describe("chatPaneSelectors", () => { state: "started", payload: { name: "Workflow", status: "running" }, streams: {}, + observedLive: true, }, "tool-2": { id: "tool-2", @@ -601,6 +602,28 @@ describe("chatPaneSelectors", () => { ]); }); + it("drops workflows hydrated from history (not observed live this session)", () => { + const state = { + runtimeItemIdsByThread: { t1: ["workflow-replayed"] }, + runtimeItemsByIdByThread: { + t1: { + "workflow-replayed": { + id: "workflow-replayed", + type: "tool_call", + state: "completed", + payload: { name: "Workflow", status: "success" }, + streams: {}, + // No observedLive — seeded from the DB on thread reopen. The + // launching process is gone; the composer dock must stay empty. + }, + }, + }, + runtimeStructuralVersionByThread: { t1: 1 }, + } as unknown as AppStoreState; + + expect(selectActiveSubAgentParentItemIds(state, "t1")).toEqual([]); + }); + it("tracks running native Agent calls without retaining completed ones", () => { const itemIds = ["agent-running", "agent-completed", "workflow-completed"]; const items = { @@ -632,6 +655,7 @@ describe("chatPaneSelectors", () => { state: "completed", payload: { name: "Workflow", status: "success" }, streams: {}, + observedLive: true, }, }; const activeState = { diff --git a/src/renderer/components/thread/ChatPane/parts/items/WorkflowAgentChat.tsx b/src/renderer/components/thread/ChatPane/parts/items/WorkflowAgentChat.tsx new file mode 100644 index 000000000..45ade7923 --- /dev/null +++ b/src/renderer/components/thread/ChatPane/parts/items/WorkflowAgentChat.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState, type ReactNode } from "react"; +import type { ProjectLocation } from "@/shared/contracts"; +import { readBridge } from "@/renderer/bridge"; +import { useAppStore } from "@/renderer/state/appStore"; +import { selectVisibleThreadTimelineEntries } from "../../chatPaneSelectors"; +import { ChatItemRow } from "./ChatItemRow"; + +/** + * Render a workflow agent's transcript with the real chat timeline. The + * supervisor converts the agent's on-disk jsonl into canonical runtime events; + * we key them under a synthetic per-agent thread id in the regular runtime + * store so `ChatItemRow` (which reads items from the store by thread id) works + * unchanged. Each poll clears and re-applies the synthetic thread — the events + * are deterministic snapshots, so replacing is cheaper than merging streamed + * deltas correctly. + */ +interface WorkflowAgentChatProps { + transcriptDir: string; + agentId: string; + /** Terminal agents are fetched once; running agents poll. */ + agentFinished: boolean; + location: ProjectLocation; + /** Rendered while no transcript entries are available (yet). */ + fallback: ReactNode; +} + +const POLL_INTERVAL_MS = 1500; + +export function WorkflowAgentChat({ + transcriptDir, + agentId, + agentFinished, + location, + fallback, +}: WorkflowAgentChatProps) { + const syntheticThreadId = `wf-agent-chat:${agentId}`; + const entries = useAppStore((s) => selectVisibleThreadTimelineEntries(s, syntheticThreadId)); + // Deferred fallback: render nothing until the first read settles, so the + // old boxed sections don't flash before the transcript-backed chat swaps in. + const [settledAgentId, setSettledAgentId] = useState(null); + + useEffect(() => { + let cancelled = false; + let timer: number | undefined; + + const fetchOnce = async (): Promise => { + try { + const { events } = await readBridge().workflowAgentChat({ + threadId: syntheticThreadId, + transcriptDir, + agentId, + agentFinished, + location, + }); + if (cancelled) return; + const store = useAppStore.getState(); + store.clearThreadRuntimeEvents(syntheticThreadId); + if (events.length > 0) store.applyRuntimeEvents(syntheticThreadId, events); + } catch (err) { + console.warn("[workflow] agent chat read failed", { agentId, err }); + } + if (cancelled) return; + setSettledAgentId(agentId); + if (!agentFinished) { + timer = window.setTimeout(() => void fetchOnce(), POLL_INTERVAL_MS); + } + }; + void fetchOnce(); + + return () => { + cancelled = true; + if (timer !== undefined) window.clearTimeout(timer); + useAppStore.getState().clearThreadRuntimeEvents(syntheticThreadId); + }; + }, [syntheticThreadId, transcriptDir, agentId, agentFinished, location]); + + if (entries.length === 0) { + return settledAgentId === agentId ? fallback : null; + } + return ( +
+ {entries.map((entry, index) => ( + + ))} +
+ ); +} diff --git a/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx b/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx index 6eae72f96..2a8593a3d 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx @@ -14,6 +14,7 @@ import { PixelLoader } from "@/renderer/components/common/PixelLoader"; import { ThreadDockRow } from "@/renderer/components/thread/ThreadDockUI"; import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount"; import { useWorkflowRun } from "@/renderer/state/useWorkflowRun"; +import { WorkflowAgentChat } from "./WorkflowAgentChat"; import type { WorkflowInfo } from "./workflowDisplay"; /** @@ -94,7 +95,12 @@ export function WorkflowOverlayBody({ : t`No agents in this phase.` } /> - + {unphased.length > 0 && phases.length > 0 ? ( @@ -266,7 +272,17 @@ function AgentRow({ ); } -function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phaseTitle: string }) { +function AgentDetail({ + agent, + phaseTitle, + transcriptDir, + location, +}: { + agent: WorkflowAgent | null; + phaseTitle: string; + transcriptDir: string | undefined; + location: ProjectLocation | undefined; +}) { if (!agent) { return (
@@ -303,6 +319,31 @@ function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phase

) : null} + {transcriptDir && location ? ( +
+ } + /> +
+ ) : ( + + )} +
+ ); +} + +/** + * Pre-transcript rendering of an agent's prompt/outcome/chat from the manifest + * previews. Shown until the transcript-backed chat timeline has entries (or + * when the transcript location isn't available at all). + */ +function AgentDetailFallback({ agent }: { agent: WorkflowAgent }) { + return ( + <> {agent.promptPreview ? (

@@ -355,7 +396,7 @@ function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phase

) : null} - + ); } @@ -432,22 +473,30 @@ function phasesFromInfo(info: WorkflowInfo): WorkflowPhase[] { } function applyWorkflowPlan(run: WorkflowRun, workflow: WorkflowInfo): WorkflowRun { - if (run.phases.length > 0 || workflow.plannedAgents.length === 0) return run; + // Statically planned agents (parsed from the script) win; otherwise fall + // back to live observations from the run's progress descriptions. Both are + // in agent-start order, matching the journal order of `unphasedAgents`. + const plan = workflow.plannedAgents.length > 0 ? workflow.plannedAgents : workflow.liveAgents; + if (run.phases.length > 0 || plan.length === 0) return run; const phases = phasesFromInfo(workflow); const phaseByTitle = new Map(phases.map((phase) => [phase.title, phase])); const unphasedAgents: WorkflowAgent[] = []; const agents = run.unphasedAgents.map((agent, index) => { - const planned = workflow.plannedAgents[index]; + const planned = plan[index]; if (!planned) return agent; const merged: WorkflowAgent = { ...agent, - label: planned.label, - ...(planned.phaseTitle ? { phaseTitle: planned.phaseTitle } : {}), + // A synthesized in-flight agent is labeled with its raw id; only then is + // the positional pairing an improvement. A real label (from the manifest + // or transcript inference) is more trustworthy than order-based pairing. + label: agent.label === agent.agentId ? planned.label : agent.label, + ...(planned.phaseTitle && !agent.phaseTitle ? { phaseTitle: planned.phaseTitle } : {}), ...(planned.model && !agent.model ? { model: planned.model } : {}), }; - if (planned.phaseTitle) { - const target = phaseByTitle.get(planned.phaseTitle); + const targetTitle = merged.phaseTitle; + if (targetTitle) { + const target = phaseByTitle.get(targetTitle); if (target) { target.agents.push(merged); return null; diff --git a/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.test.ts b/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.test.ts index 19bee1bc6..5cca2c79a 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.test.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.test.ts @@ -40,7 +40,28 @@ Run ID: wf_5478fde3-ae0 Use /workflows to watch live progress.`; describe("parseWorkflowInfo", () => { - it("prefers progress.description for the description", () => { + it("prefers the structured workflow payload over text parsing", () => { + const info = parseWorkflowInfo( + makePayload({ + progress: { description: "verify:runtimeItems.ts:altitude" }, + workflow: { + name: "simplify-review", + runId: "wf_struct", + summary: "Four-angle simplify review", + transcriptDir: "/home/x/.claude/projects/p/sess/subagents/workflows/wf_struct", + }, + }), + ); + // The stable workflow summary wins over the live agent label. + expect(info.description).toBe("Four-angle simplify review"); + expect(info.runId).toBe("wf_struct"); + expect(info.transcriptDir).toBe( + "/home/x/.claude/projects/p/sess/subagents/workflows/wf_struct", + ); + expect(info.manifestPath).toBe("/home/x/.claude/projects/p/sess/workflows/wf_struct.json"); + }); + + it("uses progress.description when no structured summary exists", () => { const info = parseWorkflowInfo( makePayload({ args: { script: SCRIPT }, diff --git a/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts b/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts index 37824f14d..8e2e1afb5 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts @@ -15,6 +15,12 @@ export interface WorkflowInfo { description?: string; phases: WorkflowPhase[]; plannedAgents: WorkflowPlannedAgent[]; + /** + * Agents observed live via the run's progress descriptions ("Phase: label"), + * in first-seen (start) order. Used to name journal-synthesized agents while + * the run is in flight; the completed manifest supersedes them. + */ + liveAgents: WorkflowPlannedAgent[]; runId?: string; /** Directory holding the per-agent jsonl transcripts. */ transcriptDir?: string; @@ -174,32 +180,41 @@ function evaluateLabelPart(part: string, alias: string, item: ScriptArrayItem): } /** - * Derive a human-readable view of a `Workflow` tool call. The provider payload - * carries the orchestration script in `args.script`, a live description in - * `progress.description`, and a launch-confirmation blob in `result`. We surface - * the description and the planned phases, and drop the launch plumbing. + * Derive a human-readable view of a `Workflow` tool call. The provider mapper + * normalizes the SDK's structured `WorkflowOutput` onto `payload.workflow` + * (runId, transcriptDir, summary) — the durable source, since the launch + * tool_result text is swallowed by the background-task keepalive. The + * result-text regexes remain as a fallback for threads persisted before the + * structured field existed. The stable workflow summary wins over the live + * `progress.description` (the currently running agent's label) so the title + * doesn't end up as the last agent that happened to run. */ export function parseWorkflowInfo(payload: ToolCallPayload): WorkflowInfo { const script = readScript(payload); const result = readResultText(payload); + const structured = payload.workflow; const description = + structured?.summary?.trim() || payload.progress?.description?.trim() || /(?:^|\n)Summary:\s*(.+)/.exec(result)?.[1]?.trim() || matchMetaString(script, "description") || readArgString(payload, "description") || undefined; - const runId = /(?:^|\n)Run ID:\s*(\S+)/.exec(result)?.[1]?.trim(); - const transcriptDir = /(?:^|\n)Transcript dir:\s*(.+)/.exec(result)?.[1]?.trim(); + const runId = structured?.runId ?? /(?:^|\n)Run ID:\s*(\S+)/.exec(result)?.[1]?.trim(); + const transcriptDir = + structured?.transcriptDir ?? /(?:^|\n)Transcript dir:\s*(.+)/.exec(result)?.[1]?.trim(); const manifestPath = transcriptDir && runId ? deriveManifestPath(transcriptDir, runId) : undefined; const phases = parsePhases(script); const plannedAgents = parsePlannedAgents(script); + const liveAgents = parseLiveAgents(structured?.liveDescriptions, phases); return { phases, plannedAgents, + liveAgents, ...(description ? { description } : {}), ...(runId ? { runId } : {}), ...(transcriptDir ? { transcriptDir } : {}), @@ -207,6 +222,32 @@ export function parseWorkflowInfo(payload: ToolCallPayload): WorkflowInfo { }; } +/** + * Parse live agent observations out of the run's progress descriptions. The + * workflow runtime updates the task description to ": " + * as each agent starts, so a description whose prefix (before the first colon) + * matches a declared phase title is an agent-start observation. Anything else + * (overall task description, log() narration) is ignored — mislabeling an + * agent is worse than showing its id. + */ +function parseLiveAgents( + liveDescriptions: readonly string[] | undefined, + phases: readonly WorkflowPhase[], +): WorkflowPlannedAgent[] { + if (!liveDescriptions?.length || phases.length === 0) return []; + const titles = new Map(phases.map((phase) => [phase.title.toLowerCase(), phase.title])); + const agents: WorkflowPlannedAgent[] = []; + for (const description of liveDescriptions) { + const match = /^([^:]+):\s*(.+)$/.exec(description); + const phaseTitle = match?.[1] ? titles.get(match[1].trim().toLowerCase()) : undefined; + const label = match?.[2]?.trim(); + if (!phaseTitle || !label) continue; + if (agents.some((agent) => agent.label === label)) continue; + agents.push({ label, phaseTitle }); + } + return agents; +} + /** * Workflow runtime writes the per-agent JSONLs under * `/subagents/workflows//` and the summary manifest under diff --git a/src/renderer/state/subAgentSelectors.ts b/src/renderer/state/subAgentSelectors.ts index b4eb83fbe..bcced7866 100644 --- a/src/renderer/state/subAgentSelectors.ts +++ b/src/renderer/state/subAgentSelectors.ts @@ -11,8 +11,13 @@ function classifyActiveSubAgent(item: RuntimeChatItem): "workflow" | "native" | // launched (background), but the real work continues for minutes. Keep // them in the active list as long as the SDK didn't reject the launch — // ActiveSubAgentTile subscribes to the manifest and auto-dismisses once - // it sees a terminal status. - if (isWorkflowTool(payload)) return payload.status === "error" ? null : "workflow"; + // it sees a terminal status. Only for items streamed live THIS session: + // a workflow replayed from history on thread reopen belongs to a process + // that is gone (or long finished) and must not resurrect the composer dock. + if (isWorkflowTool(payload)) { + if (payload.status === "error" || item.observedLive !== true) return null; + return "workflow"; + } if (item.state === "completed" && payload.status !== "running") return null; return "native"; } diff --git a/src/shared/contracts/runtimeEvent.ts b/src/shared/contracts/runtimeEvent.ts index c6894571c..d1b22abfc 100644 --- a/src/shared/contracts/runtimeEvent.ts +++ b/src/shared/contracts/runtimeEvent.ts @@ -185,6 +185,34 @@ export const acpToolLocationSchema = z.object({ }); export type AcpToolLocation = z.infer; +/** + * Structured launch metadata for an orchestration workflow (e.g. Claude Code's + * `Workflow` tool). Normalized at the provider boundary from the provider's + * structured tool result so the renderer can locate the run manifest and + * transcripts without parsing free-form result text. + */ +export const toolCallWorkflowSchema = z.object({ + /** Workflow name from the script's meta block. */ + name: z.string().optional(), + /** Run identifier (e.g. `wf_`), also the manifest file stem. */ + runId: z.string().optional(), + /** Human summary/description of what the workflow does. */ + summary: z.string().optional(), + /** Directory holding the per-agent transcript files for this run. */ + transcriptDir: z.string().optional(), + /** Path to the persisted workflow script for this invocation. */ + scriptPath: z.string().optional(), + /** + * Ordered distinct live progress descriptions observed while the run is in + * flight (e.g. "Verify: verify:file.ts"). The run manifest is only written + * at completion, so these are the sole live source of agent labels/phases — + * the renderer pairs them with journal-ordered agents until the manifest + * takes over. + */ + liveDescriptions: z.array(z.string()).optional(), +}); +export type ToolCallWorkflow = z.infer; + export const toolCallPayloadSchema = z.object({ name: z.string(), title: z.string().optional(), @@ -204,6 +232,7 @@ export const toolCallPayloadSchema = z.object({ status: toolCallStatusSchema, progress: toolCallProgressSchema.optional(), isSubAgent: z.boolean().optional(), + workflow: toolCallWorkflowSchema.optional(), }); export type ToolCallPayload = z.infer; diff --git a/src/shared/ipc/procedures/thread.ts b/src/shared/ipc/procedures/thread.ts index 5d0d6b544..eda35d13d 100644 --- a/src/shared/ipc/procedures/thread.ts +++ b/src/shared/ipc/procedures/thread.ts @@ -71,6 +71,9 @@ import { subAgentSubscribePayloadSchema, type SubAgentSubscribePayload, type SubAgentSubscribeResult, + workflowAgentChatPayloadSchema, + type WorkflowAgentChatPayload, + type WorkflowAgentChatResult, workflowGetRunPayloadSchema, type WorkflowGetRunPayload, type WorkflowGetRunResult, @@ -259,4 +262,9 @@ export const threadProcedures = { "supervisor", workflowGetRunPayloadSchema, ), + workflowAgentChat: definePayloadProcedure< + WorkflowAgentChatPayload, + WorkflowAgentChatResult, + "supervisor" + >("workflowAgentChat", "supervisor", workflowAgentChatPayloadSchema), } as const; diff --git a/src/shared/ipc/schemas.ts b/src/shared/ipc/schemas.ts index 7632a34ef..ceb378e77 100644 --- a/src/shared/ipc/schemas.ts +++ b/src/shared/ipc/schemas.ts @@ -107,6 +107,20 @@ export interface WorkflowGetRunResult { mtimeMs?: number; } +export const workflowAgentChatPayloadSchema = z.object({ + /** Synthetic renderer-side thread id the returned events are keyed under. */ + threadId: z.string().min(1), + transcriptDir: z.string().min(1), + agentId: z.string().min(1), + /** When true, dangling open items are flushed to completed. */ + agentFinished: z.boolean(), + location: projectLocationSchema, +}); +export type WorkflowAgentChatPayload = z.infer; +export interface WorkflowAgentChatResult { + events: RuntimeEvent[]; +} + export const dbStateKeySchema = z.string().min(1); export const dbStatePayloadSchema = z.object({ key: z.string().min(1), diff --git a/src/shared/remote/gitProcedures.ts b/src/shared/remote/gitProcedures.ts index 16fa9dda7..f3488da88 100644 --- a/src/shared/remote/gitProcedures.ts +++ b/src/shared/remote/gitProcedures.ts @@ -22,6 +22,7 @@ export const GIT_REMOTE_PROCEDURE_SCOPES = { subagentSubscribe: "session:read", subagentUnsubscribe: "session:read", workflowGetRun: "session:read", + workflowAgentChat: "session:read", // Project files searchProjectFiles: "session:read", diff --git a/src/supervisor/agents/claude/canonicalMapping/dispatch.ts b/src/supervisor/agents/claude/canonicalMapping/dispatch.ts index 3b26e0aba..49309fd65 100644 --- a/src/supervisor/agents/claude/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/claude/canonicalMapping/dispatch.ts @@ -30,6 +30,7 @@ import { completeTextItem, ensureTextItem, closeClaudeOpenItems } from "./textIt import { createToolItemState, hasToolCallPayload, isSubAgentToolName } from "./toolClassification"; import { startToolItem, syncSubAgentModelProgress } from "./toolItems"; import { toolPayload } from "./toolPayload"; +import { workflowFromToolUseResult } from "./workflowOutput"; export function readParentToolUseId(message: SDKMessage): string | undefined { const value = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id; @@ -450,6 +451,12 @@ function mapClaudeSdkMessageInner( if (!tool) continue; const text = extractText(obj.content); const images = extractToolResultImages(obj.content); + if (tool.toolName === "Workflow") { + const workflow = workflowFromToolUseResult( + (message as { tool_use_result?: unknown }).tool_use_result, + ); + if (workflow) tool.workflow = workflow; + } if (tool.planAggregatorRole) { if (tool.planAggregatorRole === "TaskCreate" && text.length > 0) { bindTaskCreateResult(state, tool, text); diff --git a/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts b/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts index bfc447c69..d971f5705 100644 --- a/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts +++ b/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts @@ -21,6 +21,22 @@ function readTaskUsage(obj: { usage?: unknown }): TaskUsage | undefined { return obj.usage && typeof obj.usage === "object" ? (obj.usage as TaskUsage) : undefined; } +/** + * Record a live progress description on a Workflow tool's structured metadata. + * Descriptions are the only in-flight source of agent labels ("Phase: label"); + * the manifest with authoritative labels is only written at completion. Kept + * distinct and in first-seen order so the renderer can pair them positionally + * with journal-ordered agents. + */ +function recordWorkflowLiveDescription(tool: ToolItemState, description: unknown): void { + if (tool.toolName !== "Workflow") return; + if (typeof description !== "string" || description.length === 0) return; + const workflow = (tool.workflow ??= {}); + const log = (workflow.liveDescriptions ??= []); + if (log.length >= 1000 || log.includes(description)) return; + log.push(description); +} + /** Merge a task lifecycle message's descriptive/usage fields onto a tool's progress. */ function mergeTaskProgress( tool: ToolItemState, @@ -63,6 +79,7 @@ export function applyTaskLifecycle(message: SDKMessage, state: ClaudeMapperState const tool = state.toolItemsById.get(toolUseId); if (!tool) return events; syncSubAgentModelProgress(tool); + recordWorkflowLiveDescription(tool, obj.description); const next = mergeTaskProgress(tool, obj, usage); if (Object.keys(next).length === 0) return events; @@ -127,6 +144,7 @@ export function applyTaskUpdated(message: SDKMessage, state: ClaudeMapperState): : undefined; const error = typeof patch.error === "string" && patch.error.length > 0 ? patch.error : undefined; if (!description && !error) return []; + recordWorkflowLiveDescription(tool, description); tool.progress = { ...tool.progress, ...(description ? { description } : {}), diff --git a/src/supervisor/agents/claude/canonicalMapping/toolPayload.ts b/src/supervisor/agents/claude/canonicalMapping/toolPayload.ts index 6c0583d3e..3c5495593 100644 --- a/src/supervisor/agents/claude/canonicalMapping/toolPayload.ts +++ b/src/supervisor/agents/claude/canonicalMapping/toolPayload.ts @@ -64,5 +64,6 @@ export function toolPayload( status, ...(tool.progress ? { progress: tool.progress } : {}), ...(isSubAgentToolName(tool.toolName) ? { isSubAgent: true } : {}), + ...(tool.workflow ? { workflow: tool.workflow } : {}), }; } diff --git a/src/supervisor/agents/claude/canonicalMapping/workflowOutput.ts b/src/supervisor/agents/claude/canonicalMapping/workflowOutput.ts new file mode 100644 index 000000000..b074a6389 --- /dev/null +++ b/src/supervisor/agents/claude/canonicalMapping/workflowOutput.ts @@ -0,0 +1,32 @@ +import type { ToolCallWorkflow } from "@/shared/contracts"; + +/** + * Normalize the SDK's structured `WorkflowOutput` (the `tool_use_result` of a + * `Workflow` tool launch) into the provider-agnostic `ToolCallWorkflow` shape. + * The launch tool_result text is swallowed by the subagent keepalive, so this + * structured record is the only durable source of the run's manifest and + * transcript locations. + */ +export function workflowFromToolUseResult(toolUseResult: unknown): ToolCallWorkflow | undefined { + if (!toolUseResult || typeof toolUseResult !== "object" || Array.isArray(toolUseResult)) { + return undefined; + } + const obj = toolUseResult as Record; + const read = (key: string): string | undefined => { + const value = obj[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + }; + const name = read("workflowName"); + const runId = read("runId"); + const summary = read("summary"); + const transcriptDir = read("transcriptDir"); + const scriptPath = read("scriptPath"); + if (!name && !runId && !summary && !transcriptDir && !scriptPath) return undefined; + return { + ...(name ? { name } : {}), + ...(runId ? { runId } : {}), + ...(summary ? { summary } : {}), + ...(transcriptDir ? { transcriptDir } : {}), + ...(scriptPath ? { scriptPath } : {}), + }; +} diff --git a/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts b/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts index efbaf69a2..4f46ffb6a 100644 --- a/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts +++ b/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts @@ -2580,6 +2580,100 @@ describe("sdkCanonicalMapping — background sub-agents", () => { expect(state.activeSubAgentToolToTask?.has("toolu_parent") ?? false).toBe(false); }); + it("preserves structured Workflow launch metadata through the task lifecycle", () => { + const state = createClaudeMapperState("thread-1"); + startAgentTool(state, "toolu_wf", "Workflow"); + mapClaudeSdkMessage( + { + type: "system", + subtype: "task_started", + session_id: "claude-session", + task_id: "task-WF", + tool_use_id: "toolu_wf", + description: "simplify review", + task_type: "local_workflow", + workflow_name: "simplify-review", + } as unknown as SDKMessage, + state, + ); + + // The launch tool_result carries the SDK's structured WorkflowOutput in + // tool_use_result. The keepalive swallows the result text, but the + // normalized workflow metadata must survive on the running payload. + const launchEvents = mapClaudeSdkMessage( + { + type: "user", + session_id: "claude-session", + tool_use_result: { + status: "async_launched", + taskId: "task-WF", + taskType: "local_workflow", + workflowName: "simplify-review", + runId: "wf_abc123", + summary: "Four-angle simplify review", + transcriptDir: "C:\\sess\\subagents\\workflows\\wf_abc123", + scriptPath: "C:\\sess\\workflows\\scripts\\simplify-review-wf_abc123.js", + }, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_wf", + content: "Workflow launched in background. Task ID: task-WF", + }, + ], + }, + } as unknown as SDKMessage, + state, + ); + expect(launchEvents).toMatchObject([ + { + type: "item.updated", + itemId: "toolu_wf", + payload: { + status: "running", + workflow: { + name: "simplify-review", + runId: "wf_abc123", + summary: "Four-angle simplify review", + transcriptDir: "C:\\sess\\subagents\\workflows\\wf_abc123", + scriptPath: "C:\\sess\\workflows\\scripts\\simplify-review-wf_abc123.js", + }, + }, + }, + ]); + expect(state.toolItemsById.has("toolu_wf")).toBe(true); + + // The authoritative task_notification close still carries the workflow + // metadata so the overlay can locate the manifest after completion. + const closeEvents = mapClaudeSdkMessage( + { + type: "system", + subtype: "task_notification", + session_id: "claude-session", + task_id: "task-WF", + tool_use_id: "toolu_wf", + status: "completed", + summary: "12 findings confirmed", + } as unknown as SDKMessage, + state, + ); + expect(closeEvents).toMatchObject([ + { + type: "item.updated", + itemId: "toolu_wf", + payload: { status: "success", workflow: { runId: "wf_abc123" } }, + }, + { + type: "item.completed", + itemId: "toolu_wf", + payload: { status: "success", workflow: { runId: "wf_abc123" } }, + }, + ]); + expect(state.toolItemsById.has("toolu_wf")).toBe(false); + }); + it("closes the parent as error on task_notification stopped (interrupt)", () => { const state = createClaudeMapperState("thread-1"); startAgentTool(state, "toolu_parent"); diff --git a/src/supervisor/agents/claude/sdkCanonicalMappingState.ts b/src/supervisor/agents/claude/sdkCanonicalMappingState.ts index 4299eaa1b..b4c0c47f0 100644 --- a/src/supervisor/agents/claude/sdkCanonicalMappingState.ts +++ b/src/supervisor/agents/claude/sdkCanonicalMappingState.ts @@ -1,4 +1,4 @@ -import type { CanonicalItemType, ToolCallProgress } from "@/shared/contracts"; +import type { CanonicalItemType, ToolCallProgress, ToolCallWorkflow } from "@/shared/contracts"; import type { PlanAggregatorState } from "../planAggregator"; export interface TextItemState { @@ -46,6 +46,14 @@ export interface ToolItemState { * line numbers instead of a synthetic `@@ -1 +1 @@` header. */ fileChangeMetadata?: FileChangeMetadata; + /** + * Structured launch metadata from a `Workflow` tool's `tool_use_result` + * (SDK `WorkflowOutput`). Kept on the tool state so every later payload — + * task_progress updates and the closing task_notification — still carries + * the run's manifest/transcript location after the launch tool_result + * (which is otherwise swallowed by the subagent keepalive) is gone. + */ + workflow?: ToolCallWorkflow; } export interface ClaudeMapperState { diff --git a/src/supervisor/ipcHandlers.ts b/src/supervisor/ipcHandlers.ts index 7b6253b36..2647db5aa 100644 --- a/src/supervisor/ipcHandlers.ts +++ b/src/supervisor/ipcHandlers.ts @@ -76,6 +76,10 @@ export function createSupervisorIpcHandlers(runtime: SupervisorRuntime): Supervi }); return { run }; }, + workflowAgentChat: async (payload) => { + const { readWorkflowAgentChatEvents } = await import("./workflows/agentChatEvents"); + return { events: await readWorkflowAgentChatEvents(payload) }; + }, createFileCheckpoint: async (payload) => ({ checkpoint: await checkpoints.create(payload), }), diff --git a/src/supervisor/workflows/agentChatEvents.test.ts b/src/supervisor/workflows/agentChatEvents.test.ts new file mode 100644 index 000000000..00f67687f --- /dev/null +++ b/src/supervisor/workflows/agentChatEvents.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment node + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readWorkflowAgentChatEvents } from "./agentChatEvents"; + +let dir: string; + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "wf-agent-chat-")); +}); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const TRANSCRIPT_LINES = [ + { + type: "user", + agentId: "abc", + timestamp: "2026-07-17T09:54:04.355Z", + message: { role: "user", content: "Review the diff for bugs." }, + }, + { + type: "attachment", + agentId: "abc", + attachment: { type: "skill_listing", content: "- something" }, + }, + { + type: "assistant", + agentId: "abc", + timestamp: "2026-07-17T09:54:06.000Z", + message: { + id: "msg_1", + role: "assistant", + model: "claude-sonnet-5", + content: [ + { type: "text", text: "Reading the file first." }, + { type: "tool_use", id: "toolu_read", name: "Read", input: { file_path: "a.ts" } }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }, + { + type: "user", + agentId: "abc", + timestamp: "2026-07-17T09:54:07.000Z", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_read", content: "file body" }], + }, + }, + { + type: "assistant", + agentId: "abc", + timestamp: "2026-07-17T09:54:09.000Z", + message: { + id: "msg_2", + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "No bugs found." }], + usage: { input_tokens: 20, output_tokens: 6 }, + }, + }, +]; + +function baseInput(agentId: string) { + return { + threadId: `wf-agent-chat:${agentId}`, + transcriptDir: dir, + agentId, + agentFinished: true, + location: { kind: "windows", path: "C:\\proj" } as const, + }; +} + +describe("readWorkflowAgentChatEvents", () => { + it("converts the transcript into canonical runtime events", async () => { + await writeFile( + join(dir, "agent-abc.jsonl"), + TRANSCRIPT_LINES.map((line) => JSON.stringify(line)).join("\n"), + "utf8", + ); + + const events = await readWorkflowAgentChatEvents(baseInput("abc")); + + // The prompt becomes a user_message item. + const userStart = events.find( + (e) => e.type === "item.started" && e.itemType === "user_message", + ); + expect(userStart).toMatchObject({ + payload: { content: [{ kind: "text", text: "Review the diff for bugs." }] }, + }); + + // Both assistant text blocks survive as separate assistant_message items + // (the second message must not collide with the first's block index). + const textDeltas = events.filter( + (e) => e.type === "content.delta" && e.stream === "assistant_text", + ); + expect(textDeltas.map((e) => (e.type === "content.delta" ? e.delta : ""))).toEqual([ + "Reading the file first.", + "No bugs found.", + ]); + + // The tool call opens and completes via its tool_result. + const toolStart = events.find( + (e) => + e.type === "item.started" && + e.itemType !== "user_message" && + e.itemType !== "assistant_message" && + e.itemType !== "reasoning", + ); + expect(toolStart).toBeDefined(); + const toolItemId = toolStart && "itemId" in toolStart ? toolStart.itemId : ""; + expect(events.some((e) => e.type === "item.completed" && e.itemId === toolItemId)).toBe(true); + + // Every id is deterministic and scoped to the agent. + const itemIds = events.flatMap((event) => ("itemId" in event ? [event.itemId] : [])); + expect(itemIds.length).toBeGreaterThan(0); + expect(itemIds.filter((id) => !/^wfa-abc-\d+$/.test(id))).toEqual([]); + }); + + it("is deterministic across re-reads of the same file", async () => { + const first = await readWorkflowAgentChatEvents(baseInput("abc")); + const second = await readWorkflowAgentChatEvents(baseInput("abc")); + expect(second).toEqual(first); + }); + + it("returns [] when the transcript does not exist", async () => { + const events = await readWorkflowAgentChatEvents(baseInput("missing")); + expect(events).toEqual([]); + }); +}); diff --git a/src/supervisor/workflows/agentChatEvents.ts b/src/supervisor/workflows/agentChatEvents.ts new file mode 100644 index 000000000..95aeed709 --- /dev/null +++ b/src/supervisor/workflows/agentChatEvents.ts @@ -0,0 +1,170 @@ +import { readFile } from "node:fs/promises"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { ProjectLocation, RuntimeEvent } from "@/shared/contracts"; +import { + closeClaudeOpenItems, + createClaudeMapperState, + mapClaudeSdkMessage, +} from "../agents/claude/sdkCanonicalMapping"; +import { manifestUncPath } from "./transcriptReader"; + +/** + * Convert a workflow agent's on-disk transcript (`agent-.jsonl`) into the + * same canonical runtime events a live thread produces, so the overlay can + * render the agent's chat with the real chat timeline (ChatItemRow) instead of + * bespoke transcript boxes. + * + * The jsonl lines carry full Claude SDK messages, so they run through the + * regular Claude canonical mapper. Two transcript-specific adjustments: + * - plain user text (the agent's prompt) is synthesized into a `user_message` + * item — the mapper only consumes `tool_result` user content; + * - item ids are remapped to a deterministic per-agent sequence so a re-read + * of the (append-only) file yields identical events for the same lines. + */ +export interface ReadWorkflowAgentChatInput { + /** Synthetic renderer-side thread id the events are keyed under. */ + threadId: string; + transcriptDir: string; + agentId: string; + /** When true, dangling open items are flushed to completed. */ + agentFinished: boolean; + location: ProjectLocation; +} + +export async function readWorkflowAgentChatEvents( + input: ReadWorkflowAgentChatInput, +): Promise { + let raw: string; + try { + raw = await readFile(agentJsonlPath(input), "utf8"); + } catch (err) { + if (isNotFoundError(err)) return []; + throw err; + } + + const state = createClaudeMapperState(input.threadId); + const events: RuntimeEvent[] = []; + let userMessageCount = 0; + + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + let record: unknown; + try { + record = JSON.parse(trimmed); + } catch { + continue; + } + if (!record || typeof record !== "object") continue; + const obj = record as Record; + const type = obj.type; + if (type !== "user" && type !== "assistant") continue; + + if (type === "user") { + const text = plainUserText(obj); + if (text) { + userMessageCount += 1; + const itemId = `user-${userMessageCount}`; + events.push( + { + type: "item.started", + threadId: input.threadId, + itemId, + itemType: "user_message", + payload: { content: [{ kind: "text", text }] }, + }, + { type: "item.completed", threadId: input.threadId, itemId }, + ); + } + } + + events.push(...mapClaudeSdkMessage(obj as unknown as SDKMessage, state)); + + // Full (non-streamed) assistant messages complete their text items + // immediately, but the per-index maps persist — without a clear, the next + // message's block 0 would collide with a completed slot and be dropped. + // (In live streaming `message_start` performs this reset.) + if (type === "assistant") { + state.assistantTextItems.clear(); + state.reasoningItems.clear(); + } + } + + if (input.agentFinished) { + events.push(...closeClaudeOpenItems(state)); + } + + return remapDeterministicIds( + events.filter( + (event) => + event.type === "item.started" || + event.type === "item.updated" || + event.type === "item.completed" || + event.type === "content.delta", + ), + input.agentId, + ); +} + +/** + * Extract the plain text of a user message that is NOT a tool_result (i.e. the + * agent's prompt). String content is the common SDK shape; array content may + * carry text blocks alongside tool_results — only the text blocks count here. + */ +function plainUserText(obj: Record): string | undefined { + const message = obj.message; + if (!message || typeof message !== "object") return undefined; + const content = (message as Record).content; + if (typeof content === "string") return content.trim() || undefined; + if (!Array.isArray(content)) return undefined; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const blockObj = block as Record; + if (blockObj.type === "text" && typeof blockObj.text === "string" && blockObj.text.length > 0) { + parts.push(blockObj.text); + } + } + const joined = parts.join("\n").trim(); + return joined || undefined; +} + +/** + * Rewrite mapper-generated item ids (random per invocation) into a stable + * `wfa--` sequence assigned in first-appearance order. The jsonl + * is append-only, so re-reads produce the same events for the same prefix of + * lines and the ids stay identical across polls. + */ +function remapDeterministicIds(events: RuntimeEvent[], agentId: string): RuntimeEvent[] { + const idMap = new Map(); + const mapId = (itemId: string): string => { + let mapped = idMap.get(itemId); + if (!mapped) { + mapped = `wfa-${agentId}-${idMap.size + 1}`; + idMap.set(itemId, mapped); + } + return mapped; + }; + return events.map((event) => + "itemId" in event && typeof event.itemId === "string" + ? { ...event, itemId: mapId(event.itemId) } + : event, + ); +} + +function agentJsonlPath(input: ReadWorkflowAgentChatInput): string { + const dir = + input.location.kind === "wsl" + ? manifestUncPath(input.location.uncPath, input.location.linuxPath, input.transcriptDir) + : input.transcriptDir; + const sep = dir.includes("\\") ? "\\" : "/"; + return `${dir.replace(/[\\/]+$/u, "")}${sep}agent-${input.agentId}.jsonl`; +} + +function isNotFoundError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + if (code === "ENOENT") return true; + const message = err instanceof Error ? err.message : String(err); + return /ENOENT|no such file/i.test(message); +} From 0f1954f547d8748918f3445fb381170accf24f57 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 17 Jul 2026 03:33:21 -0700 Subject: [PATCH 2/2] fix(workflow): avoid duplicate planned agents in overlay --- .../ChatPane/parts/items/WorkflowOverlayBody.tsx | 15 +++++++-------- .../ChatPane/parts/items/workflowDisplay.ts | 7 +++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx b/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx index 2a8593a3d..b374ad391 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/WorkflowOverlayBody.tsx @@ -482,9 +482,12 @@ function applyWorkflowPlan(run: WorkflowRun, workflow: WorkflowInfo): WorkflowRu const phases = phasesFromInfo(workflow); const phaseByTitle = new Map(phases.map((phase) => [phase.title, phase])); const unphasedAgents: WorkflowAgent[] = []; - const agents = run.unphasedAgents.map((agent, index) => { + for (const [index, agent] of run.unphasedAgents.entries()) { const planned = plan[index]; - if (!planned) return agent; + if (!planned) { + unphasedAgents.push(agent); + continue; + } const merged: WorkflowAgent = { ...agent, // A synthesized in-flight agent is labeled with its raw id; only then is @@ -499,14 +502,10 @@ function applyWorkflowPlan(run: WorkflowRun, workflow: WorkflowInfo): WorkflowRu const target = phaseByTitle.get(targetTitle); if (target) { target.agents.push(merged); - return null; + continue; } } - return merged; - }); - - for (const agent of agents) { - if (agent) unphasedAgents.push(agent); + unphasedAgents.push(merged); } return { ...run, phases, unphasedAgents }; diff --git a/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts b/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts index 8e2e1afb5..30f02675f 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts +++ b/src/renderer/components/thread/ChatPane/parts/items/workflowDisplay.ts @@ -209,7 +209,8 @@ export function parseWorkflowInfo(payload: ToolCallPayload): WorkflowInfo { transcriptDir && runId ? deriveManifestPath(transcriptDir, runId) : undefined; const phases = parsePhases(script); const plannedAgents = parsePlannedAgents(script); - const liveAgents = parseLiveAgents(structured?.liveDescriptions, phases); + const liveAgents = + plannedAgents.length > 0 ? [] : parseLiveAgents(structured?.liveDescriptions, phases); return { phases, @@ -237,12 +238,14 @@ function parseLiveAgents( if (!liveDescriptions?.length || phases.length === 0) return []; const titles = new Map(phases.map((phase) => [phase.title.toLowerCase(), phase.title])); const agents: WorkflowPlannedAgent[] = []; + const seenLabels = new Set(); for (const description of liveDescriptions) { const match = /^([^:]+):\s*(.+)$/.exec(description); const phaseTitle = match?.[1] ? titles.get(match[1].trim().toLowerCase()) : undefined; const label = match?.[2]?.trim(); if (!phaseTitle || !label) continue; - if (agents.some((agent) => agent.label === label)) continue; + if (seenLabels.has(label)) continue; + seenLabels.add(label); agents.push({ label, phaseTitle }); } return agents;