@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,351 @@
1
+ /**
2
+ * Spec 3 (issue #435, Phase 2) — `executeBrokerStep` routes a broker
3
+ * `kind:"suspended"` SendResult through the existing `$SUSPEND` exit.
4
+ *
5
+ * Stubs `globalThis.fetch` so the Sumeru `sendMessage` SSE stream ends in a
6
+ * `suspend` terminal event (send timeout) rather than `done`. Verifies:
7
+ * 1. `executeBrokerStep` takes the suspended branch (NOT the frontmatter
8
+ * retry / error path) and returns `isError === false` with
9
+ * `frontmatter.$status === "$SUSPEND"`.
10
+ * 2. The persisted StepNode's output node validates as a suspend output
11
+ * (`$status: "$SUSPEND"`, non-empty `reason` carrying the timeout +
12
+ * nativeId), so thread status resolves to `suspended`.
13
+ * 3. `nativeId` / `elapsedMs` are recorded on the detail node for diagnostics.
14
+ * 4. The completed path is unchanged (regression): a `done` stream still
15
+ * extracts frontmatter and reports usage.
16
+ */
17
+
18
+ import { mkdtemp, rm } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { putSchema } from "@ocas/core";
22
+ import type {
23
+ CasRef,
24
+ StepNodePayload,
25
+ ThreadId,
26
+ WorkflowConfig,
27
+ WorkflowPayload,
28
+ } from "@united-workforce/protocol";
29
+ import { SUSPEND_STATUS } from "@united-workforce/protocol";
30
+ import { createProcessLogger } from "@united-workforce/util";
31
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
32
+ import { executeBrokerStep, openBrokerSessionStore } from "../commands/broker-step.js";
33
+ import { createUwfStore, type UwfStore } from "../store.js";
34
+
35
+ type FetchCall = { url: string; method: string; body: string };
36
+
37
+ function sseFrame(id: number, event: string, data: unknown): string {
38
+ return `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
39
+ }
40
+
41
+ function buildSseResponse(frames: string[]): Response {
42
+ const encoder = new TextEncoder();
43
+ const stream = new ReadableStream<Uint8Array>({
44
+ start(controller) {
45
+ for (const frame of frames) controller.enqueue(encoder.encode(frame));
46
+ controller.close();
47
+ },
48
+ });
49
+ return new Response(stream, {
50
+ status: 200,
51
+ headers: { "Content-Type": "text/event-stream; charset=utf-8" },
52
+ });
53
+ }
54
+
55
+ function buildJsonResponse(status: number, body: unknown): Response {
56
+ return new Response(JSON.stringify(body), {
57
+ status,
58
+ headers: { "Content-Type": "application/json" },
59
+ });
60
+ }
61
+
62
+ const PLANNER_OUTPUT_SCHEMA = {
63
+ title: "planner-output",
64
+ type: "object" as const,
65
+ required: ["$status", "plan"],
66
+ properties: {
67
+ $status: { type: "string" as const, enum: ["done", "failed"] },
68
+ plan: { type: "string" as const },
69
+ },
70
+ additionalProperties: false,
71
+ };
72
+
73
+ const PLANNER_RAW_OUTPUT = `---
74
+ $status: done
75
+ plan: ship it
76
+ ---
77
+ the plan body`;
78
+
79
+ const HOST = "http://127.0.0.1:7900";
80
+ const GATEWAY = "planner-gw";
81
+ const ALIAS = "planner-agent";
82
+ const SESSION_ID = "ses_suspend_e2e";
83
+ const THREAD_ID = "06FCBROKERSUSPENDSTEP0001" as ThreadId;
84
+ const ROLE = "planner";
85
+ const NATIVE_ID = "ses_native_abc";
86
+ const ELAPSED_MS = 1800000;
87
+
88
+ function buildConfig(): WorkflowConfig {
89
+ return {
90
+ agents: { [ALIAS]: { host: HOST, gateway: GATEWAY } },
91
+ defaultAgent: ALIAS,
92
+ agentOverrides: null,
93
+ };
94
+ }
95
+
96
+ async function buildWorkflow(uwf: UwfStore): Promise<{
97
+ workflow: WorkflowPayload;
98
+ startHash: CasRef;
99
+ }> {
100
+ const frontmatterHash = (await putSchema(uwf.store, PLANNER_OUTPUT_SCHEMA)) as CasRef;
101
+ const workflow: WorkflowPayload = {
102
+ version: 1,
103
+ name: "broker-suspend-e2e",
104
+ description: "broker step suspend end-to-end",
105
+ roles: {
106
+ planner: {
107
+ description: "plans things",
108
+ goal: "produce a plan",
109
+ capabilities: [],
110
+ procedure: "think hard",
111
+ output: "frontmatter+body",
112
+ frontmatter: frontmatterHash,
113
+ },
114
+ },
115
+ graph: {
116
+ planner: {
117
+ done: { role: "$END", prompt: "", location: null },
118
+ },
119
+ },
120
+ };
121
+ const startHash = (await uwf.store.cas.put(uwf.schemas.startNode, {
122
+ workflow: await uwf.store.cas.put(uwf.schemas.workflow, workflow),
123
+ prompt: "p",
124
+ cwd: "/tmp/work",
125
+ })) as CasRef;
126
+ return { workflow, startHash };
127
+ }
128
+
129
+ function suspendStream(): Response {
130
+ return buildSseResponse([
131
+ sseFrame(1, "turn", {
132
+ type: "@sumeru/turn",
133
+ value: { index: 0, role: "user", content: "edge prompt", timestamp: "", toolCalls: null },
134
+ }),
135
+ sseFrame(2, "turn", {
136
+ type: "@sumeru/turn",
137
+ value: { index: 1, role: "assistant", content: "draft1", timestamp: "", toolCalls: null },
138
+ }),
139
+ sseFrame(3, "turn", {
140
+ type: "@sumeru/turn",
141
+ value: { index: 2, role: "assistant", content: "draft2", timestamp: "", toolCalls: null },
142
+ }),
143
+ sseFrame(4, "suspend", {
144
+ type: "@sumeru/suspend",
145
+ value: { reason: "timeout", nativeId: NATIVE_ID, elapsedMs: ELAPSED_MS },
146
+ }),
147
+ ]);
148
+ }
149
+
150
+ function completedStream(): Response {
151
+ return buildSseResponse([
152
+ sseFrame(1, "turn", {
153
+ type: "@sumeru/turn",
154
+ value: {
155
+ index: 1,
156
+ role: "assistant",
157
+ content: PLANNER_RAW_OUTPUT,
158
+ timestamp: "",
159
+ toolCalls: null,
160
+ },
161
+ }),
162
+ sseFrame(2, "done", {
163
+ type: "@sumeru/summary",
164
+ value: { turnCount: 2, tokens: { in: 9, out: 4 }, durationMs: 42 },
165
+ }),
166
+ ]);
167
+ }
168
+
169
+ function resolveFetchUrl(input: string | URL | Request): string {
170
+ if (typeof input === "string") return input;
171
+ if (input instanceof URL) return input.href;
172
+ return input.url;
173
+ }
174
+
175
+ function makePlog(tmpDir: string) {
176
+ return createProcessLogger({
177
+ storageRoot: tmpDir,
178
+ context: { thread: THREAD_ID, workflow: "broker-suspend-e2e" },
179
+ });
180
+ }
181
+
182
+ describe("executeBrokerStep — suspended SendResult → thread suspended (issue #435)", () => {
183
+ let tmpDir: string;
184
+ let savedOcasHome: string | undefined;
185
+ let calls: FetchCall[];
186
+ let messageResponse: () => Response;
187
+
188
+ beforeEach(async () => {
189
+ savedOcasHome = process.env.OCAS_HOME;
190
+ tmpDir = await mkdtemp(join(tmpdir(), "broker-suspend-e2e-"));
191
+ process.env.OCAS_HOME = join(tmpDir, "cas");
192
+ calls = [];
193
+ messageResponse = suspendStream;
194
+ vi.stubGlobal(
195
+ "fetch",
196
+ async (input: string | URL | Request, init: RequestInit | undefined): Promise<Response> => {
197
+ const url = resolveFetchUrl(input);
198
+ const method = init?.method ?? "GET";
199
+ const body = typeof init?.body === "string" ? init.body : "";
200
+ calls.push({ url, method, body });
201
+ if (url.endsWith(`/gateways/${GATEWAY}/sessions`)) {
202
+ return buildJsonResponse(201, {
203
+ type: "@sumeru/session",
204
+ value: { id: SESSION_ID, gateway: GATEWAY },
205
+ });
206
+ }
207
+ if (url.endsWith(`/sessions/${SESSION_ID}/messages`)) {
208
+ return messageResponse();
209
+ }
210
+ return buildJsonResponse(500, { error: "unexpected url", url });
211
+ },
212
+ );
213
+ });
214
+
215
+ afterEach(async () => {
216
+ vi.unstubAllGlobals();
217
+ if (savedOcasHome === undefined) delete process.env.OCAS_HOME;
218
+ else process.env.OCAS_HOME = savedOcasHome;
219
+ await rm(tmpDir, { recursive: true, force: true });
220
+ });
221
+
222
+ test("a suspend stream yields a $SUSPEND step (isError=false), not an error step", async () => {
223
+ const uwf = await createUwfStore(tmpDir);
224
+ const { workflow, startHash } = await buildWorkflow(uwf);
225
+
226
+ const result = await executeBrokerStep({
227
+ storageRoot: tmpDir,
228
+ uwf,
229
+ config: buildConfig(),
230
+ workflow,
231
+ threadId: THREAD_ID,
232
+ role: ROLE,
233
+ edgePrompt: "make a plan",
234
+ effectiveCwd: "/tmp/work",
235
+ startHash,
236
+ prevHash: null,
237
+ agentOverride: null,
238
+ previousAttempts: null,
239
+ plog: makePlog(tmpDir),
240
+ });
241
+
242
+ expect(result.isError).toBe(false);
243
+ expect(result.errorMessage).toBeNull();
244
+ expect(result.frontmatter.$status).toBe(SUSPEND_STATUS);
245
+
246
+ // Only TWO HTTP calls — createSession + ONE sendMessage. No frontmatter
247
+ // retry send happened (suspend is a human gate, not a frontmatter failure).
248
+ const messageCalls = calls.filter((c) => c.url.endsWith("/messages"));
249
+ expect(messageCalls).toHaveLength(1);
250
+
251
+ // The persisted StepNode's output node validates as a suspend output:
252
+ // `$status: "$SUSPEND"` with a non-empty reason carrying the timeout info.
253
+ const stepNode = uwf.store.cas.get(result.stepHash);
254
+ expect(stepNode).not.toBeNull();
255
+ const payload = stepNode?.payload as StepNodePayload;
256
+ const outputNode = uwf.store.cas.get(payload.output);
257
+ expect(outputNode).not.toBeNull();
258
+ const output = outputNode?.payload as Record<string, unknown>;
259
+ expect(output.$status).toBe(SUSPEND_STATUS);
260
+ expect(typeof output.reason).toBe("string");
261
+ expect(output.reason as string).toContain(String(ELAPSED_MS));
262
+ expect(output.reason as string).toContain(NATIVE_ID);
263
+ });
264
+
265
+ test("detail node records nativeId and elapsedMs for diagnostics", async () => {
266
+ const uwf = await createUwfStore(tmpDir);
267
+ const { workflow, startHash } = await buildWorkflow(uwf);
268
+
269
+ const result = await executeBrokerStep({
270
+ storageRoot: tmpDir,
271
+ uwf,
272
+ config: buildConfig(),
273
+ workflow,
274
+ threadId: THREAD_ID,
275
+ role: ROLE,
276
+ edgePrompt: "make a plan",
277
+ effectiveCwd: "/tmp/work",
278
+ startHash,
279
+ prevHash: null,
280
+ agentOverride: null,
281
+ previousAttempts: null,
282
+ plog: makePlog(tmpDir),
283
+ });
284
+
285
+ const detailNode = uwf.store.cas.get(result.detailHash);
286
+ expect(detailNode).not.toBeNull();
287
+ const detail = detailNode?.payload as Record<string, unknown>;
288
+ expect(detail.nativeId).toBe(NATIVE_ID);
289
+ expect(detail.elapsedMs).toBe(ELAPSED_MS);
290
+ expect(detail.sessionId).toBe(SESSION_ID);
291
+ });
292
+
293
+ test("the (threadId, role) session mapping is upserted for the future resume", async () => {
294
+ const uwf = await createUwfStore(tmpDir);
295
+ const { workflow, startHash } = await buildWorkflow(uwf);
296
+
297
+ await executeBrokerStep({
298
+ storageRoot: tmpDir,
299
+ uwf,
300
+ config: buildConfig(),
301
+ workflow,
302
+ threadId: THREAD_ID,
303
+ role: ROLE,
304
+ edgePrompt: "make a plan",
305
+ effectiveCwd: "/tmp/work",
306
+ startHash,
307
+ prevHash: null,
308
+ agentOverride: null,
309
+ previousAttempts: null,
310
+ plog: makePlog(tmpDir),
311
+ });
312
+
313
+ const sessionStore = openBrokerSessionStore(tmpDir);
314
+ try {
315
+ const row = sessionStore.getSession(THREAD_ID, ROLE);
316
+ expect(row?.sessionId).toBe(SESSION_ID);
317
+ expect(row?.host).toBe(HOST);
318
+ expect(row?.gateway).toBe(GATEWAY);
319
+ } finally {
320
+ sessionStore.close();
321
+ }
322
+ });
323
+
324
+ test("regression: a completed (done) stream still extracts frontmatter + usage", async () => {
325
+ messageResponse = completedStream;
326
+ const uwf = await createUwfStore(tmpDir);
327
+ const { workflow, startHash } = await buildWorkflow(uwf);
328
+
329
+ const result = await executeBrokerStep({
330
+ storageRoot: tmpDir,
331
+ uwf,
332
+ config: buildConfig(),
333
+ workflow,
334
+ threadId: THREAD_ID,
335
+ role: ROLE,
336
+ edgePrompt: "make a plan",
337
+ effectiveCwd: "/tmp/work",
338
+ startHash,
339
+ prevHash: null,
340
+ agentOverride: null,
341
+ previousAttempts: null,
342
+ plog: makePlog(tmpDir),
343
+ });
344
+
345
+ expect(result.isError).toBe(false);
346
+ expect(result.frontmatter).toEqual({ $status: "done", plan: "ship it" });
347
+ expect(result.usage?.inputTokens).toBe(9);
348
+ expect(result.usage?.outputTokens).toBe(4);
349
+ expect(result.usage?.turns).toBe(2);
350
+ });
351
+ });