@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,236 @@
1
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { bootstrap, type Hash, type JSONSchema, putSchema } from "@ocas/core";
5
+ import { openStore } from "@ocas/fs";
6
+ import type { CasRef, StepNodePayload } from "@united-workforce/protocol";
7
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
8
+ import { cmdStepShow } from "../commands/step.js";
9
+ import { writeEnvelope } from "../format.js";
10
+ import { toStepDetailPayload } from "../output-mappers.js";
11
+ import { registerUwfSchemas } from "../schemas.js";
12
+ import { createUwfStore } from "../store.js";
13
+
14
+ /**
15
+ * Issue #403 — regression guard for the `step show` **text** path.
16
+ *
17
+ * PR #394 added the `--- Content ---` turn block (plus `Usage` / `Turns`) to
18
+ * `STEP_DETAIL_TEMPLATE` and the `toStepDetailPayload` mapper flattens
19
+ * `detail.turns` into a top-level `turns` array — but no test asserted that the
20
+ * rendered text actually contains the turn bodies. A stale build (e.g. the
21
+ * published `protocol@0.4.0`) or an accidental retarget to `detail.turns` would
22
+ * go undetected.
23
+ *
24
+ * This exercises the full path
25
+ * cmdStepShow → toStepDetailPayload → writeEnvelope(text) → renderEnvelopeText
26
+ * and asserts the rendered text contains `--- Content ---`, each turn's content
27
+ * substring, and the `Turns N` line. The sibling JSON contract lives in
28
+ * `step-show-json.test.ts`; the protocol-level template invariant lives in
29
+ * `packages/protocol/src/__tests__/output-templates-step-detail.test.ts`.
30
+ */
31
+
32
+ const TURN_SCHEMA: JSONSchema = {
33
+ title: "test-turn",
34
+ type: "object",
35
+ required: ["index", "role", "content"],
36
+ properties: {
37
+ index: { type: "integer" },
38
+ role: { type: "string", enum: ["assistant", "tool"] },
39
+ content: { type: "string" },
40
+ toolCalls: {
41
+ anyOf: [
42
+ {
43
+ type: "array",
44
+ items: {
45
+ type: "object",
46
+ required: ["name", "args"],
47
+ properties: {
48
+ name: { type: "string" },
49
+ args: { type: "string" },
50
+ },
51
+ additionalProperties: false,
52
+ },
53
+ },
54
+ { type: "null" },
55
+ ],
56
+ },
57
+ },
58
+ additionalProperties: false,
59
+ };
60
+
61
+ const DETAIL_SCHEMA: JSONSchema = {
62
+ title: "test-detail",
63
+ type: "object",
64
+ required: ["turns"],
65
+ properties: {
66
+ turns: {
67
+ type: "array",
68
+ items: { type: "string", format: "ocas_ref" },
69
+ },
70
+ },
71
+ additionalProperties: false,
72
+ };
73
+
74
+ type TestSetup = {
75
+ store: Awaited<ReturnType<typeof openStore>>;
76
+ schemas: Awaited<ReturnType<typeof registerUwfSchemas>>;
77
+ turnType: Hash;
78
+ detailType: Hash;
79
+ };
80
+
81
+ async function setupTest(casDir: string): Promise<TestSetup> {
82
+ const store = await openStore(casDir);
83
+ await bootstrap(store);
84
+ const schemas = await registerUwfSchemas(store);
85
+ const [turnType, detailType] = await Promise.all([
86
+ putSchema(store, TURN_SCHEMA),
87
+ putSchema(store, DETAIL_SCHEMA),
88
+ ]);
89
+ return { store, schemas, turnType, detailType };
90
+ }
91
+
92
+ async function createTestStep(
93
+ setup: TestSetup,
94
+ turnPayloads: Array<{
95
+ index: number;
96
+ role: string;
97
+ content: string;
98
+ toolCalls: Array<{ name: string; args: string }> | null;
99
+ }>,
100
+ usage: StepNodePayload["usage"],
101
+ ): Promise<CasRef> {
102
+ const { store, schemas, turnType, detailType } = setup;
103
+
104
+ const turnHashes: CasRef[] = [];
105
+ for (const payload of turnPayloads) {
106
+ turnHashes.push(await store.cas.put(turnType, payload));
107
+ }
108
+
109
+ const detailHash = await store.cas.put(detailType, { turns: turnHashes });
110
+ const startHash = await store.cas.put(schemas.startNode, {
111
+ workflow: "0000000000000" as CasRef,
112
+ prompt: "test prompt",
113
+ cwd: "/tmp",
114
+ });
115
+ const outputHash = await store.cas.put(schemas.text, { $status: "reviewed" });
116
+
117
+ const stepPayload: StepNodePayload = {
118
+ prev: null,
119
+ start: startHash,
120
+ role: "reviewer",
121
+ agent: "claude-code",
122
+ output: outputHash,
123
+ detail: detailHash,
124
+ edgePrompt: "",
125
+ startedAtMs: 1_000_000,
126
+ completedAtMs: 1_137_400,
127
+ assembledPrompt: null,
128
+ cwd: "/tmp",
129
+ usage,
130
+ previousAttempts: null,
131
+ };
132
+ return store.cas.put(schemas.stepNode, stepPayload);
133
+ }
134
+
135
+ /** Capture everything written to `process.stdout` while `fn` runs. */
136
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
137
+ const buf: string[] = [];
138
+ const spy = vi.spyOn(process.stdout, "write").mockImplementation(((
139
+ chunk: string | Uint8Array,
140
+ ): boolean => {
141
+ buf.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
142
+ return true;
143
+ }) as typeof process.stdout.write);
144
+ try {
145
+ await fn();
146
+ } finally {
147
+ spy.mockRestore();
148
+ }
149
+ return buf.join("");
150
+ }
151
+
152
+ describe("cmdStepShow text rendering (issue #403)", () => {
153
+ let testDir: string;
154
+ let casDir: string;
155
+ let originalEnv: string | undefined;
156
+
157
+ beforeEach(async () => {
158
+ testDir = await mkdtemp(join(tmpdir(), "uwf-step-show-text-"));
159
+ casDir = join(testDir, "cas");
160
+ await mkdir(casDir, { recursive: true });
161
+ originalEnv = process.env.OCAS_HOME;
162
+ process.env.OCAS_HOME = casDir;
163
+ });
164
+
165
+ afterEach(async () => {
166
+ await rm(testDir, { recursive: true, force: true });
167
+ if (originalEnv === undefined) {
168
+ delete process.env.OCAS_HOME;
169
+ } else {
170
+ process.env.OCAS_HOME = originalEnv;
171
+ }
172
+ vi.restoreAllMocks();
173
+ });
174
+
175
+ test("renders the --- Content --- block with each turn's role and content", async () => {
176
+ const setup = await setupTest(casDir);
177
+ const stepHash = await createTestStep(
178
+ setup,
179
+ [
180
+ { index: 0, role: "assistant", content: "first turn body", toolCalls: null },
181
+ { index: 1, role: "assistant", content: "second turn body", toolCalls: null },
182
+ ],
183
+ { turns: 9, inputTokens: 38612, outputTokens: 10584, duration: 137400 },
184
+ );
185
+
186
+ const detail = await cmdStepShow(testDir, stepHash);
187
+ const uwf = await createUwfStore(testDir);
188
+ const out = await captureStdout(async () =>
189
+ writeEnvelope(toStepDetailPayload(stepHash, detail), "step-detail", {
190
+ format: "text",
191
+ store: uwf.store,
192
+ schemas: uwf.schemas,
193
+ }),
194
+ );
195
+
196
+ // Metadata header
197
+ expect(out).toContain(`Step ${stepHash}`);
198
+ expect(out).toContain("Role reviewer");
199
+ expect(out).toContain("Agent claude-code");
200
+ expect(out).toContain("Status reviewed");
201
+ expect(out).toContain("Duration 137.4s");
202
+ expect(out).toContain("Usage 38612 in / 10584 out / 9 turns");
203
+
204
+ // Turn-content block — the headline regression assertion
205
+ expect(out).toContain("Turns 2");
206
+ expect(out).toContain("--- Content ---");
207
+ expect(out).toContain("[assistant] first turn body");
208
+ expect(out).toContain("[assistant] second turn body");
209
+
210
+ // No JSON envelope leakage and a single trailing newline
211
+ expect(out).not.toContain('"type"');
212
+ expect(out).not.toContain("undefined");
213
+ expect(out.endsWith("\n")).toBe(true);
214
+ expect(out.endsWith("\n\n")).toBe(false);
215
+ });
216
+
217
+ test("omits the Content block for a step with zero turns, without throwing", async () => {
218
+ const setup = await setupTest(casDir);
219
+ const stepHash = await createTestStep(setup, [], null);
220
+
221
+ const detail = await cmdStepShow(testDir, stepHash);
222
+ const uwf = await createUwfStore(testDir);
223
+ const out = await captureStdout(async () =>
224
+ writeEnvelope(toStepDetailPayload(stepHash, detail), "step-detail", {
225
+ format: "text",
226
+ store: uwf.store,
227
+ schemas: uwf.schemas,
228
+ }),
229
+ );
230
+
231
+ expect(out).toContain("Role reviewer");
232
+ expect(out).not.toContain("--- Content ---");
233
+ expect(out).not.toMatch(/^Turns\s/m);
234
+ expect(out).not.toContain("undefined");
235
+ });
236
+ });
@@ -0,0 +1,411 @@
1
+ /**
2
+ * #423 — CLI subprocess integration test for `uwf step turns`.
3
+ *
4
+ * This test exercises the full CLI invocation path via subprocess (`execFileSync`),
5
+ * catching regressions that function-level tests cannot detect:
6
+ * - Argument parsing (yargs configuration)
7
+ * - Output formatting at the command boundary
8
+ * - Environment variable handling (UWF_HOME, OCAS_HOME)
9
+ * - Exit code behavior
10
+ *
11
+ * The test covers a RECURRING ROLE scenario (developer → reviewer → developer)
12
+ * which was the root cause of #412 and is the most likely to regress.
13
+ *
14
+ * Existing function-level tests (step-turns.test.ts, step-turns-panorama-phase3.test.ts)
15
+ * verify `cmdStepTurns` directly — this test does NOT duplicate those; it only
16
+ * adds the missing subprocess/CLI-level coverage.
17
+ *
18
+ * Follows the pattern established in thread-start-cwd-cli.test.ts.
19
+ */
20
+
21
+ import { execFileSync } from "node:child_process";
22
+ import { mkdir, rm } from "node:fs/promises";
23
+ import { tmpdir } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { putSchema } from "@ocas/core";
27
+ import type { CasRef, ThreadId } from "@united-workforce/protocol";
28
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
29
+ import {
30
+ createUwfStore,
31
+ setActiveTurnHead,
32
+ setThread,
33
+ type UwfStore,
34
+ writeStepStart,
35
+ writeTurnNode,
36
+ } from "../store.js";
37
+
38
+ // ── test setup ───────────────────────────────────────────────────────────────
39
+
40
+ const THREAD_ID = "06FD9WEG5BH7C8JPD04X4184E4" as ThreadId;
41
+
42
+ const DETAIL_SCHEMA = {
43
+ title: "broker-detail-cli-test",
44
+ type: "object" as const,
45
+ required: ["sessionId", "duration", "turnCount"],
46
+ properties: {
47
+ sessionId: { type: "string" as const },
48
+ duration: { type: "integer" as const },
49
+ turnCount: { type: "integer" as const },
50
+ },
51
+ additionalProperties: false,
52
+ };
53
+
54
+ let tmpDir: string;
55
+ let storageRoot: string;
56
+ let casDir: string;
57
+ let savedUwfHome: string | undefined;
58
+ let savedOcasHome: string | undefined;
59
+
60
+ beforeEach(async () => {
61
+ savedUwfHome = process.env.UWF_HOME;
62
+ savedOcasHome = process.env.OCAS_HOME;
63
+
64
+ tmpDir = join(tmpdir(), `uwf-step-turns-cli-${Date.now()}`);
65
+ storageRoot = join(tmpDir, "storage");
66
+ casDir = join(tmpDir, "cas");
67
+ await mkdir(storageRoot, { recursive: true });
68
+ await mkdir(casDir, { recursive: true });
69
+ });
70
+
71
+ afterEach(async () => {
72
+ if (savedUwfHome === undefined) delete process.env.UWF_HOME;
73
+ else process.env.UWF_HOME = savedUwfHome;
74
+ if (savedOcasHome === undefined) delete process.env.OCAS_HOME;
75
+ else process.env.OCAS_HOME = savedOcasHome;
76
+
77
+ if (tmpDir) {
78
+ await rm(tmpDir, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ /**
83
+ * Seed a thread with a recurring role pattern: developer → reviewer → developer.
84
+ * This scenario exercises the #412 fix (owner-based segmentation) and ensures
85
+ * the CLI correctly renders separate segments for the same role.
86
+ *
87
+ * Returns the thread ID for CLI invocation.
88
+ */
89
+ async function seedRecurringRoleThread(uwf: UwfStore): Promise<ThreadId> {
90
+ // Create workflow and start node
91
+ const workflowHash = uwf.store.cas.put(uwf.schemas.workflow, {
92
+ version: 1,
93
+ name: "recurring-role-test",
94
+ description: "test workflow for recurring role CLI test",
95
+ roles: {},
96
+ graph: {},
97
+ }) as CasRef;
98
+ const startHash = uwf.store.cas.put(uwf.schemas.startNode, {
99
+ workflow: workflowHash,
100
+ prompt: "implement feature X",
101
+ cwd: "/tmp/test",
102
+ }) as CasRef;
103
+
104
+ const detailSchemaHash = putSchema(uwf.store, DETAIL_SCHEMA);
105
+ const outputHash = uwf.store.cas.put(uwf.schemas.text, "output") as CasRef;
106
+
107
+ // Step 1: developer round 1 — 2 turns
108
+ const stepStart1 = writeStepStart(uwf, {
109
+ role: "developer",
110
+ edgePrompt: "Implement feature X",
111
+ stepIndex: 0,
112
+ prev: null,
113
+ start: startHash,
114
+ startedAtMs: 1000,
115
+ cwd: "/tmp/test",
116
+ });
117
+ const t1 = writeTurnNode(uwf, {
118
+ role: "assistant",
119
+ content: "DEV_R1_TURN_1: Reading requirements...",
120
+ prev: null,
121
+ owner: stepStart1,
122
+ });
123
+ const t2 = writeTurnNode(uwf, {
124
+ role: "assistant",
125
+ content: "DEV_R1_TURN_2: Implementation complete.",
126
+ prev: t1,
127
+ owner: stepStart1,
128
+ });
129
+ const detail1 = uwf.store.cas.put(detailSchemaHash, {
130
+ sessionId: "ses_dev_r1",
131
+ duration: 5,
132
+ turnCount: 2,
133
+ }) as CasRef;
134
+ const stepNode1 = uwf.store.cas.put(uwf.schemas.stepNode, {
135
+ start: startHash,
136
+ prev: null,
137
+ role: "developer",
138
+ output: outputHash,
139
+ detail: detail1,
140
+ agent: "test-agent",
141
+ edgePrompt: "Implement feature X",
142
+ startedAtMs: 1000,
143
+ completedAtMs: 2000,
144
+ cwd: "/tmp/test",
145
+ assembledPrompt: null,
146
+ usage: null,
147
+ previousAttempts: null,
148
+ }) as CasRef;
149
+
150
+ // Step 2: reviewer — 2 turns
151
+ const stepStart2 = writeStepStart(uwf, {
152
+ role: "reviewer",
153
+ edgePrompt: "Review the implementation",
154
+ stepIndex: 1,
155
+ prev: stepStart1,
156
+ start: startHash,
157
+ startedAtMs: 3000,
158
+ cwd: "/tmp/test",
159
+ });
160
+ const t3 = writeTurnNode(uwf, {
161
+ role: "assistant",
162
+ content: "REV_TURN_1: Reviewing code quality...",
163
+ prev: t2,
164
+ owner: stepStart2,
165
+ });
166
+ const t4 = writeTurnNode(uwf, {
167
+ role: "assistant",
168
+ content: "REV_TURN_2: Found issues, requesting changes.",
169
+ prev: t3,
170
+ owner: stepStart2,
171
+ });
172
+ const detail2 = uwf.store.cas.put(detailSchemaHash, {
173
+ sessionId: "ses_rev",
174
+ duration: 4,
175
+ turnCount: 2,
176
+ }) as CasRef;
177
+ const stepNode2 = uwf.store.cas.put(uwf.schemas.stepNode, {
178
+ start: startHash,
179
+ prev: stepNode1,
180
+ role: "reviewer",
181
+ output: outputHash,
182
+ detail: detail2,
183
+ agent: "test-agent",
184
+ edgePrompt: "Review the implementation",
185
+ startedAtMs: 3000,
186
+ completedAtMs: 4000,
187
+ cwd: "/tmp/test",
188
+ assembledPrompt: null,
189
+ usage: null,
190
+ previousAttempts: null,
191
+ }) as CasRef;
192
+
193
+ // Step 3: developer round 2 — 3 turns
194
+ const stepStart3 = writeStepStart(uwf, {
195
+ role: "developer",
196
+ edgePrompt: "Address reviewer feedback",
197
+ stepIndex: 2,
198
+ prev: stepStart2,
199
+ start: startHash,
200
+ startedAtMs: 5000,
201
+ cwd: "/tmp/test",
202
+ });
203
+ const t5 = writeTurnNode(uwf, {
204
+ role: "assistant",
205
+ content: "DEV_R2_TURN_1: Reading feedback...",
206
+ prev: t4,
207
+ owner: stepStart3,
208
+ });
209
+ const t6 = writeTurnNode(uwf, {
210
+ role: "assistant",
211
+ content: "DEV_R2_TURN_2: Making requested changes...",
212
+ prev: t5,
213
+ owner: stepStart3,
214
+ });
215
+ const t7 = writeTurnNode(uwf, {
216
+ role: "assistant",
217
+ content: "DEV_R2_TURN_3: Changes complete, ready for re-review.",
218
+ prev: t6,
219
+ owner: stepStart3,
220
+ });
221
+ const detail3 = uwf.store.cas.put(detailSchemaHash, {
222
+ sessionId: "ses_dev_r2",
223
+ duration: 6,
224
+ turnCount: 3,
225
+ }) as CasRef;
226
+ const stepNode3 = uwf.store.cas.put(uwf.schemas.stepNode, {
227
+ start: startHash,
228
+ prev: stepNode2,
229
+ role: "developer",
230
+ output: outputHash,
231
+ detail: detail3,
232
+ agent: "test-agent",
233
+ edgePrompt: "Address reviewer feedback",
234
+ startedAtMs: 5000,
235
+ completedAtMs: 6000,
236
+ cwd: "/tmp/test",
237
+ assembledPrompt: null,
238
+ usage: null,
239
+ previousAttempts: null,
240
+ }) as CasRef;
241
+
242
+ // Set thread state — all steps completed
243
+ setThread(uwf.varStore, THREAD_ID, {
244
+ head: stepNode3,
245
+ status: "idle",
246
+ suspendedRole: null,
247
+ suspendMessage: null,
248
+ completedAt: null,
249
+ });
250
+
251
+ // Set active-turn-head to the last turn
252
+ setActiveTurnHead(uwf.store, THREAD_ID, t7);
253
+
254
+ return THREAD_ID;
255
+ }
256
+
257
+ // ── spec: step-turns-cli-subprocess-recurring-role.md ────────────────────────
258
+
259
+ describe("uwf step turns CLI subprocess integration (#423)", () => {
260
+ test("recurring role scenario (developer→reviewer→developer) via subprocess", async () => {
261
+ // Setup: seed the store with recurring role thread
262
+ process.env.OCAS_HOME = casDir;
263
+ const uwf = await createUwfStore(storageRoot);
264
+ const threadId = await seedRecurringRoleThread(uwf);
265
+
266
+ // Build paths to CLI binary
267
+ const pkgRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
268
+ const uwfBin = join(pkgRoot, "dist", "cli.js");
269
+
270
+ // Invoke CLI via subprocess
271
+ const output = execFileSync(process.execPath, [uwfBin, "step", "turns", threadId], {
272
+ env: { ...process.env, UWF_HOME: storageRoot, OCAS_HOME: casDir },
273
+ encoding: "utf8",
274
+ });
275
+
276
+ // Exit code 0 is implicit — execFileSync throws on non-zero exit
277
+
278
+ // Verify: 3 step groups in chronological order
279
+ const groups = output.match(/## (developer|reviewer)/g);
280
+ expect(groups).toEqual(["## developer", "## reviewer", "## developer"]);
281
+
282
+ // Verify: Both developer segments present (NOT collapsed)
283
+ const firstDevIdx = output.indexOf("## developer");
284
+ const reviewerIdx = output.indexOf("## reviewer");
285
+ const secondDevIdx = output.indexOf("## developer", reviewerIdx + 1);
286
+ expect(firstDevIdx).toBeLessThan(reviewerIdx);
287
+ expect(reviewerIdx).toBeLessThan(secondDevIdx);
288
+
289
+ // Verify: Each segment's turns are correctly attributed (no cross-segment leakage)
290
+ const devR1Section = output.slice(firstDevIdx, reviewerIdx);
291
+ const revSection = output.slice(reviewerIdx, secondDevIdx);
292
+ const devR2Section = output.slice(secondDevIdx);
293
+
294
+ // Developer round 1: 2 turns
295
+ expect(devR1Section).toContain("DEV_R1_TURN_1");
296
+ expect(devR1Section).toContain("DEV_R1_TURN_2");
297
+ expect(devR1Section).not.toContain("REV_TURN");
298
+ expect(devR1Section).not.toContain("DEV_R2_TURN");
299
+
300
+ // Reviewer: 2 turns
301
+ expect(revSection).toContain("REV_TURN_1");
302
+ expect(revSection).toContain("REV_TURN_2");
303
+ expect(revSection).not.toContain("DEV_R1_TURN");
304
+ expect(revSection).not.toContain("DEV_R2_TURN");
305
+
306
+ // Developer round 2: 3 turns
307
+ expect(devR2Section).toContain("DEV_R2_TURN_1");
308
+ expect(devR2Section).toContain("DEV_R2_TURN_2");
309
+ expect(devR2Section).toContain("DEV_R2_TURN_3");
310
+ expect(devR2Section).not.toContain("DEV_R1_TURN");
311
+ expect(devR2Section).not.toContain("REV_TURN");
312
+
313
+ // Verify: All 7 turns rendered with global numbering (Turn 1 through Turn 7)
314
+ expect(output).toContain("## Turn 1");
315
+ expect(output).toContain("## Turn 2");
316
+ expect(output).toContain("## Turn 3");
317
+ expect(output).toContain("## Turn 4");
318
+ expect(output).toContain("## Turn 5");
319
+ expect(output).toContain("## Turn 6");
320
+ expect(output).toContain("## Turn 7");
321
+
322
+ // Verify: All steps marked as completed (✓)
323
+ const checkmarks = output.match(/✓/g);
324
+ expect(checkmarks).toHaveLength(3);
325
+ });
326
+
327
+ test("CLI accepts --role filter via subprocess", async () => {
328
+ // Setup
329
+ process.env.OCAS_HOME = casDir;
330
+ const uwf = await createUwfStore(storageRoot);
331
+ const threadId = await seedRecurringRoleThread(uwf);
332
+
333
+ const pkgRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
334
+ const uwfBin = join(pkgRoot, "dist", "cli.js");
335
+
336
+ // Invoke CLI with --role developer
337
+ const output = execFileSync(
338
+ process.execPath,
339
+ [uwfBin, "step", "turns", threadId, "--role", "developer"],
340
+ {
341
+ env: { ...process.env, UWF_HOME: storageRoot, OCAS_HOME: casDir },
342
+ encoding: "utf8",
343
+ },
344
+ );
345
+
346
+ // Verify: Only developer groups present
347
+ const groups = output.match(/## (developer|reviewer)/g);
348
+ expect(groups).toEqual(["## developer", "## developer"]);
349
+
350
+ // Verify: No reviewer turns
351
+ expect(output).not.toContain("REV_TURN");
352
+
353
+ // Verify: Both developer segment turns present
354
+ expect(output).toContain("DEV_R1_TURN");
355
+ expect(output).toContain("DEV_R2_TURN");
356
+ });
357
+
358
+ test("CLI accepts --limit and --offset pagination via subprocess", async () => {
359
+ // Setup
360
+ process.env.OCAS_HOME = casDir;
361
+ const uwf = await createUwfStore(storageRoot);
362
+ const threadId = await seedRecurringRoleThread(uwf);
363
+
364
+ const pkgRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
365
+ const uwfBin = join(pkgRoot, "dist", "cli.js");
366
+
367
+ // Invoke CLI with --offset 2 --limit 3 (turns 3, 4, 5)
368
+ const output = execFileSync(
369
+ process.execPath,
370
+ [uwfBin, "step", "turns", threadId, "--offset", "2", "--limit", "3"],
371
+ {
372
+ env: { ...process.env, UWF_HOME: storageRoot, OCAS_HOME: casDir },
373
+ encoding: "utf8",
374
+ },
375
+ );
376
+
377
+ // Verify: Global indices Turn 3, Turn 4, Turn 5 (1-based display)
378
+ expect(output).toContain("## Turn 3");
379
+ expect(output).toContain("## Turn 4");
380
+ expect(output).toContain("## Turn 5");
381
+
382
+ // Verify: Turns 1, 2 are skipped
383
+ expect(output).not.toContain("## Turn 1");
384
+ expect(output).not.toContain("## Turn 2");
385
+
386
+ // Verify: Turns 6, 7 are beyond the limit
387
+ expect(output).not.toContain("## Turn 6");
388
+ expect(output).not.toContain("## Turn 7");
389
+ });
390
+
391
+ test("CLI exits with error for invalid thread ID", async () => {
392
+ const pkgRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
393
+ const uwfBin = join(pkgRoot, "dist", "cli.js");
394
+
395
+ // Invoke CLI with a non-existent thread ID
396
+ const invalidThreadId = "06FDINVALIDTHREADID000000";
397
+
398
+ try {
399
+ execFileSync(process.execPath, [uwfBin, "step", "turns", invalidThreadId], {
400
+ env: { ...process.env, UWF_HOME: storageRoot, OCAS_HOME: casDir },
401
+ encoding: "utf8",
402
+ });
403
+ // If we get here, the command didn't fail as expected
404
+ expect.fail("Expected CLI to exit with non-zero code for invalid thread ID");
405
+ } catch (err) {
406
+ // execFileSync throws on non-zero exit — this is expected
407
+ const error = err as { status: number; stderr?: Buffer };
408
+ expect(error.status).not.toBe(0);
409
+ }
410
+ });
411
+ });