@ryanfw/prompt-orchestration-pipeline 1.3.0 → 1.3.2

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 (50) hide show
  1. package/README.md +1 -0
  2. package/docs/pop-task-guide.md +45 -0
  3. package/package.json +3 -3
  4. package/src/core/__tests__/agent-step.test.ts +402 -35
  5. package/src/core/__tests__/task-runner.test.ts +104 -0
  6. package/src/core/agent-step.ts +295 -41
  7. package/src/core/agent-types.ts +61 -0
  8. package/src/core/file-io.ts +8 -74
  9. package/src/core/orchestrator.ts +2 -1
  10. package/src/core/pipeline-definition.ts +1 -1
  11. package/src/core/pipeline-runner.ts +54 -3
  12. package/src/core/status-writer.ts +44 -26
  13. package/src/core/task-runner.ts +44 -1
  14. package/src/core/validation.ts +1 -1
  15. package/src/harness/__tests__/discovery.test.ts +235 -0
  16. package/src/harness/discovery.ts +109 -0
  17. package/src/harness/index.ts +22 -0
  18. package/src/harness/mcp-io-server.ts +1 -1
  19. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  20. package/src/ui/dist/assets/{index-D7hzshSS.js → index--RH3sAt3.js} +129 -1
  21. package/src/ui/dist/assets/index--RH3sAt3.js.map +1 -0
  22. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  23. package/src/ui/dist/index.html +2 -2
  24. package/src/ui/embedded-assets.js +6 -6
  25. package/src/ui/pages/Code.tsx +135 -0
  26. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  27. package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
  28. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  29. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  30. package/src/ui/server/config-bridge.ts +1 -0
  31. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  32. package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
  33. package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
  34. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  35. package/src/ui/server/utils/http-utils.ts +6 -1
  36. package/src/ui/server/utils/path-containment.ts +18 -0
  37. package/src/ui/server/utils/status-corruption.ts +27 -0
  38. package/src/harness/__tests__/descriptors.test.ts +0 -378
  39. package/src/harness/__tests__/executor.test.ts +0 -193
  40. package/src/harness/__tests__/resolve.test.ts +0 -200
  41. package/src/harness/__tests__/types.test.ts +0 -297
  42. package/src/harness/descriptors/claude.ts +0 -132
  43. package/src/harness/descriptors/codex.ts +0 -126
  44. package/src/harness/descriptors/index.ts +0 -10
  45. package/src/harness/descriptors/opencode.ts +0 -147
  46. package/src/harness/executor.ts +0 -128
  47. package/src/harness/resolve.ts +0 -176
  48. package/src/harness/types.ts +0 -100
  49. package/src/ui/dist/assets/index-D7hzshSS.js.map +0 -1
  50. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -1,378 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { claudeDescriptor } from "../descriptors/claude.ts";
3
- import { codexDescriptor } from "../descriptors/codex.ts";
4
- import { opencodeDescriptor } from "../descriptors/opencode.ts";
5
- import { DESCRIPTORS } from "../descriptors/index.ts";
6
- import type { HarnessEvent, HarnessRunOptions } from "../types.ts";
7
-
8
- const baseOpts: HarnessRunOptions = {
9
- harness: "claude",
10
- prompt: "do the thing",
11
- cwd: "/tmp/work",
12
- };
13
-
14
- const mcpConn = { url: "http://127.0.0.1:9000/mcp", token: "tok-123" };
15
-
16
- // ── index.ts ──────────────────────────────────────────────────────────
17
-
18
- describe("DESCRIPTORS", () => {
19
- it("contains all three harness names", () => {
20
- expect(Object.keys(DESCRIPTORS).sort()).toEqual(["claude", "codex", "opencode"]);
21
- });
22
-
23
- it("maps each name to the correct descriptor", () => {
24
- expect(DESCRIPTORS.claude.name).toBe("claude");
25
- expect(DESCRIPTORS.codex.name).toBe("codex");
26
- expect(DESCRIPTORS.opencode.name).toBe("opencode");
27
- });
28
- });
29
-
30
- // ── versionArgv ───────────────────────────────────────────────────────
31
-
32
- describe("versionArgv", () => {
33
- it("claude uses --version", () => {
34
- expect(claudeDescriptor.versionArgv).toEqual(["claude", "--version"]);
35
- });
36
-
37
- it("codex uses --version", () => {
38
- expect(codexDescriptor.versionArgv).toEqual(["codex", "--version"]);
39
- });
40
-
41
- it("opencode uses --version", () => {
42
- expect(opencodeDescriptor.versionArgv).toEqual(["opencode", "--version"]);
43
- });
44
- });
45
-
46
- // ── claude descriptor ─────────────────────────────────────────────────
47
-
48
- describe("claude buildArgv", () => {
49
- it("includes --dangerously-skip-permissions", () => {
50
- const argv = claudeDescriptor.buildArgv(baseOpts);
51
- expect(argv).toContain("--dangerously-skip-permissions");
52
- });
53
-
54
- it("includes --model when model is given", () => {
55
- const argv = claudeDescriptor.buildArgv({ ...baseOpts, model: "sonnet" });
56
- expect(argv).toContain("--model");
57
- expect(argv).toContain("sonnet");
58
- });
59
-
60
- it("omits --model when model is absent", () => {
61
- const argv = claudeDescriptor.buildArgv(baseOpts);
62
- expect(argv).not.toContain("--model");
63
- });
64
-
65
- it("includes -p with the prompt", () => {
66
- const argv = claudeDescriptor.buildArgv(baseOpts);
67
- expect(argv[0]).toBe("claude");
68
- expect(argv[1]).toBe("-p");
69
- expect(argv[2]).toBe("do the thing");
70
- });
71
-
72
- it("includes --output-format stream-json and --verbose", () => {
73
- const argv = claudeDescriptor.buildArgv(baseOpts);
74
- expect(argv).toContain("--output-format");
75
- expect(argv).toContain("stream-json");
76
- expect(argv).toContain("--verbose");
77
- });
78
-
79
- it("includes --mcp-config with correct JSON when mcp is set", () => {
80
- const argv = claudeDescriptor.buildArgv({ ...baseOpts, mcp: mcpConn });
81
- const idx = argv.indexOf("--mcp-config");
82
- expect(idx).toBeGreaterThan(-1);
83
- const config = JSON.parse(argv[idx + 1]!);
84
- expect(config.mcpServers.popio.type).toBe("http");
85
- expect(config.mcpServers.popio.url).toBe("http://127.0.0.1:9000/mcp");
86
- expect(config.mcpServers.popio.headers.Authorization).toBe("Bearer tok-123");
87
- });
88
-
89
- it("omits --mcp-config when mcp is absent", () => {
90
- const argv = claudeDescriptor.buildArgv(baseOpts);
91
- expect(argv).not.toContain("--mcp-config");
92
- });
93
- });
94
-
95
- describe("claude buildEnv", () => {
96
- it("returns empty env", () => {
97
- const result = claudeDescriptor.buildEnv(baseOpts);
98
- expect(result.env).toEqual({});
99
- expect(result.tmpFiles).toBeUndefined();
100
- });
101
- });
102
-
103
- // ── codex descriptor ──────────────────────────────────────────────────
104
-
105
- describe("codex buildArgv", () => {
106
- it("includes --dangerously-bypass-approvals-and-sandbox", () => {
107
- const argv = codexDescriptor.buildArgv({ ...baseOpts, harness: "codex" });
108
- expect(argv).toContain("--dangerously-bypass-approvals-and-sandbox");
109
- });
110
-
111
- it("includes --skip-git-repo-check", () => {
112
- const argv = codexDescriptor.buildArgv({ ...baseOpts, harness: "codex" });
113
- expect(argv).toContain("--skip-git-repo-check");
114
- });
115
-
116
- it("includes -C with cwd", () => {
117
- const argv = codexDescriptor.buildArgv({ ...baseOpts, harness: "codex" });
118
- const idx = argv.indexOf("-C");
119
- expect(idx).toBeGreaterThan(-1);
120
- expect(argv[idx + 1]).toBe("/tmp/work");
121
- });
122
-
123
- it("includes -m when model is given", () => {
124
- const argv = codexDescriptor.buildArgv({ ...baseOpts, harness: "codex", model: "gpt-4" });
125
- expect(argv).toContain("-m");
126
- expect(argv).toContain("gpt-4");
127
- });
128
-
129
- it("omits -m when model is absent", () => {
130
- const argv = codexDescriptor.buildArgv({ ...baseOpts, harness: "codex" });
131
- expect(argv).not.toContain("-m");
132
- });
133
-
134
- it("includes MCP -c overrides when mcp is set", () => {
135
- const argv = codexDescriptor.buildArgv({ ...baseOpts, harness: "codex", mcp: mcpConn });
136
- const urlArg = argv.find((a) => a.includes("mcp_servers.popio.url"));
137
- expect(urlArg).toBe('mcp_servers.popio.url="http://127.0.0.1:9000/mcp"');
138
- const tokenArg = argv.find((a) => a.includes("bearer_token_env_var"));
139
- expect(tokenArg).toBe('mcp_servers.popio.bearer_token_env_var="POP_MCP_TOKEN"');
140
- });
141
-
142
- it("omits MCP -c overrides when mcp is absent", () => {
143
- const argv = codexDescriptor.buildArgv({ ...baseOpts, harness: "codex" });
144
- expect(argv.find((a) => a.includes("mcp_servers"))).toBeUndefined();
145
- });
146
- });
147
-
148
- describe("codex buildEnv", () => {
149
- it("sets POP_MCP_TOKEN when mcp is set", () => {
150
- const result = codexDescriptor.buildEnv({ ...baseOpts, harness: "codex", mcp: mcpConn });
151
- expect(result.env.POP_MCP_TOKEN).toBe("tok-123");
152
- });
153
-
154
- it("does not set POP_MCP_TOKEN when mcp is absent", () => {
155
- const result = codexDescriptor.buildEnv({ ...baseOpts, harness: "codex" });
156
- expect(result.env.POP_MCP_TOKEN).toBeUndefined();
157
- });
158
-
159
- it("does not set CODEX_HOME", () => {
160
- const result = codexDescriptor.buildEnv({ ...baseOpts, harness: "codex", mcp: mcpConn });
161
- expect(result.env.CODEX_HOME).toBeUndefined();
162
- });
163
- });
164
-
165
- // ── opencode descriptor ───────────────────────────────────────────────
166
-
167
- describe("opencode buildArgv", () => {
168
- it("includes --dangerously-skip-permissions", () => {
169
- const argv = opencodeDescriptor.buildArgv({ ...baseOpts, harness: "opencode" });
170
- expect(argv).toContain("--dangerously-skip-permissions");
171
- });
172
-
173
- it("includes --dir with cwd", () => {
174
- const argv = opencodeDescriptor.buildArgv({ ...baseOpts, harness: "opencode" });
175
- const idx = argv.indexOf("--dir");
176
- expect(idx).toBeGreaterThan(-1);
177
- expect(argv[idx + 1]).toBe("/tmp/work");
178
- });
179
-
180
- it("includes --model when model is given", () => {
181
- const argv = opencodeDescriptor.buildArgv({ ...baseOpts, harness: "opencode", model: "gpt-4o" });
182
- expect(argv).toContain("--model");
183
- expect(argv).toContain("gpt-4o");
184
- });
185
-
186
- it("omits --model when model is absent", () => {
187
- const argv = opencodeDescriptor.buildArgv({ ...baseOpts, harness: "opencode" });
188
- expect(argv).not.toContain("--model");
189
- });
190
-
191
- it("includes --format json", () => {
192
- const argv = opencodeDescriptor.buildArgv({ ...baseOpts, harness: "opencode" });
193
- expect(argv).toContain("--format");
194
- expect(argv).toContain("json");
195
- });
196
- });
197
-
198
- describe("opencode buildEnv", () => {
199
- it("sets OPENCODE_PERMISSION allow-all", () => {
200
- const result = opencodeDescriptor.buildEnv({ ...baseOpts, harness: "opencode" });
201
- const perm = JSON.parse(result.env.OPENCODE_PERMISSION!);
202
- expect(perm).toEqual([{ permission: "*", pattern: "*", action: "allow" }]);
203
- });
204
-
205
- it("writes opencode.json to tmpFiles when mcp is set", () => {
206
- const result = opencodeDescriptor.buildEnv({ ...baseOpts, harness: "opencode", mcp: mcpConn });
207
- expect(result.tmpFiles).toHaveLength(1);
208
- const file = result.tmpFiles![0]!;
209
- expect(file.path).toContain("opencode-mcp-");
210
- expect(file.path).toContain("opencode.json");
211
- const config = JSON.parse(file.content);
212
- expect(config.mcp.popio.type).toBe("remote");
213
- expect(config.mcp.popio.url).toBe("http://127.0.0.1:9000/mcp");
214
- expect(config.mcp.popio.headers.Authorization).toBe("Bearer tok-123");
215
- expect(config.mcp.popio.enabled).toBe(true);
216
- });
217
-
218
- it("sets OPENCODE_CONFIG_DIR when mcp is set", () => {
219
- const result = opencodeDescriptor.buildEnv({ ...baseOpts, harness: "opencode", mcp: mcpConn });
220
- expect(result.env.OPENCODE_CONFIG_DIR).toContain("opencode-mcp-");
221
- });
222
-
223
- it("omits tmpFiles when mcp is absent", () => {
224
- const result = opencodeDescriptor.buildEnv({ ...baseOpts, harness: "opencode" });
225
- expect(result.tmpFiles).toBeUndefined();
226
- });
227
-
228
- it("does not set OPENCODE_CONFIG_DIR when mcp is absent", () => {
229
- const result = opencodeDescriptor.buildEnv({ ...baseOpts, harness: "opencode" });
230
- expect(result.env.OPENCODE_CONFIG_DIR).toBeUndefined();
231
- });
232
- });
233
-
234
- // ── extractors ────────────────────────────────────────────────────────
235
-
236
- describe("claude extractors", () => {
237
- const resultEvent: HarnessEvent = {
238
- type: "result",
239
- raw: {
240
- type: "result",
241
- usage: { input_tokens: 100, output_tokens: 50 },
242
- total_cost_usd: 0.003,
243
- session_id: "sess-claude-1",
244
- },
245
- };
246
- const textEvent: HarnessEvent = { type: "text", raw: { type: "text" }, text: "hello" };
247
- const events: HarnessEvent[] = [textEvent, resultEvent];
248
-
249
- it("extractUsage returns token counts", () => {
250
- const usage = claudeDescriptor.extractUsage(events);
251
- expect(usage).toEqual({ inputTokens: 100, outputTokens: 50, totalTokens: 150 });
252
- });
253
-
254
- it("extractCostUsd returns cost", () => {
255
- expect(claudeDescriptor.extractCostUsd(events)).toBe(0.003);
256
- });
257
-
258
- it("extractSessionId returns session id", () => {
259
- expect(claudeDescriptor.extractSessionId(events)).toBe("sess-claude-1");
260
- });
261
-
262
- it("extractFinalMessage returns last text", () => {
263
- expect(claudeDescriptor.extractFinalMessage(events)).toBe("hello");
264
- });
265
-
266
- it("extractors return undefined when no result event", () => {
267
- expect(claudeDescriptor.extractUsage([textEvent])).toBeUndefined();
268
- expect(claudeDescriptor.extractCostUsd([textEvent])).toBeUndefined();
269
- expect(claudeDescriptor.extractSessionId([textEvent])).toBeUndefined();
270
- });
271
-
272
- it("extractFinalMessage returns empty string when no text/result events", () => {
273
- expect(claudeDescriptor.extractFinalMessage([])).toBe("");
274
- });
275
- });
276
-
277
- describe("codex extractors", () => {
278
- const resultEvent: HarnessEvent = {
279
- type: "result",
280
- raw: {
281
- type: "result",
282
- usage: { input_tokens: 200, output_tokens: 80 },
283
- total_cost_usd: 0.007,
284
- session_id: "sess-codex-1",
285
- },
286
- };
287
- const events: HarnessEvent[] = [resultEvent];
288
-
289
- it("extractUsage returns token counts", () => {
290
- const usage = codexDescriptor.extractUsage(events);
291
- expect(usage).toEqual({ inputTokens: 200, outputTokens: 80, totalTokens: 280 });
292
- });
293
-
294
- it("extractCostUsd returns cost", () => {
295
- expect(codexDescriptor.extractCostUsd(events)).toBe(0.007);
296
- });
297
-
298
- it("extractSessionId returns session id", () => {
299
- expect(codexDescriptor.extractSessionId(events)).toBe("sess-codex-1");
300
- });
301
-
302
- it("extractors return undefined when no result event", () => {
303
- const rawEvent: HarnessEvent = { type: "raw", raw: { foo: 1 } };
304
- expect(codexDescriptor.extractUsage([rawEvent])).toBeUndefined();
305
- expect(codexDescriptor.extractCostUsd([rawEvent])).toBeUndefined();
306
- expect(codexDescriptor.extractSessionId([rawEvent])).toBeUndefined();
307
- });
308
- });
309
-
310
- describe("opencode extractors", () => {
311
- const resultEvent: HarnessEvent = {
312
- type: "result",
313
- raw: {
314
- type: "result",
315
- info: {
316
- tokens: { input: 300, output: 120, total: 420 },
317
- costUsd: 0.012,
318
- sessionId: "sess-oc-1",
319
- },
320
- },
321
- };
322
- const events: HarnessEvent[] = [resultEvent];
323
-
324
- it("extractUsage returns token counts from info.tokens", () => {
325
- const usage = opencodeDescriptor.extractUsage(events);
326
- expect(usage).toEqual({ inputTokens: 300, outputTokens: 120, totalTokens: 420 });
327
- });
328
-
329
- it("extractCostUsd returns cost from info.costUsd", () => {
330
- expect(opencodeDescriptor.extractCostUsd(events)).toBe(0.012);
331
- });
332
-
333
- it("extractSessionId returns session id from info.sessionId", () => {
334
- expect(opencodeDescriptor.extractSessionId(events)).toBe("sess-oc-1");
335
- });
336
-
337
- it("extractors return undefined when no result event", () => {
338
- const rawEvent: HarnessEvent = { type: "raw", raw: {} };
339
- expect(opencodeDescriptor.extractUsage([rawEvent])).toBeUndefined();
340
- expect(opencodeDescriptor.extractCostUsd([rawEvent])).toBeUndefined();
341
- expect(opencodeDescriptor.extractSessionId([rawEvent])).toBeUndefined();
342
- });
343
- });
344
-
345
- // ── parseEvents ───────────────────────────────────────────────────────
346
-
347
- describe("parseEvents (shared across descriptors)", () => {
348
- it("categorizes known event types", () => {
349
- const lines = [
350
- { type: "text", text: "hi" },
351
- { type: "tool_call", tool: "read_file" },
352
- { type: "tool_result" },
353
- { type: "system" },
354
- { type: "result" },
355
- ];
356
- const events = claudeDescriptor.parseEvents(lines);
357
- expect(events.map((e) => e.type)).toEqual([
358
- "text",
359
- "tool_call",
360
- "tool_result",
361
- "system",
362
- "result",
363
- ]);
364
- });
365
-
366
- it("defaults unknown types to raw", () => {
367
- const lines = [{ type: "unknown_thing", data: 1 }, { no_type: true }];
368
- const events = claudeDescriptor.parseEvents(lines);
369
- expect(events[0]!.type).toBe("raw");
370
- expect(events[1]!.type).toBe("raw");
371
- });
372
-
373
- it("preserves raw reference", () => {
374
- const line = { type: "text", text: "hello" };
375
- const events = claudeDescriptor.parseEvents([line]);
376
- expect(events[0]!.raw).toBe(line);
377
- });
378
- });
@@ -1,193 +0,0 @@
1
- import { describe, it, expect, mock } from "bun:test";
2
- import { runHarnessTask, isHarnessAvailable } from "../executor.ts";
3
- import type { RunJsonlSubprocessResult } from "../subprocess.ts";
4
- import type { HarnessDescriptor, HarnessEvent, HarnessRunOptions, HarnessName } from "../types.ts";
5
-
6
- const fakeClaudeDescriptor: HarnessDescriptor = {
7
- name: "claude",
8
- versionArgv: ["claude", "--version"],
9
- buildArgv: () => ["claude", "-p", "test prompt"],
10
- buildEnv: () => ({ env: {} }),
11
- parseEvents: (lines: unknown[]) =>
12
- (lines as Record<string, unknown>[]).map((raw) => ({
13
- type: ((raw as Record<string, unknown>).type as string) ?? "raw",
14
- raw,
15
- })) as HarnessEvent[],
16
- extractFinalMessage: (events: HarnessEvent[]) => {
17
- const result = events.find((e) => e.type === "result");
18
- return (result?.raw as Record<string, unknown>)?.message as string ?? "";
19
- },
20
- extractUsage: (events: HarnessEvent[]) => {
21
- const result = events.find((e) => e.type === "result");
22
- const usage = (result?.raw as Record<string, unknown>)?.usage;
23
- if (usage) return usage as { inputTokens: number; outputTokens: number; totalTokens: number };
24
- return undefined;
25
- },
26
- extractCostUsd: (events: HarnessEvent[]) => {
27
- const result = events.find((e) => e.type === "result");
28
- return (result?.raw as Record<string, unknown>)?.costUsd as number | undefined;
29
- },
30
- extractSessionId: (events: HarnessEvent[]) => {
31
- const result = events.find((e) => e.type === "result");
32
- return (result?.raw as Record<string, unknown>)?.sessionId as string | undefined;
33
- },
34
- };
35
-
36
- const fakeDescriptors: Record<HarnessName, HarnessDescriptor> = {
37
- claude: fakeClaudeDescriptor,
38
- codex: fakeClaudeDescriptor,
39
- opencode: fakeClaudeDescriptor,
40
- };
41
-
42
- function fakeSubprocess(result: RunJsonlSubprocessResult) {
43
- return mock(async () => result);
44
- }
45
-
46
- function makeOptions(overrides?: Partial<HarnessRunOptions>): HarnessRunOptions {
47
- return {
48
- harness: "claude",
49
- prompt: "test prompt",
50
- cwd: "/tmp",
51
- ...overrides,
52
- };
53
- }
54
-
55
- describe("runHarnessTask", () => {
56
- it("returns finalMessage/usage/costUsd/sessionId from parsed events", async () => {
57
- const events = [
58
- { type: "result", message: "hello", usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, costUsd: 0.05, sessionId: "sess-1" },
59
- ];
60
- const fake = fakeSubprocess({
61
- events,
62
- stdout: "",
63
- stderr: "",
64
- exitCode: 0,
65
- timedOut: false,
66
- });
67
-
68
- const result = await runHarnessTask(makeOptions(), {
69
- runJsonlSubprocess: fake,
70
- descriptors: fakeDescriptors,
71
- });
72
-
73
- expect(result.finalMessage).toBe("hello");
74
- expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5, totalTokens: 15 });
75
- expect(result.costUsd).toBe(0.05);
76
- expect(result.sessionId).toBe("sess-1");
77
- expect(result.exitCode).toBe(0);
78
- });
79
-
80
- it("throws error containing stderr on non-zero exitCode", async () => {
81
- const fake = fakeSubprocess({
82
- events: [],
83
- stdout: "",
84
- stderr: "something went wrong",
85
- exitCode: 1,
86
- timedOut: false,
87
- });
88
-
89
- await expect(
90
- runHarnessTask(makeOptions(), { runJsonlSubprocess: fake, descriptors: fakeDescriptors }),
91
- ).rejects.toThrow('exited with code 1: something went wrong');
92
- });
93
-
94
- it("throws timeout error when timedOut is true", async () => {
95
- const fake = fakeSubprocess({
96
- events: [],
97
- stdout: "",
98
- stderr: "killed",
99
- exitCode: -1,
100
- timedOut: true,
101
- });
102
-
103
- await expect(
104
- runHarnessTask(makeOptions({ timeoutMs: 100 }), {
105
- runJsonlSubprocess: fake,
106
- descriptors: fakeDescriptors,
107
- }),
108
- ).rejects.toThrow("timed out after 100ms");
109
- });
110
-
111
- it("calls onEvent for each parsed event", async () => {
112
- const events = [
113
- { type: "text", text: "hi" },
114
- { type: "result", message: "done" },
115
- ];
116
- const fake = fakeSubprocess({
117
- events,
118
- stdout: "",
119
- stderr: "",
120
- exitCode: 0,
121
- timedOut: false,
122
- });
123
-
124
- const onEvent = mock(() => {});
125
- await runHarnessTask(makeOptions({ onEvent }), {
126
- runJsonlSubprocess: fake,
127
- descriptors: fakeDescriptors,
128
- });
129
-
130
- expect(onEvent).toHaveBeenCalledTimes(2);
131
- });
132
- });
133
-
134
- describe("isHarnessAvailable", () => {
135
- it("returns true when spawnSync exit code is 0", async () => {
136
- const originalSpawnSync = Bun.spawnSync;
137
- (Bun as any).spawnSync = mock(() => ({ exitCode: 0 }));
138
-
139
- try {
140
- expect(await isHarnessAvailable("claude", fakeDescriptors)).toBe(true);
141
- } finally {
142
- (Bun as any).spawnSync = originalSpawnSync;
143
- }
144
- });
145
-
146
- it("returns false when spawnSync exit code is non-zero", async () => {
147
- const originalSpawnSync = Bun.spawnSync;
148
- (Bun as any).spawnSync = mock(() => ({ exitCode: 1 }));
149
-
150
- try {
151
- expect(await isHarnessAvailable("claude", fakeDescriptors)).toBe(false);
152
- } finally {
153
- (Bun as any).spawnSync = originalSpawnSync;
154
- }
155
- });
156
- });
157
-
158
- describe("runHarnessTask binary resolution", () => {
159
- it("spawns the resolved absolute binary with a healed PATH", async () => {
160
- let captured: { argv: string[]; env: Record<string, string> } | undefined;
161
- const fake = mock(async (args: { argv: string[]; env: Record<string, string> }) => {
162
- captured = args;
163
- return { events: [], stdout: "", stderr: "", exitCode: 0, timedOut: false };
164
- });
165
-
166
- await runHarnessTask(makeOptions(), {
167
- runJsonlSubprocess: fake,
168
- descriptors: fakeDescriptors,
169
- resolveBinary: () => "/abs/bin/claude",
170
- });
171
-
172
- expect(captured?.argv[0]).toBe("/abs/bin/claude");
173
- expect(captured?.argv.slice(1)).toEqual(["-p", "test prompt"]);
174
- expect(typeof captured?.env.PATH).toBe("string");
175
- expect((captured?.env.PATH ?? "").length).toBeGreaterThan(0);
176
- });
177
-
178
- it("throws a clear not-found error when the CLI is missing", async () => {
179
- const enoent = mock(async () => {
180
- const err = new Error("ENOENT: no such file or directory, posix_spawn 'claude'") as Error & { code?: string };
181
- err.code = "ENOENT";
182
- throw err;
183
- });
184
-
185
- await expect(
186
- runHarnessTask(makeOptions(), {
187
- runJsonlSubprocess: enoent,
188
- descriptors: fakeDescriptors,
189
- resolveBinary: () => null,
190
- }),
191
- ).rejects.toThrow(/CLI "claude" not found[\s\S]*POP_CLAUDE_BIN/);
192
- });
193
- });