@united-workforce/cli 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +32 -5
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/broker-step-active-turns.test.d.ts +20 -0
  4. package/dist/__tests__/broker-step-active-turns.test.d.ts.map +1 -0
  5. package/dist/__tests__/broker-step-active-turns.test.js +428 -0
  6. package/dist/__tests__/broker-step-active-turns.test.js.map +1 -0
  7. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts +13 -0
  8. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts.map +1 -0
  9. package/dist/__tests__/broker-step-turn-chain-phase2.test.js +429 -0
  10. package/dist/__tests__/broker-step-turn-chain-phase2.test.js.map +1 -0
  11. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts +18 -0
  12. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts.map +1 -0
  13. package/dist/__tests__/e2e-broker-step-suspend.test.js +313 -0
  14. package/dist/__tests__/e2e-broker-step-suspend.test.js.map +1 -0
  15. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts +28 -0
  16. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts.map +1 -0
  17. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js +322 -0
  18. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js.map +1 -0
  19. package/dist/__tests__/log-tag-validity.test.d.ts +2 -0
  20. package/dist/__tests__/log-tag-validity.test.d.ts.map +1 -0
  21. package/dist/__tests__/log-tag-validity.test.js +110 -0
  22. package/dist/__tests__/log-tag-validity.test.js.map +1 -0
  23. package/dist/__tests__/setup-agent-discovery.test.js +23 -23
  24. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  25. package/dist/__tests__/step-show-json.test.js +5 -5
  26. package/dist/__tests__/step-show-json.test.js.map +1 -1
  27. package/dist/__tests__/step-show-text.test.d.ts +2 -0
  28. package/dist/__tests__/step-show-text.test.d.ts.map +1 -0
  29. package/dist/__tests__/step-show-text.test.js +192 -0
  30. package/dist/__tests__/step-show-text.test.js.map +1 -0
  31. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts +21 -0
  32. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts.map +1 -0
  33. package/dist/__tests__/step-turns-cli-subprocess.test.js +356 -0
  34. package/dist/__tests__/step-turns-cli-subprocess.test.js.map +1 -0
  35. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts +21 -0
  36. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts.map +1 -0
  37. package/dist/__tests__/step-turns-panorama-phase3.test.js +476 -0
  38. package/dist/__tests__/step-turns-panorama-phase3.test.js.map +1 -0
  39. package/dist/__tests__/step-turns.test.d.ts +24 -0
  40. package/dist/__tests__/step-turns.test.d.ts.map +1 -0
  41. package/dist/__tests__/step-turns.test.js +646 -0
  42. package/dist/__tests__/step-turns.test.js.map +1 -0
  43. package/dist/__tests__/store-turn-chain.test.d.ts +2 -0
  44. package/dist/__tests__/store-turn-chain.test.d.ts.map +1 -0
  45. package/dist/__tests__/store-turn-chain.test.js +341 -0
  46. package/dist/__tests__/store-turn-chain.test.js.map +1 -0
  47. package/dist/__tests__/thread-list-limit-offset.test.d.ts +24 -0
  48. package/dist/__tests__/thread-list-limit-offset.test.d.ts.map +1 -0
  49. package/dist/__tests__/thread-list-limit-offset.test.js +254 -0
  50. package/dist/__tests__/thread-list-limit-offset.test.js.map +1 -0
  51. package/dist/__tests__/thread-list-template-ms-date.test.js +7 -2
  52. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -1
  53. package/dist/__tests__/thread.test.js +28 -14
  54. package/dist/__tests__/thread.test.js.map +1 -1
  55. package/dist/cli.js +910 -344
  56. package/dist/cli.js.map +1 -1
  57. package/dist/commands/broker-step.d.ts +10 -3
  58. package/dist/commands/broker-step.d.ts.map +1 -1
  59. package/dist/commands/broker-step.js +231 -27
  60. package/dist/commands/broker-step.js.map +1 -1
  61. package/dist/commands/prompt.d.ts.map +1 -1
  62. package/dist/commands/prompt.js +42 -50
  63. package/dist/commands/prompt.js.map +1 -1
  64. package/dist/commands/setup.d.ts +6 -4
  65. package/dist/commands/setup.d.ts.map +1 -1
  66. package/dist/commands/setup.js +16 -26
  67. package/dist/commands/setup.js.map +1 -1
  68. package/dist/commands/step.d.ts +48 -1
  69. package/dist/commands/step.d.ts.map +1 -1
  70. package/dist/commands/step.js +496 -3
  71. package/dist/commands/step.js.map +1 -1
  72. package/dist/output-mappers.d.ts +8 -0
  73. package/dist/output-mappers.d.ts.map +1 -1
  74. package/dist/output-mappers.js +72 -18
  75. package/dist/output-mappers.js.map +1 -1
  76. package/dist/schemas.d.ts +3 -0
  77. package/dist/schemas.d.ts.map +1 -1
  78. package/dist/schemas.js +17 -3
  79. package/dist/schemas.js.map +1 -1
  80. package/dist/store.d.ts +147 -1
  81. package/dist/store.d.ts.map +1 -1
  82. package/dist/store.js +254 -1
  83. package/dist/store.js.map +1 -1
  84. package/dist/text-renderers.d.ts.map +1 -1
  85. package/dist/text-renderers.js +27 -2
  86. package/dist/text-renderers.js.map +1 -1
  87. package/package.json +7 -6
  88. package/src/__tests__/broker-step-active-turns.test.ts +509 -0
  89. package/src/__tests__/broker-step-turn-chain-phase2.test.ts +525 -0
  90. package/src/__tests__/e2e-broker-step-suspend.test.ts +351 -0
  91. package/src/__tests__/e2e-thread-resume-timeout-suspend.test.ts +360 -0
  92. package/src/__tests__/log-tag-validity.test.ts +124 -0
  93. package/src/__tests__/setup-agent-discovery.test.ts +23 -23
  94. package/src/__tests__/step-show-json.test.ts +5 -5
  95. package/src/__tests__/step-show-text.test.ts +236 -0
  96. package/src/__tests__/step-turns-cli-subprocess.test.ts +411 -0
  97. package/src/__tests__/step-turns-panorama-phase3.test.ts +579 -0
  98. package/src/__tests__/step-turns.test.ts +734 -0
  99. package/src/__tests__/store-turn-chain.test.ts +386 -0
  100. package/src/__tests__/thread-list-limit-offset.test.ts +305 -0
  101. package/src/__tests__/thread-list-template-ms-date.test.ts +7 -2
  102. package/src/__tests__/thread.test.ts +29 -15
  103. package/src/cli.ts +1056 -483
  104. package/src/commands/broker-step.ts +315 -38
  105. package/src/commands/prompt.ts +42 -50
  106. package/src/commands/setup.ts +16 -28
  107. package/src/commands/step.ts +655 -3
  108. package/src/output-mappers.ts +99 -21
  109. package/src/schemas.ts +32 -2
  110. package/src/store.ts +297 -2
  111. package/src/text-renderers.ts +35 -2
@@ -0,0 +1,579 @@
1
+ /**
2
+ * Phase 3 (#421) — buildTurnsPanorama rewritten to use owner-based segmentation.
3
+ *
4
+ * This is the consumer-side rewrite that root-causes #412 (recurring role in-flight
5
+ * mis-attribution). The panorama now:
6
+ * 1. Walks the step chain (step-start prev) instead of role-keyed vars
7
+ * 2. Each segment's turns are sourced via `turnsOfStep(turnHead, stepStartHash)`
8
+ * 3. In-flight detection: active-step matches step-start AND no step-complete
9
+ * 4. edgePrompt is read directly from step-start
10
+ *
11
+ * Covers the specs:
12
+ * - step-turns-panorama-owner-segmentation.md
13
+ * - step-turns-panorama-412-recurring-role.md
14
+ * - step-turns-panorama-role-filter.md
15
+ * - step-turns-panorama-pagination.md
16
+ * - step-turns-panorama-live-mode.md
17
+ * - step-turns-panorama-in-flight-detection.md
18
+ * - step-turns-panorama-edge-prompt.md
19
+ */
20
+
21
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { putSchema } from "@ocas/core";
25
+ import type { CasRef, ThreadId } from "@united-workforce/protocol";
26
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
27
+ import { cmdStepTurns } from "../commands/step.js";
28
+ import {
29
+ createUwfStore,
30
+ getActiveTurnHead,
31
+ setActiveStep,
32
+ setActiveTurnHead,
33
+ setThread,
34
+ turnsOfStep,
35
+ type UwfStore,
36
+ walkTurnChain,
37
+ writeStepStart,
38
+ writeTurnNode,
39
+ } from "../store.js";
40
+
41
+ // ── schemas ─────────────────────────────────────────────────────────────────
42
+
43
+ const DETAIL_SCHEMA = {
44
+ title: "broker-detail-phase3",
45
+ type: "object" as const,
46
+ required: ["sessionId", "duration", "turnCount"],
47
+ properties: {
48
+ sessionId: { type: "string" as const },
49
+ duration: { type: "integer" as const },
50
+ turnCount: { type: "integer" as const },
51
+ },
52
+ additionalProperties: false,
53
+ };
54
+
55
+ // ── fixtures ─────────────────────────────────────────────────────────────────
56
+
57
+ let tmpDir: string;
58
+ let savedOcasHome: string | undefined;
59
+
60
+ beforeEach(async () => {
61
+ savedOcasHome = process.env.OCAS_HOME;
62
+ tmpDir = await mkdtemp(join(tmpdir(), "step-turns-phase3-"));
63
+ const casDir = join(tmpDir, "cas");
64
+ await mkdir(casDir, { recursive: true });
65
+ process.env.OCAS_HOME = casDir;
66
+ });
67
+
68
+ afterEach(async () => {
69
+ if (savedOcasHome === undefined) delete process.env.OCAS_HOME;
70
+ else process.env.OCAS_HOME = savedOcasHome;
71
+ await rm(tmpDir, { recursive: true, force: true });
72
+ });
73
+
74
+ const THREAD_ID = "06FDPANORAMAPH3OWNERTEST1" as ThreadId;
75
+
76
+ /**
77
+ * Seed a complete Phase 3 scenario:
78
+ * - Creates start node
79
+ * - Creates step-starts linked via prev chain
80
+ * - Creates turns linked via prev chain, each with owner pointing to its step-start
81
+ * - Creates StepNodes for completed steps (for backward compat with walkChain)
82
+ * - Sets thread head (StepNode or StartNode), active-step (if in-flight), active-turn-head
83
+ *
84
+ * Key insight: Thread head still points to StepNode (or StartNode), step-start is
85
+ * a parallel chain. The panorama should read from active-turn-head and walk
86
+ * owner -> step-start -> prev to reconstruct segments.
87
+ *
88
+ * Returns { startHash, stepStarts, stepNodes, turns } for verification.
89
+ */
90
+ async function seedPhase3Chain(
91
+ uwf: UwfStore,
92
+ threadId: ThreadId,
93
+ steps: {
94
+ role: string;
95
+ edgePrompt: string;
96
+ turnContents: string[];
97
+ inFlight: boolean;
98
+ }[],
99
+ ): Promise<{
100
+ startHash: CasRef;
101
+ stepStarts: CasRef[];
102
+ stepNodes: (CasRef | null)[];
103
+ turns: CasRef[][];
104
+ }> {
105
+ // Create workflow and start node
106
+ const workflowHash = uwf.store.cas.put(uwf.schemas.workflow, {
107
+ version: 1,
108
+ name: "phase3-test-wf",
109
+ description: "phase3",
110
+ roles: {},
111
+ graph: {},
112
+ }) as CasRef;
113
+ const startHash = uwf.store.cas.put(uwf.schemas.startNode, {
114
+ workflow: workflowHash,
115
+ prompt: "task",
116
+ cwd: "/tmp/work",
117
+ }) as CasRef;
118
+
119
+ const detailSchemaHash = putSchema(uwf.store, DETAIL_SCHEMA);
120
+ const outputHash = uwf.store.cas.put(uwf.schemas.text, "output") as CasRef;
121
+
122
+ const stepStarts: CasRef[] = [];
123
+ const stepNodes: (CasRef | null)[] = [];
124
+ const allTurns: CasRef[][] = [];
125
+ let prevStepStart: CasRef | null = null;
126
+ let prevStepNode: CasRef | null = null;
127
+ let prevTurn: CasRef | null = null;
128
+ let inFlightStepStart: CasRef | null = null;
129
+
130
+ for (let i = 0; i < steps.length; i++) {
131
+ const step = steps[i]!;
132
+
133
+ // Write step-start (new Phase 2 node)
134
+ const stepStart = writeStepStart(uwf, {
135
+ role: step.role,
136
+ edgePrompt: step.edgePrompt,
137
+ stepIndex: i,
138
+ prev: prevStepStart,
139
+ start: startHash,
140
+ startedAtMs: 1000 + i * 1000,
141
+ cwd: "/tmp/work",
142
+ });
143
+ stepStarts.push(stepStart);
144
+
145
+ // Write turns for this step
146
+ const stepTurns: CasRef[] = [];
147
+ for (const content of step.turnContents) {
148
+ const turn = writeTurnNode(uwf, {
149
+ role: "assistant",
150
+ content,
151
+ prev: prevTurn,
152
+ owner: stepStart,
153
+ });
154
+ stepTurns.push(turn);
155
+ prevTurn = turn;
156
+ }
157
+ allTurns.push(stepTurns);
158
+
159
+ // Write StepNode if not in-flight (for backward compat with thread head)
160
+ if (step.inFlight) {
161
+ stepNodes.push(null);
162
+ inFlightStepStart = stepStart;
163
+ } else {
164
+ const detail = uwf.store.cas.put(detailSchemaHash, {
165
+ sessionId: `ses_${step.role}_${i}`,
166
+ duration: 5,
167
+ turnCount: step.turnContents.length,
168
+ }) as CasRef;
169
+ // Write a StepNode (legacy format for thread head)
170
+ const stepNode = uwf.store.cas.put(uwf.schemas.stepNode, {
171
+ start: startHash,
172
+ prev: prevStepNode,
173
+ role: step.role,
174
+ output: outputHash,
175
+ detail,
176
+ agent: "test-agent",
177
+ edgePrompt: step.edgePrompt,
178
+ startedAtMs: 1000 + i * 1000,
179
+ completedAtMs: 2000 + i * 1000,
180
+ cwd: "/tmp/work",
181
+ assembledPrompt: null,
182
+ usage: null,
183
+ previousAttempts: null,
184
+ }) as CasRef;
185
+ stepNodes.push(stepNode);
186
+ prevStepNode = stepNode;
187
+ }
188
+
189
+ prevStepStart = stepStart;
190
+ }
191
+
192
+ // Set thread head to latest StepNode or StartNode if no completes
193
+ const lastStepNode = stepNodes.filter((n) => n !== null).pop();
194
+ const threadHead = lastStepNode ?? startHash;
195
+
196
+ setThread(uwf.varStore, threadId, {
197
+ head: threadHead,
198
+ status: inFlightStepStart !== null ? "running" : "idle",
199
+ suspendedRole: null,
200
+ suspendMessage: null,
201
+ completedAt: null,
202
+ });
203
+
204
+ // Set active-step if in-flight
205
+ if (inFlightStepStart !== null) {
206
+ setActiveStep(uwf.store, threadId, inFlightStepStart);
207
+ }
208
+
209
+ // Set active-turn-head to the latest turn
210
+ if (prevTurn !== null) {
211
+ setActiveTurnHead(uwf.store, threadId, prevTurn);
212
+ }
213
+
214
+ return { startHash, stepStarts, stepNodes, turns: allTurns };
215
+ }
216
+
217
+ // ── spec: step-turns-panorama-owner-segmentation.md ─────────────────────────
218
+
219
+ describe("buildTurnsPanorama segments turns by step-start owner (#421)", () => {
220
+ test("3 completed steps: each group shows only turns with matching owner", async () => {
221
+ const uwf = await createUwfStore(tmpDir);
222
+ await seedPhase3Chain(uwf, THREAD_ID, [
223
+ { role: "planner", edgePrompt: "Plan the task", turnContents: ["t0", "t1"], inFlight: false },
224
+ { role: "developer", edgePrompt: "Implement", turnContents: ["t2", "t3"], inFlight: false },
225
+ { role: "reviewer", edgePrompt: "Review", turnContents: ["t4", "t5"], inFlight: false },
226
+ ]);
227
+
228
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
229
+
230
+ // All three step groups present in step-chain order
231
+ expect(out).toContain("## planner");
232
+ expect(out).toContain("## developer");
233
+ expect(out).toContain("## reviewer");
234
+ expect(out.indexOf("## planner")).toBeLessThan(out.indexOf("## developer"));
235
+ expect(out.indexOf("## developer")).toBeLessThan(out.indexOf("## reviewer"));
236
+
237
+ // Each group shows only its own turns (no cross-segment leakage)
238
+ const plannerSection = out.slice(out.indexOf("## planner"), out.indexOf("## developer"));
239
+ const developerSection = out.slice(out.indexOf("## developer"), out.indexOf("## reviewer"));
240
+ const reviewerSection = out.slice(out.indexOf("## reviewer"));
241
+
242
+ expect(plannerSection).toContain("t0");
243
+ expect(plannerSection).toContain("t1");
244
+ expect(plannerSection).not.toContain("t2");
245
+
246
+ expect(developerSection).toContain("t2");
247
+ expect(developerSection).toContain("t3");
248
+ expect(developerSection).not.toContain("t4");
249
+
250
+ expect(reviewerSection).toContain("t4");
251
+ expect(reviewerSection).toContain("t5");
252
+ expect(reviewerSection).not.toContain("t0");
253
+
254
+ // Each group is marked completed
255
+ expect(out).toMatch(/## planner.*✓/);
256
+ expect(out).toMatch(/## developer.*✓/);
257
+ expect(out).toMatch(/## reviewer.*✓/);
258
+ });
259
+
260
+ test("owner filtering via turnsOfStep returns correct subset", async () => {
261
+ const uwf = await createUwfStore(tmpDir);
262
+ const { stepStarts } = await seedPhase3Chain(uwf, THREAD_ID, [
263
+ { role: "planner", edgePrompt: "Plan", turnContents: ["p0", "p1"], inFlight: false },
264
+ { role: "developer", edgePrompt: "Dev", turnContents: ["d0", "d1"], inFlight: false },
265
+ { role: "reviewer", edgePrompt: "Rev", turnContents: ["r0", "r1"], inFlight: false },
266
+ ]);
267
+
268
+ const turnHead = getActiveTurnHead(uwf.store, THREAD_ID)!;
269
+
270
+ // Each step-start should own exactly its 2 turns
271
+ expect(turnsOfStep(uwf, turnHead, stepStarts[0]!)).toHaveLength(2);
272
+ expect(turnsOfStep(uwf, turnHead, stepStarts[1]!)).toHaveLength(2);
273
+ expect(turnsOfStep(uwf, turnHead, stepStarts[2]!)).toHaveLength(2);
274
+
275
+ // Full turn chain has 6 turns
276
+ expect(walkTurnChain(uwf, turnHead)).toHaveLength(6);
277
+ });
278
+ });
279
+
280
+ // ── spec: step-turns-panorama-412-recurring-role.md ─────────────────────────
281
+
282
+ describe("#412 recurring role with in-flight correctly handled (#421)", () => {
283
+ test("developer→reviewer→developer(in-flight): each segment correctly attributed", async () => {
284
+ const uwf = await createUwfStore(tmpDir);
285
+ await seedPhase3Chain(uwf, THREAD_ID, [
286
+ { role: "developer", edgePrompt: "Round 1", turnContents: ["t1", "t2"], inFlight: false },
287
+ { role: "reviewer", edgePrompt: "Review", turnContents: ["t3", "t4"], inFlight: false },
288
+ {
289
+ role: "developer",
290
+ edgePrompt: "Round 2",
291
+ turnContents: ["t5", "t6", "t7"],
292
+ inFlight: true,
293
+ },
294
+ ]);
295
+
296
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
297
+
298
+ // Three groups in chronological order
299
+ const groups = out.match(/## (developer|reviewer)/g);
300
+ expect(groups).toEqual(["## developer", "## reviewer", "## developer"]);
301
+
302
+ // Round 1 developer: completed, 2 turns
303
+ const firstDev = out.indexOf("## developer");
304
+ const reviewer = out.indexOf("## reviewer");
305
+ const secondDev = out.indexOf("## developer", reviewer + 1);
306
+
307
+ const r1Section = out.slice(firstDev, reviewer);
308
+ expect(r1Section).toContain("✓");
309
+ expect(r1Section).not.toContain("进行中");
310
+ expect(r1Section).toContain("t1");
311
+ expect(r1Section).toContain("t2");
312
+ expect(r1Section).not.toContain("t5");
313
+
314
+ // Reviewer: completed
315
+ const revSection = out.slice(reviewer, secondDev);
316
+ expect(revSection).toContain("✓");
317
+ expect(revSection).toContain("t3");
318
+ expect(revSection).toContain("t4");
319
+
320
+ // Round 2 developer: in-flight, 3 turns
321
+ const r2Section = out.slice(secondDev);
322
+ expect(r2Section).toContain("🔄 进行中");
323
+ expect(r2Section).not.toContain("✓");
324
+ expect(r2Section).toContain("t5");
325
+ expect(r2Section).toContain("t6");
326
+ expect(r2Section).toContain("t7");
327
+ expect(r2Section).not.toContain("t1");
328
+ });
329
+
330
+ test("same role multi-round: turns never mixed between segments", async () => {
331
+ const uwf = await createUwfStore(tmpDir);
332
+ const { stepStarts } = await seedPhase3Chain(uwf, THREAD_ID, [
333
+ {
334
+ role: "developer",
335
+ edgePrompt: "R1",
336
+ turnContents: ["dev_r1_t1", "dev_r1_t2"],
337
+ inFlight: false,
338
+ },
339
+ { role: "reviewer", edgePrompt: "Rev", turnContents: ["rev_t1"], inFlight: false },
340
+ {
341
+ role: "developer",
342
+ edgePrompt: "R2",
343
+ turnContents: ["dev_r2_t1", "dev_r2_t2", "dev_r2_t3"],
344
+ inFlight: false,
345
+ },
346
+ ]);
347
+
348
+ // Verify turn ownership in CAS
349
+ const turnHead = getActiveTurnHead(uwf.store, THREAD_ID)!;
350
+ const dev1Turns = turnsOfStep(uwf, turnHead, stepStarts[0]!);
351
+ const revTurns = turnsOfStep(uwf, turnHead, stepStarts[1]!);
352
+ const dev2Turns = turnsOfStep(uwf, turnHead, stepStarts[2]!);
353
+
354
+ expect(dev1Turns).toHaveLength(2);
355
+ expect(revTurns).toHaveLength(1);
356
+ expect(dev2Turns).toHaveLength(3);
357
+
358
+ // Verify content via panorama
359
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
360
+ expect(out.match(/dev_r1/g)).toHaveLength(2);
361
+ expect(out.match(/dev_r2/g)).toHaveLength(3);
362
+ expect(out.match(/rev_t/g)).toHaveLength(1);
363
+ });
364
+ });
365
+
366
+ // ── spec: step-turns-panorama-role-filter.md ─────────────────────────────────
367
+
368
+ describe("--role filters by step-start role, preserving all segments (#421)", () => {
369
+ test("--role developer shows all developer segments", async () => {
370
+ const uwf = await createUwfStore(tmpDir);
371
+ await seedPhase3Chain(uwf, THREAD_ID, [
372
+ { role: "developer", edgePrompt: "R1", turnContents: ["d1"], inFlight: false },
373
+ { role: "reviewer", edgePrompt: "Rev", turnContents: ["r1"], inFlight: false },
374
+ { role: "developer", edgePrompt: "R2", turnContents: ["d2"], inFlight: false },
375
+ { role: "reviewer", edgePrompt: "Rev2", turnContents: ["r2"], inFlight: false },
376
+ { role: "developer", edgePrompt: "R3", turnContents: ["d3"], inFlight: false },
377
+ ]);
378
+
379
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { role: "developer", live: false });
380
+
381
+ // 3 developer groups
382
+ expect((out.match(/## developer/g) ?? []).length).toBe(3);
383
+
384
+ // No reviewer groups
385
+ expect(out).not.toContain("## reviewer");
386
+
387
+ // All developer turns present
388
+ expect(out).toContain("d1");
389
+ expect(out).toContain("d2");
390
+ expect(out).toContain("d3");
391
+
392
+ // No reviewer turns
393
+ expect(out).not.toContain("r1");
394
+ expect(out).not.toContain("r2");
395
+ });
396
+
397
+ test("--role for non-existent role returns empty panorama", async () => {
398
+ const uwf = await createUwfStore(tmpDir);
399
+ await seedPhase3Chain(uwf, THREAD_ID, [
400
+ { role: "developer", edgePrompt: "Dev", turnContents: ["d1"], inFlight: false },
401
+ ]);
402
+
403
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { role: "tester", live: false });
404
+
405
+ expect(out).toContain(`# Thread ${THREAD_ID}`);
406
+ expect(out).not.toContain("## Turn");
407
+ expect(out).not.toContain("d1");
408
+ });
409
+ });
410
+
411
+ // ── spec: step-turns-panorama-pagination.md ──────────────────────────────────
412
+
413
+ describe("--limit/--offset paginates on flattened cross-step turn sequence (#421)", () => {
414
+ test("pagination crosses step boundaries correctly", async () => {
415
+ const uwf = await createUwfStore(tmpDir);
416
+ // 12 turns total: 4 per step
417
+ await seedPhase3Chain(uwf, THREAD_ID, [
418
+ { role: "step0", edgePrompt: "S0", turnContents: ["t0", "t1", "t2", "t3"], inFlight: false },
419
+ { role: "step1", edgePrompt: "S1", turnContents: ["t4", "t5", "t6", "t7"], inFlight: false },
420
+ {
421
+ role: "step2",
422
+ edgePrompt: "S2",
423
+ turnContents: ["t8", "t9", "t10", "t11"],
424
+ inFlight: false,
425
+ },
426
+ ]);
427
+
428
+ // --offset 5 --limit 4: indices 5,6,7,8 → t5,t6,t7,t8
429
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, offset: 5, limit: 4 });
430
+
431
+ // Should span step1 (t5,t6,t7) and step2 (t8)
432
+ expect(out).toContain("t5");
433
+ expect(out).toContain("t6");
434
+ expect(out).toContain("t7");
435
+ expect(out).toContain("t8");
436
+ expect(out).not.toContain("t4");
437
+ expect(out).not.toContain("t9");
438
+
439
+ // Global indices: Turn 6, Turn 7, Turn 8, Turn 9 (1-based)
440
+ expect(out).toContain("## Turn 6");
441
+ expect(out).toContain("## Turn 9");
442
+ expect(out).not.toContain("## Turn 5");
443
+ expect(out).not.toContain("## Turn 10");
444
+ });
445
+
446
+ test("groups with no surviving turns after pagination still show header", async () => {
447
+ const uwf = await createUwfStore(tmpDir);
448
+ await seedPhase3Chain(uwf, THREAD_ID, [
449
+ { role: "step0", edgePrompt: "S0", turnContents: ["t0", "t1"], inFlight: false },
450
+ { role: "step1", edgePrompt: "S1", turnContents: ["t2", "t3"], inFlight: false },
451
+ ]);
452
+
453
+ // --offset 0 --limit 2: only step0 turns survive
454
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false, offset: 0, limit: 2 });
455
+
456
+ // step1 header should still appear (empty)
457
+ expect(out).toContain("## step0");
458
+ expect(out).toContain("## step1");
459
+ expect(out).toContain("t0");
460
+ expect(out).toContain("t1");
461
+ expect(out).not.toContain("t2");
462
+ });
463
+ });
464
+
465
+ // ── spec: step-turns-panorama-in-flight-detection.md ─────────────────────────
466
+
467
+ describe("in-flight detection via active-step + missing step-complete (#421)", () => {
468
+ test("completed step (has step-complete) is marked ✓ even if active-step points elsewhere", async () => {
469
+ const uwf = await createUwfStore(tmpDir);
470
+ await seedPhase3Chain(uwf, THREAD_ID, [
471
+ { role: "step1", edgePrompt: "S1", turnContents: ["t1"], inFlight: false },
472
+ { role: "step2", edgePrompt: "S2", turnContents: ["t2"], inFlight: true },
473
+ ]);
474
+
475
+ const out = await cmdStepTurns(tmpDir, THREAD_ID, { live: false });
476
+
477
+ // step1 completed: ✓
478
+ expect(out).toMatch(/## step1.*✓/);
479
+
480
+ // step2 in-flight: 🔄 进行中
481
+ expect(out).toMatch(/## step2.*🔄 进行中/);
482
+ });
483
+
484
+ test("in-flight step is detected by active-step match AND no step-complete", async () => {
485
+ const uwf = await createUwfStore(tmpDir);
486
+ const { stepStarts } = await seedPhase3Chain(uwf, THREAD_ID, [
487
+ { role: "done", edgePrompt: "D", turnContents: ["d1"], inFlight: false },
488
+ { role: "wip", edgePrompt: "W", turnContents: ["w1"], inFlight: true },
489
+ ]);
490
+
491
+ // Verify active-step points to the in-flight step-start
492
+ const uwf2 = await createUwfStore(tmpDir);
493
+ const activeStep = uwf2.varStore.list({ exactName: `@uwf/active-step/${THREAD_ID}` });
494
+ expect(activeStep.length).toBe(1);
495
+ expect(activeStep[0]!.value).toBe(stepStarts[1]);
496
+ });
497
+ });
498
+
499
+ // ── spec: step-turns-panorama-live-mode.md ───────────────────────────────────
500
+
501
+ describe("--live follows active-turn-head growth (#421)", () => {
502
+ test("live mode polls active-turn-head and prints new turns", async () => {
503
+ const uwf = await createUwfStore(tmpDir);
504
+ const { stepStarts } = await seedPhase3Chain(uwf, THREAD_ID, [
505
+ { role: "coder", edgePrompt: "Code", turnContents: ["initial"], inFlight: true },
506
+ ]);
507
+
508
+ const printed: string[] = [];
509
+ let tick = 0;
510
+ const stepStart = stepStarts[0]!;
511
+ let prevTurn = getActiveTurnHead(uwf.store, THREAD_ID)!;
512
+
513
+ await cmdStepTurns(tmpDir, THREAD_ID, {
514
+ role: "coder",
515
+ live: true,
516
+ pollIntervalMs: 0,
517
+ onChunk: (chunk: string) => printed.push(chunk),
518
+ isRunning: async () => tick < 3,
519
+ sleep: async () => {
520
+ tick += 1;
521
+ if (tick === 1) {
522
+ const t = writeTurnNode(uwf, {
523
+ role: "assistant",
524
+ content: "live1",
525
+ prev: prevTurn,
526
+ owner: stepStart,
527
+ });
528
+ setActiveTurnHead(uwf.store, THREAD_ID, t);
529
+ prevTurn = t;
530
+ } else if (tick === 2) {
531
+ const t = writeTurnNode(uwf, {
532
+ role: "assistant",
533
+ content: "live2",
534
+ prev: prevTurn,
535
+ owner: stepStart,
536
+ });
537
+ setActiveTurnHead(uwf.store, THREAD_ID, t);
538
+ prevTurn = t;
539
+ }
540
+ },
541
+ });
542
+
543
+ const joined = printed.join("\n");
544
+ expect(joined).toContain("initial");
545
+ expect(joined).toContain("live1");
546
+ expect(joined).toContain("live2");
547
+ });
548
+ });
549
+
550
+ // ── spec: step-turns-panorama-edge-prompt.md ─────────────────────────────────
551
+
552
+ describe("edgePrompt readable from step-start (#421)", () => {
553
+ test("step-start stores edgePrompt correctly", async () => {
554
+ const uwf = await createUwfStore(tmpDir);
555
+ const { stepStarts } = await seedPhase3Chain(uwf, THREAD_ID, [
556
+ {
557
+ role: "planner",
558
+ edgePrompt: "Initial prompt from user",
559
+ turnContents: ["t1"],
560
+ inFlight: false,
561
+ },
562
+ {
563
+ role: "developer",
564
+ edgePrompt: "Implement the plan from planner",
565
+ turnContents: ["t2"],
566
+ inFlight: false,
567
+ },
568
+ ]);
569
+
570
+ // Read step-start nodes and verify edgePrompt
571
+ const ss1 = uwf.store.cas.get(stepStarts[0]!);
572
+ const ss2 = uwf.store.cas.get(stepStarts[1]!);
573
+
574
+ expect((ss1?.payload as { edgePrompt: string }).edgePrompt).toBe("Initial prompt from user");
575
+ expect((ss2?.payload as { edgePrompt: string }).edgePrompt).toBe(
576
+ "Implement the plan from planner",
577
+ );
578
+ });
579
+ });