@davidorex/pi-workflows 0.1.0

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 (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +169 -0
  3. package/agents/audit-fixer.agent.yaml +18 -0
  4. package/agents/code-explorer.agent.yaml +11 -0
  5. package/agents/decomposer.agent.yaml +51 -0
  6. package/agents/investigator.agent.yaml +49 -0
  7. package/agents/pattern-analyzer.agent.yaml +21 -0
  8. package/agents/phase-author.agent.yaml +16 -0
  9. package/agents/plan-decomposer.agent.yaml +12 -0
  10. package/agents/quality-analyzer.agent.yaml +21 -0
  11. package/agents/researcher.agent.yaml +38 -0
  12. package/agents/spec-implementer.agent.yaml +12 -0
  13. package/agents/structure-analyzer.agent.yaml +21 -0
  14. package/agents/synthesizer.agent.yaml +23 -0
  15. package/agents/verifier.agent.yaml +12 -0
  16. package/package.json +40 -0
  17. package/schemas/decomposition-specs.schema.json +50 -0
  18. package/schemas/execution-results.schema.json +50 -0
  19. package/schemas/investigation-findings.schema.json +45 -0
  20. package/schemas/pattern-analysis.schema.json +43 -0
  21. package/schemas/plan-breakdown.schema.json +22 -0
  22. package/schemas/quality-analysis.schema.json +35 -0
  23. package/schemas/research-findings.schema.json +44 -0
  24. package/schemas/structure-analysis.schema.json +42 -0
  25. package/schemas/synthesis.schema.json +33 -0
  26. package/schemas/verifier-output.schema.json +96 -0
  27. package/src/agent-spec.test.ts +289 -0
  28. package/src/agent-spec.ts +122 -0
  29. package/src/block-validation.test.ts +350 -0
  30. package/src/checkpoint.test.ts +237 -0
  31. package/src/checkpoint.ts +139 -0
  32. package/src/completion.test.ts +143 -0
  33. package/src/completion.ts +68 -0
  34. package/src/dag.test.ts +350 -0
  35. package/src/dag.ts +219 -0
  36. package/src/dispatch.test.ts +473 -0
  37. package/src/dispatch.ts +353 -0
  38. package/src/expression.test.ts +353 -0
  39. package/src/expression.ts +332 -0
  40. package/src/format.test.ts +80 -0
  41. package/src/format.ts +41 -0
  42. package/src/graduated-failure.test.ts +556 -0
  43. package/src/index.test.ts +114 -0
  44. package/src/index.ts +488 -0
  45. package/src/loop.test.ts +549 -0
  46. package/src/output.test.ts +213 -0
  47. package/src/output.ts +70 -0
  48. package/src/parallel-integration.test.ts +175 -0
  49. package/src/resume.test.ts +192 -0
  50. package/src/state.test.ts +192 -0
  51. package/src/state.ts +253 -0
  52. package/src/step-agent.test.ts +327 -0
  53. package/src/step-agent.ts +178 -0
  54. package/src/step-command.test.ts +330 -0
  55. package/src/step-command.ts +132 -0
  56. package/src/step-foreach.test.ts +647 -0
  57. package/src/step-foreach.ts +148 -0
  58. package/src/step-gate.test.ts +185 -0
  59. package/src/step-gate.ts +116 -0
  60. package/src/step-loop.test.ts +626 -0
  61. package/src/step-loop.ts +323 -0
  62. package/src/step-parallel.test.ts +475 -0
  63. package/src/step-parallel.ts +168 -0
  64. package/src/step-pause.test.ts +30 -0
  65. package/src/step-pause.ts +27 -0
  66. package/src/step-shared.test.ts +355 -0
  67. package/src/step-shared.ts +157 -0
  68. package/src/step-transform.test.ts +155 -0
  69. package/src/step-transform.ts +47 -0
  70. package/src/template-integration.test.ts +72 -0
  71. package/src/template.test.ts +162 -0
  72. package/src/template.ts +92 -0
  73. package/src/test-helpers.ts +51 -0
  74. package/src/tui.test.ts +355 -0
  75. package/src/tui.ts +182 -0
  76. package/src/types.ts +195 -0
  77. package/src/verifier-schema.test.ts +226 -0
  78. package/src/workflow-discovery.test.ts +105 -0
  79. package/src/workflow-discovery.ts +113 -0
  80. package/src/workflow-executor.test.ts +1530 -0
  81. package/src/workflow-executor.ts +810 -0
  82. package/src/workflow-sdk.test.ts +238 -0
  83. package/src/workflow-sdk.ts +262 -0
  84. package/src/workflow-spec.test.ts +576 -0
  85. package/src/workflow-spec.ts +471 -0
  86. package/templates/analyzers/base-analyzer.md +9 -0
  87. package/templates/analyzers/macros.md +1 -0
  88. package/templates/analyzers/patterns-task.md +11 -0
  89. package/templates/analyzers/patterns.md +15 -0
  90. package/templates/analyzers/quality-task.md +11 -0
  91. package/templates/analyzers/quality.md +15 -0
  92. package/templates/analyzers/structure-task.md +17 -0
  93. package/templates/analyzers/structure.md +15 -0
  94. package/templates/audit-fixer/task.md +67 -0
  95. package/templates/decomposer/task.md +56 -0
  96. package/templates/explorer/system.md +3 -0
  97. package/templates/explorer/task.md +9 -0
  98. package/templates/investigator/task.md +30 -0
  99. package/templates/phase-author/task.md +74 -0
  100. package/templates/plan-decomposer/task.md +46 -0
  101. package/templates/researcher/task.md +26 -0
  102. package/templates/spec-implementer/task.md +53 -0
  103. package/templates/synthesizer/system.md +3 -0
  104. package/templates/synthesizer/task.md +38 -0
  105. package/templates/verifier/task.md +57 -0
  106. package/workflows/create-phase.workflow.yaml +67 -0
  107. package/workflows/do-gap.workflow.yaml +153 -0
  108. package/workflows/fix-audit.workflow.yaml +217 -0
  109. package/workflows/gap-to-phase.workflow.yaml +98 -0
  110. package/workflows/parallel-analysis.workflow.yaml +62 -0
  111. package/workflows/parallel-explicit.workflow.yaml +42 -0
  112. package/workflows/pausable-analysis.workflow.yaml +44 -0
  113. package/workflows/resumable-analysis.workflow.yaml +42 -0
  114. package/workflows/self-implement.workflow.yaml +63 -0
  115. package/workflows/typed-analysis.workflow.yaml +63 -0
@@ -0,0 +1,473 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { dispatch, buildArgs, extractText, extractToolArgsPreview } from "./dispatch.ts";
4
+ import type { StepSpec, AgentSpec, StepResult } from "./types.ts";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+
9
+ // Skip integration tests if pi is not available
10
+ let hasPi = false;
11
+ try {
12
+ const { execSync } = await import("node:child_process");
13
+ execSync("pi --version", { stdio: "ignore" });
14
+ hasPi = true;
15
+ } catch {}
16
+
17
+ // ── Unit tests: extractText ──
18
+
19
+ describe("extractText", () => {
20
+ it("extracts text from a content array with a text block", () => {
21
+ const content = [{ type: "text", text: "Hello world" }];
22
+ assert.strictEqual(extractText(content), "Hello world");
23
+ });
24
+
25
+ it("returns empty string for empty array", () => {
26
+ assert.strictEqual(extractText([]), "");
27
+ });
28
+
29
+ it("returns empty string for null/undefined", () => {
30
+ assert.strictEqual(extractText(null), "");
31
+ assert.strictEqual(extractText(undefined), "");
32
+ });
33
+
34
+ it("returns empty string for non-array", () => {
35
+ assert.strictEqual(extractText("just a string"), "");
36
+ assert.strictEqual(extractText(42), "");
37
+ });
38
+
39
+ it("returns first text block when multiple exist", () => {
40
+ const content = [
41
+ { type: "text", text: "First" },
42
+ { type: "text", text: "Second" },
43
+ ];
44
+ assert.strictEqual(extractText(content), "First");
45
+ });
46
+
47
+ it("skips non-text blocks", () => {
48
+ const content = [
49
+ { type: "tool_use", name: "bash" },
50
+ { type: "text", text: "After tool" },
51
+ ];
52
+ assert.strictEqual(extractText(content), "After tool");
53
+ });
54
+
55
+ it("returns empty string for array with no text blocks", () => {
56
+ const content = [
57
+ { type: "tool_use", name: "bash" },
58
+ { type: "image", source: {} },
59
+ ];
60
+ assert.strictEqual(extractText(content), "");
61
+ });
62
+ });
63
+
64
+ // ── Unit tests: extractToolArgsPreview ──
65
+
66
+ describe("extractToolArgsPreview", () => {
67
+ it("returns empty string for null/undefined", () => {
68
+ assert.strictEqual(extractToolArgsPreview(null), "");
69
+ assert.strictEqual(extractToolArgsPreview(undefined), "");
70
+ });
71
+
72
+ it("returns empty string for non-object", () => {
73
+ assert.strictEqual(extractToolArgsPreview("string"), "");
74
+ assert.strictEqual(extractToolArgsPreview(42), "");
75
+ });
76
+
77
+ it("extracts command arg", () => {
78
+ assert.strictEqual(extractToolArgsPreview({ command: "ls -la" }), "ls -la");
79
+ });
80
+
81
+ it("extracts path arg", () => {
82
+ assert.strictEqual(extractToolArgsPreview({ path: "/foo/bar.ts" }), "/foo/bar.ts");
83
+ });
84
+
85
+ it("extracts pattern arg", () => {
86
+ assert.strictEqual(extractToolArgsPreview({ pattern: "*.ts" }), "*.ts");
87
+ });
88
+
89
+ it("extracts query arg", () => {
90
+ assert.strictEqual(extractToolArgsPreview({ query: "SELECT 1" }), "SELECT 1");
91
+ });
92
+
93
+ it("extracts task arg", () => {
94
+ assert.strictEqual(extractToolArgsPreview({ task: "do something" }), "do something");
95
+ });
96
+
97
+ it("prefers command over other keys", () => {
98
+ assert.strictEqual(
99
+ extractToolArgsPreview({ command: "npm test", path: "/foo" }),
100
+ "npm test",
101
+ );
102
+ });
103
+
104
+ it("truncates long values to 60 chars", () => {
105
+ const longVal = "a".repeat(100);
106
+ const result = extractToolArgsPreview({ command: longVal });
107
+ assert.strictEqual(result.length, 60);
108
+ assert.ok(result.endsWith("..."));
109
+ assert.strictEqual(result, "a".repeat(57) + "...");
110
+ });
111
+
112
+ it("does not truncate values at exactly 60 chars", () => {
113
+ const val60 = "b".repeat(60);
114
+ assert.strictEqual(extractToolArgsPreview({ command: val60 }), val60);
115
+ });
116
+
117
+ it("returns empty string when no recognized keys present", () => {
118
+ assert.strictEqual(extractToolArgsPreview({ foo: "bar", baz: 42 }), "");
119
+ });
120
+
121
+ it("skips non-string values for recognized keys", () => {
122
+ assert.strictEqual(extractToolArgsPreview({ command: 123, path: true }), "");
123
+ });
124
+ });
125
+
126
+ // ── Unit tests: buildArgs ──
127
+
128
+ describe("buildArgs", () => {
129
+ const baseOptions = {
130
+ cwd: "/tmp",
131
+ sessionLogDir: "/tmp/sessions",
132
+ stepName: "test-step",
133
+ };
134
+
135
+ it("produces minimal args for a bare step and agent", () => {
136
+ const step: StepSpec = { agent: "default" };
137
+ const agent: AgentSpec = { name: "default" };
138
+ const args = buildArgs(step, agent, "hello", baseOptions);
139
+
140
+ assert.deepStrictEqual(args.slice(0, 2), ["--mode", "json"]);
141
+ assert.ok(args.includes("--session-dir"));
142
+ assert.ok(args.includes("/tmp/sessions"));
143
+ // Ends with -p hello
144
+ assert.strictEqual(args[args.length - 2], "-p");
145
+ assert.strictEqual(args[args.length - 1], "hello");
146
+ // No model args
147
+ assert.ok(!args.includes("--models"));
148
+ });
149
+
150
+ it("includes model from agent spec", () => {
151
+ const step: StepSpec = { agent: "coder" };
152
+ const agent: AgentSpec = { name: "coder", model: "claude-sonnet-4-20250514" };
153
+ const args = buildArgs(step, agent, "do it", baseOptions);
154
+
155
+ const idx = args.indexOf("--models");
156
+ assert.ok(idx >= 0);
157
+ assert.strictEqual(args[idx + 1], "claude-sonnet-4-20250514:off");
158
+ });
159
+
160
+ it("step model overrides agent model", () => {
161
+ const step: StepSpec = { agent: "coder", model: "claude-opus-4-20250514" };
162
+ const agent: AgentSpec = { name: "coder", model: "claude-sonnet-4-20250514" };
163
+ const args = buildArgs(step, agent, "do it", baseOptions);
164
+
165
+ const idx = args.indexOf("--models");
166
+ assert.strictEqual(args[idx + 1], "claude-opus-4-20250514:off");
167
+ });
168
+
169
+ it("appends thinking suffix to model", () => {
170
+ const step: StepSpec = { agent: "coder" };
171
+ const agent: AgentSpec = { name: "coder", model: "claude-sonnet-4-20250514", thinking: "16k" };
172
+ const args = buildArgs(step, agent, "do it", baseOptions);
173
+
174
+ const idx = args.indexOf("--models");
175
+ assert.strictEqual(args[idx + 1], "claude-sonnet-4-20250514:16k");
176
+ });
177
+
178
+ it("resolves model from modelConfig by role when agent has no model", () => {
179
+ const step: StepSpec = { agent: "coder" };
180
+ const agent: AgentSpec = { name: "coder", role: "action" };
181
+ const opts = { ...baseOptions, modelConfig: { default: "fallback-model", by_role: { action: "opus-4" } } };
182
+ const args = buildArgs(step, agent, "do it", opts);
183
+
184
+ const idx = args.indexOf("--models");
185
+ assert.ok(idx >= 0);
186
+ assert.strictEqual(args[idx + 1], "opus-4:off");
187
+ });
188
+
189
+ it("resolves model from modelConfig default when no role match", () => {
190
+ const step: StepSpec = { agent: "coder" };
191
+ const agent: AgentSpec = { name: "coder", role: "unknown-role" };
192
+ const opts = { ...baseOptions, modelConfig: { default: "fallback-model", by_role: { action: "opus-4" } } };
193
+ const args = buildArgs(step, agent, "do it", opts);
194
+
195
+ const idx = args.indexOf("--models");
196
+ assert.ok(idx >= 0);
197
+ assert.strictEqual(args[idx + 1], "fallback-model:off");
198
+ });
199
+
200
+ it("step model overrides modelConfig", () => {
201
+ const step: StepSpec = { agent: "coder", model: "step-override" };
202
+ const agent: AgentSpec = { name: "coder", role: "action" };
203
+ const opts = { ...baseOptions, modelConfig: { default: "fallback-model", by_role: { action: "opus-4" } } };
204
+ const args = buildArgs(step, agent, "do it", opts);
205
+
206
+ const idx = args.indexOf("--models");
207
+ assert.strictEqual(args[idx + 1], "step-override:off");
208
+ });
209
+
210
+ it("agent model overrides modelConfig", () => {
211
+ const step: StepSpec = { agent: "coder" };
212
+ const agent: AgentSpec = { name: "coder", model: "agent-model", role: "action" };
213
+ const opts = { ...baseOptions, modelConfig: { default: "fallback-model", by_role: { action: "opus-4" } } };
214
+ const args = buildArgs(step, agent, "do it", opts);
215
+
216
+ const idx = args.indexOf("--models");
217
+ assert.strictEqual(args[idx + 1], "agent-model:off");
218
+ });
219
+
220
+ it("handles builtin tools", () => {
221
+ const step: StepSpec = { agent: "coder" };
222
+ const agent: AgentSpec = { name: "coder", tools: ["bash", "read", "write"] };
223
+ const args = buildArgs(step, agent, "do it", baseOptions);
224
+
225
+ const idx = args.indexOf("--tools");
226
+ assert.ok(idx >= 0);
227
+ assert.strictEqual(args[idx + 1], "bash,read,write");
228
+ });
229
+
230
+ it("handles extension tool paths", () => {
231
+ const step: StepSpec = { agent: "coder" };
232
+ const agent: AgentSpec = { name: "coder", tools: ["bash", "./ext/my-tool.ts", "/abs/tool.js"] };
233
+ const args = buildArgs(step, agent, "do it", baseOptions);
234
+
235
+ const toolsIdx = args.indexOf("--tools");
236
+ assert.ok(toolsIdx >= 0);
237
+ assert.strictEqual(args[toolsIdx + 1], "bash");
238
+
239
+ // Should have two --extension entries for the paths
240
+ const extIndices = args.reduce<number[]>((acc, a, i) => {
241
+ if (a === "--extension") acc.push(i);
242
+ return acc;
243
+ }, []);
244
+ assert.strictEqual(extIndices.length, 2);
245
+ assert.strictEqual(args[extIndices[0] + 1], "./ext/my-tool.ts");
246
+ assert.strictEqual(args[extIndices[1] + 1], "/abs/tool.js");
247
+ });
248
+
249
+ it("handles extensions scoping", () => {
250
+ const step: StepSpec = { agent: "coder" };
251
+ const agent: AgentSpec = { name: "coder", extensions: ["./ext1.ts", "./ext2.ts"] };
252
+ const args = buildArgs(step, agent, "do it", baseOptions);
253
+
254
+ assert.ok(args.includes("--no-extensions"));
255
+ const extIndices = args.reduce<number[]>((acc, a, i) => {
256
+ if (a === "--extension") acc.push(i);
257
+ return acc;
258
+ }, []);
259
+ assert.strictEqual(extIndices.length, 2);
260
+ });
261
+
262
+ it("handles skills scoping", () => {
263
+ const step: StepSpec = { agent: "coder" };
264
+ const agent: AgentSpec = { name: "coder", skills: ["coding", "testing"] };
265
+ const args = buildArgs(step, agent, "do it", baseOptions);
266
+
267
+ assert.ok(args.includes("--no-skills"));
268
+ });
269
+ });
270
+
271
+ // ── Integration tests: dispatch (require pi on PATH) ──
272
+
273
+ describe("dispatch", { skip: !hasPi ? "pi not available" : undefined }, () => {
274
+ it("runs a simple prompt and returns text output", async () => {
275
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-dispatch-"));
276
+ const sessDir = path.join(tmpDir, "sessions");
277
+ fs.mkdirSync(sessDir, { recursive: true });
278
+
279
+ const result = await dispatch(
280
+ { agent: "default" }, // no specific agent — use pi defaults
281
+ { name: "default" }, // minimal agent spec
282
+ "Respond with exactly: HELLO WORKFLOW",
283
+ { cwd: process.cwd(), sessionLogDir: sessDir, stepName: "test" },
284
+ );
285
+
286
+ assert.strictEqual(result.status, "completed");
287
+ assert.ok(result.textOutput.includes("HELLO WORKFLOW"));
288
+ assert.ok(result.usage.turns >= 1);
289
+ assert.ok(result.durationMs > 0);
290
+
291
+ fs.rmSync(tmpDir, { recursive: true });
292
+ }, { timeout: 60000 });
293
+
294
+ it("returns failed status on bad prompt/agent", async () => {
295
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-dispatch-"));
296
+ const sessDir = path.join(tmpDir, "sessions");
297
+ fs.mkdirSync(sessDir, { recursive: true });
298
+
299
+ // --tools none should cause pi to fail or at least limit capabilities
300
+ const result = await dispatch(
301
+ { agent: "nonexistent-agent-xyz" },
302
+ { name: "nonexistent-agent-xyz" },
303
+ "This should fail",
304
+ { cwd: process.cwd(), sessionLogDir: sessDir, stepName: "test" },
305
+ );
306
+
307
+ // Exact failure mode depends on pi behavior with unknown agents
308
+ // At minimum we should get a result back (not throw)
309
+ assert.ok(result.durationMs > 0);
310
+
311
+ fs.rmSync(tmpDir, { recursive: true });
312
+ }, { timeout: 30000 });
313
+
314
+ it("supports cancellation via AbortSignal", async () => {
315
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-dispatch-"));
316
+ const sessDir = path.join(tmpDir, "sessions");
317
+ fs.mkdirSync(sessDir, { recursive: true });
318
+
319
+ const controller = new AbortController();
320
+
321
+ // Cancel after 2 seconds
322
+ setTimeout(() => controller.abort(), 2000);
323
+
324
+ const result = await dispatch(
325
+ { agent: "default" },
326
+ { name: "default" },
327
+ "Write a very long essay about the history of computing. Make it at least 5000 words.",
328
+ { cwd: process.cwd(), sessionLogDir: sessDir, stepName: "test", signal: controller.signal },
329
+ );
330
+
331
+ assert.strictEqual(result.status, "failed");
332
+
333
+ fs.rmSync(tmpDir, { recursive: true });
334
+ }, { timeout: 30000 });
335
+
336
+ it("fires onEvent callback for process events", async () => {
337
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-dispatch-"));
338
+ const sessDir = path.join(tmpDir, "sessions");
339
+ fs.mkdirSync(sessDir, { recursive: true });
340
+
341
+ const events: string[] = [];
342
+
343
+ const result = await dispatch(
344
+ { agent: "default" },
345
+ { name: "default" },
346
+ "Respond with exactly: TEST",
347
+ {
348
+ cwd: process.cwd(),
349
+ sessionLogDir: sessDir,
350
+ stepName: "test",
351
+ onEvent: (evt) => events.push(evt.type),
352
+ },
353
+ );
354
+
355
+ assert.ok(events.includes("agent_start"));
356
+ assert.ok(events.includes("message_end"));
357
+
358
+ fs.rmSync(tmpDir, { recursive: true });
359
+ }, { timeout: 60000 });
360
+ });
361
+
362
+ // ── Unit tests: StepResult truncation fields ──
363
+
364
+ describe("StepResult truncation fields", () => {
365
+ it("truncated and warnings are optional on StepResult", () => {
366
+ // Verify the type contract: a StepResult without truncated/warnings is valid
367
+ const result: StepResult = {
368
+ step: "test",
369
+ agent: "agent",
370
+ status: "completed",
371
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
372
+ durationMs: 0,
373
+ };
374
+ assert.strictEqual(result.truncated, undefined);
375
+ assert.strictEqual(result.warnings, undefined);
376
+
377
+ // A truncated result is also valid
378
+ const truncResult: StepResult = {
379
+ ...result,
380
+ truncated: true,
381
+ warnings: ["Stdout exceeded 10MB limit"],
382
+ };
383
+ assert.strictEqual(truncResult.truncated, true);
384
+ assert.strictEqual(truncResult.warnings!.length, 1);
385
+ });
386
+ });
387
+
388
+ // ── Unit tests: truncation detection logic ──
389
+
390
+ describe("truncation detection logic", () => {
391
+ it("sets truncated flag when accumulated bytes exceed threshold", () => {
392
+ // Simulate the accumulation logic from dispatch's data handler
393
+ const MAX = 100; // small threshold for testing
394
+ let bufBytes = 0;
395
+ let stdoutTruncated = false;
396
+ const processedLines: string[] = [];
397
+ let buf = "";
398
+
399
+ function handleChunk(chunk: string) {
400
+ bufBytes += Buffer.byteLength(chunk);
401
+ if (bufBytes > MAX) {
402
+ if (!stdoutTruncated) {
403
+ stdoutTruncated = true;
404
+ if (buf.trim()) {
405
+ processedLines.push(buf.trim());
406
+ buf = "";
407
+ }
408
+ }
409
+ return;
410
+ }
411
+ buf += chunk;
412
+ const lines = buf.split("\n");
413
+ buf = lines.pop() || "";
414
+ for (const line of lines) {
415
+ if (line.trim()) processedLines.push(line.trim());
416
+ }
417
+ }
418
+
419
+ // Send chunks that total over 100 bytes
420
+ handleChunk('{"type":"event1"}\n'); // ~18 bytes
421
+ handleChunk('{"type":"event2"}\n'); // ~36 total
422
+ handleChunk('{"type":"event3"}\n'); // ~54 total
423
+ handleChunk('{"type":"event4"}\n'); // ~72 total
424
+ handleChunk('{"type":"event5"}\n'); // ~90 total
425
+ handleChunk('{"type":"event6"}\n'); // ~108 total — exceeds 100
426
+
427
+ assert.strictEqual(stdoutTruncated, true);
428
+ // Events before the threshold should have been processed
429
+ assert.ok(processedLines.length >= 4);
430
+ assert.ok(processedLines.length < 6, "Post-threshold events should not be processed");
431
+ });
432
+
433
+ it("does not set truncated flag when under threshold", () => {
434
+ const MAX = 10000;
435
+ let bufBytes = 0;
436
+ let stdoutTruncated = false;
437
+
438
+ function handleChunk(chunk: string) {
439
+ bufBytes += Buffer.byteLength(chunk);
440
+ if (bufBytes > MAX) {
441
+ stdoutTruncated = true;
442
+ return;
443
+ }
444
+ }
445
+
446
+ handleChunk('{"type":"small"}\n');
447
+ assert.strictEqual(stdoutTruncated, false);
448
+ });
449
+ });
450
+
451
+ // ── Integration test: dispatch truncation contract ──
452
+
453
+ describe("dispatch truncation", { skip: !hasPi ? "pi not available" : undefined }, () => {
454
+ it("sets truncated flag when stdout exceeds buffer limit", async () => {
455
+ // A normal-sized response should NOT have truncated set.
456
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-dispatch-trunc-"));
457
+ const sessDir = path.join(tmpDir, "sessions");
458
+ fs.mkdirSync(sessDir, { recursive: true });
459
+
460
+ const result = await dispatch(
461
+ { agent: "default" },
462
+ { name: "default" },
463
+ "Respond with exactly: OK",
464
+ { cwd: process.cwd(), sessionLogDir: sessDir, stepName: "test" },
465
+ );
466
+
467
+ assert.strictEqual(result.status, "completed");
468
+ assert.strictEqual(result.truncated, undefined, "Normal response should not be truncated");
469
+ assert.strictEqual(result.warnings, undefined, "Normal response should have no warnings");
470
+
471
+ fs.rmSync(tmpDir, { recursive: true });
472
+ }, { timeout: 60000 });
473
+ });