1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 mergeClaudeWorkflowProgress,
5 parseClaudeWorkflowRunHandles,
6} from "./claudeWorkflowProgress.ts";
7
8const startFrame = {
9 type: "system",
10 subtype: "task_started",
11 task_id: "w1",
12 task_type: "local_workflow",
13 workflow_name: "probe-wf",
14};
15
16const snapshotFrame = (
17 entries: ReadonlyArray<Record<string, unknown>>,
18 usage?: Record<string, number>,
19) => ({
20 type: "system",
21 subtype: "task_progress",
22 task_id: "w1",
23 description: "Alpha: alpha:one",
24 workflow_progress: entries,
25 ...(usage === undefined ? {} : { usage }),
26});
27
28const phase = (index: number, title: string) => ({ type: "workflow_phase", index, title });
29
30const agent = (overrides: Record<string, unknown>) => ({
31 type: "workflow_agent",
32 index: 1,
33 label: "alpha:one",
34 state: "start",
35 phaseIndex: 1,
36 phaseTitle: "Alpha",
37 ...overrides,
38});
39
40describe("mergeClaudeWorkflowProgress", () => {
41 it("marks a coordinator as a workflow before any member exists", () => {
42 const workflow = mergeClaudeWorkflowProgress({
43 previous: undefined,
44 name: "probe-wf",
45 message: startFrame,
46 });
47 expect(workflow).toEqual({ name: "probe-wf", phases: [], agents: [] });
48 });
49
50 it("leaves an ordinary subagent's frames alone", () => {
51 expect(
52 mergeClaudeWorkflowProgress({
53 previous: undefined,
54 message: {
55 type: "system",
56 subtype: "task_progress",
57 task_id: "t1",
58 description: "reading files",
59 usage: { total_tokens: 500, tool_uses: 2, duration_ms: 90 },
60 },
61 }),
62 ).toBeUndefined();
63 });
64
65 it("maps provider agent states and per-member metrics", () => {
66 const workflow = mergeClaudeWorkflowProgress({
67 previous: undefined,
68 message: snapshotFrame([
69 phase(1, "Alpha"),
70 agent({
71 state: "done",
72 agentId: "a1",
73 model: "claude-opus-5[1m]",
74 attempt: 2,
75 tokens: 71_141,
76 toolCalls: 3,
77 durationMs: 1277,
78 startedAt: 1_700_000_000_000,
79 queuedAt: 1_699_999_999_000,
80 promptPreview: "Reply with exactly: A1",
81 resultPreview: "A1",
82 }),
83 agent({ index: 2, label: "alpha:two", state: "queued" }),
84 ]),
85 });
86 expect(workflow?.phases).toEqual([{ index: 1, title: "Alpha" }]);
87 expect(workflow?.agents[0]).toEqual({
88 index: 1,
89 label: "alpha:one",
90 agentId: "a1",
91 state: "completed",
92 phaseIndex: 1,
93 phaseTitle: "Alpha",
94 model: "claude-opus-5[1m]",
95 attempt: 2,
96 totalTokens: 71_141,
97 toolCalls: 3,
98 durationMs: 1277,
99 queuedAt: 1_699_999_999_000,
100 startedAt: 1_700_000_000_000,
101 prompt: "Reply with exactly: A1",
102 result: "A1",
103 });
104 expect(workflow?.agents[1]?.state).toBe("queued");
105 });
106
107 it.each(["something-new", "toString", "constructor", "__proto__"])(
108 "treats unrecognised state %s as still running rather than settled",
109 (state) => {
110 const workflow = mergeClaudeWorkflowProgress({
111 previous: undefined,
112 message: snapshotFrame([agent({ state })]),
113 });
114 expect(workflow?.agents[0]?.state).toBe("running");
115 },
116 );
117
118 it("keeps the roster through a usage-only frame", () => {
119 const first = mergeClaudeWorkflowProgress({
120 previous: undefined,
121 name: "probe-wf",
122 message: snapshotFrame([phase(1, "Alpha"), agent({})], {
123 total_tokens: 100,
124 tool_uses: 1,
125 duration_ms: 40,
126 }),
127 });
128 const second = mergeClaudeWorkflowProgress({
129 previous: first,
130 message: snapshotFrame([], { total_tokens: 250, tool_uses: 2, duration_ms: 900 }),
131 });
132 expect(second?.agents).toHaveLength(1);
133 expect(second?.phases).toHaveLength(1);
134 expect(second?.name).toBe("probe-wf");
135 expect(second?.totalTokens).toBe(250);
136 });
137
138 it("harvests run handles from the launch acknowledgement", () => {
139 const workflow = mergeClaudeWorkflowProgress({
140 previous: undefined,
141 runHandles: parseClaudeWorkflowRunHandles({
142 status: "async_launched",
143 taskType: "local_workflow",
144 runId: "wf_abc",
145 transcriptDir: "/root/.claude/projects/p/s/subagents/workflows/wf_abc",
146 scriptPath: "/root/.claude/projects/p/s/workflows/scripts/x.js",
147 }),
148 message: startFrame,
149 });
150 expect(workflow?.runHandles?.transcriptDir).toBe(
151 "/root/.claude/projects/p/s/subagents/workflows/wf_abc",
152 );
153 expect(workflow?.runHandles?.runId).toBe("wf_abc");
154 });
155
156 it("ignores a non-workflow tool acknowledgement", () => {
157 expect(
158 parseClaudeWorkflowRunHandles({ status: "async_launched", taskType: "local_agent" }),
159 ).toBeUndefined();
160 });
161
162 it("keeps a phase the plan reached at runtime even if a later frame omits it", () => {
163 const first = mergeClaudeWorkflowProgress({
164 previous: undefined,
165 message: snapshotFrame([phase(1, "Alpha"), phase(2, "Beta"), phase(3, "Gamma")]),
166 });
167 const second = mergeClaudeWorkflowProgress({
168 previous: first,
169 message: snapshotFrame([phase(1, "Alpha")]),
170 });
171 expect(second?.phases.map((entry) => entry.title)).toEqual(["Alpha", "Beta", "Gamma"]);
172 });
173
174 it("keeps other members and phase links when a sparse frame starts a new attempt", () => {
175 const first = mergeClaudeWorkflowProgress({
176 previous: undefined,
177 message: snapshotFrame([
178 phase(1, "Alpha"),
179 agent({
180 state: "error",
181 attempt: 1,
182 promptPreview: "First prompt",
183 resultPreview: "First result",
184 tokens: 100,
185 toolCalls: 2,
186 durationMs: 50,
187 queuedAt: 1000,
188 startedAt: 1010,
189 }),
190 agent({ index: 2, state: "done" }),
191 ]),
192 });
193 const second = mergeClaudeWorkflowProgress({
194 previous: first,
195 message: snapshotFrame([
196 { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 },
197 ]),
198 });
199 expect(second?.agents).toEqual([
200 {
201 index: 1,
202 label: "alpha:one",
203 state: "running",
204 attempt: 2,
205 phaseIndex: 1,
206 phaseTitle: "Alpha",
207 },
208 first?.agents[1],
209 ]);
210 expect(second?.phases).toEqual(first?.phases);
211 });
212
213 it("ignores an older attempt after a retry completes", () => {
214 const first = mergeClaudeWorkflowProgress({
215 previous: undefined,
216 message: snapshotFrame([agent({ state: "done", attempt: 2, resultPreview: "New result" })]),
217 });
218 const second = mergeClaudeWorkflowProgress({
219 previous: first,
220 message: snapshotFrame([agent({ state: "error", attempt: 1, resultPreview: "Old result" })]),
221 });
222 expect(second?.agents).toEqual(first?.agents);
223 });
224
225 it.each([
226 ["done", "start"],
227 ["done", "queued"],
228 ["error", "start"],
229 ["error", "queued"],
230 ["start", "queued"],
231 ])("keeps member state when the same attempt regresses from %s to %s", (state, staleState) => {
232 const first = mergeClaudeWorkflowProgress({
233 previous: undefined,
234 message: snapshotFrame([agent({ state, attempt: 2, resultPreview: "Current result" })]),
235 });
236 const second = mergeClaudeWorkflowProgress({
237 previous: first,
238 message: snapshotFrame([
239 agent({ state: staleState, attempt: 2, resultPreview: "Old result" }),
240 ]),
241 });
242 expect(second?.agents).toEqual(first?.agents);
243 });
244
245 it("retains member fields omitted by a later frame in the same attempt", () => {
246 const first = mergeClaudeWorkflowProgress({
247 previous: undefined,
248 message: snapshotFrame([agent({ attempt: 2, promptPreview: "Current prompt", tokens: 100 })]),
249 });
250 const second = mergeClaudeWorkflowProgress({
251 previous: first,
252 message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]),
253 });
254 expect(second?.agents[0]).toMatchObject({
255 state: "completed",
256 attempt: 2,
257 prompt: "Current prompt",
258 result: "Current result",
259 totalTokens: 100,
260 });
261 });
262
263 it("keeps existing run handles when a later acknowledgement supplies only a script path", () => {
264 const first = mergeClaudeWorkflowProgress({
265 previous: undefined,
266 runHandles: { runId: "wf_abc", transcriptDir: "/workflows/wf_abc" },
267 message: startFrame,
268 });
269 const second = mergeClaudeWorkflowProgress({
270 previous: first,
271 runHandles: { scriptPath: "/scripts/workflow.js" },
272 message: startFrame,
273 });
274 expect(second?.runHandles).toEqual({
275 runId: "wf_abc",
276 transcriptDir: "/workflows/wf_abc",
277 scriptPath: "/scripts/workflow.js",
278 });
279 });
280
281 it("preserves prompt and result whitespace", () => {
282 const workflow = mergeClaudeWorkflowProgress({
283 previous: undefined,
284 message: snapshotFrame([
285 agent({ promptPreview: " indented\n", resultPreview: "\n answer\n" }),
286 ]),
287 });
288 expect(workflow?.agents[0]?.prompt).toBe(" indented\n");
289 expect(workflow?.agents[0]?.result).toBe("\n answer\n");
290 });
291
292 it("drops timestamps outside the DateTime range", () => {
293 const workflow = mergeClaudeWorkflowProgress({
294 previous: undefined,
295 message: snapshotFrame([
296 agent({ startedAt: 8_640_000_000_000_001, queuedAt: Number.MAX_SAFE_INTEGER }),
297 agent({ index: 2, startedAt: 8_640_000_000_000_000, queuedAt: 0 }),
298 ]),
299 });
300 expect(workflow?.agents[0]).not.toHaveProperty("startedAt");
301 expect(workflow?.agents[0]).not.toHaveProperty("queuedAt");
302 expect(workflow?.agents[1]?.startedAt).toBe(8_640_000_000_000_000);
303 expect(workflow?.agents[1]?.queuedAt).toBe(0);
304 });
305
306 it("drops malformed entries instead of failing the frame", () => {
307 const workflow = mergeClaudeWorkflowProgress({
308 previous: undefined,
309 message: snapshotFrame([
310 { type: "workflow_phase", title: "No index" },
311 { type: "workflow_agent", index: 4 },
312 { type: "something_else", index: 1, title: "Ignored" },
313 phase(1, "Alpha"),
314 ]),
315 });
316 expect(workflow).toEqual({ phases: [{ index: 1, title: "Alpha" }], agents: [] });
317 });
318});