@markjaquith/agency 3.2.13 → 3.2.15

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 (69) hide show
  1. package/package.json +7 -2
  2. package/src/workbase/schemas.ts +12 -0
  3. package/fixtures/protocol/orchestration-recipes.json +0 -53
  4. package/pi-extensions/agency.test.ts +0 -117
  5. package/src/check-commits.test.ts +0 -58
  6. package/src/cli-parser.test.ts +0 -945
  7. package/src/cli.test.ts +0 -1889
  8. package/src/commands/act.test.ts +0 -418
  9. package/src/commands/archive.test.ts +0 -236
  10. package/src/commands/context.test.ts +0 -665
  11. package/src/commands/description.test.ts +0 -103
  12. package/src/commands/doctor.test.ts +0 -185
  13. package/src/commands/epic.test.ts +0 -196
  14. package/src/commands/init.test.ts +0 -117
  15. package/src/commands/integration.test.ts +0 -165
  16. package/src/commands/pr.test.ts +0 -248
  17. package/src/commands/push.test.ts +0 -56
  18. package/src/commands/read-only.test.ts +0 -161
  19. package/src/commands/repo.test.ts +0 -199
  20. package/src/commands/restore.test.ts +0 -106
  21. package/src/commands/status.test.ts +0 -113
  22. package/src/commands/sync.test.ts +0 -200
  23. package/src/commands/task-phase.test.ts +0 -410
  24. package/src/commands/task.test.ts +0 -281
  25. package/src/commands/validate.test.ts +0 -186
  26. package/src/commands/work.test.ts +0 -1375
  27. package/src/commands/workbase.test.ts +0 -170
  28. package/src/graph-schema.test.ts +0 -124
  29. package/src/protocol.test.ts +0 -163
  30. package/src/readiness.test.ts +0 -105
  31. package/src/services/ArchiveBulkService.test.ts +0 -384
  32. package/src/services/ArchiveService.test.ts +0 -1092
  33. package/src/services/EpicService.test.ts +0 -74
  34. package/src/services/FileSystemService.test.ts +0 -81
  35. package/src/services/GraphMutationService.test.ts +0 -486
  36. package/src/services/GraphService.test.ts +0 -494
  37. package/src/services/IntegrationService.test.ts +0 -1091
  38. package/src/services/LifecycleTransaction.test.ts +0 -145
  39. package/src/services/PullRequestService.test.ts +0 -716
  40. package/src/services/PushService.test.ts +0 -408
  41. package/src/services/ReadinessService.test.ts +0 -290
  42. package/src/services/RepositoryService.test.ts +0 -789
  43. package/src/services/ReviewService.test.ts +0 -408
  44. package/src/services/SyncService.test.ts +0 -1377
  45. package/src/services/TaskPhaseService.test.ts +0 -852
  46. package/src/services/VersionControlService.test.ts +0 -62
  47. package/src/services/WorkbaseService.test.ts +0 -910
  48. package/src/services/WorktreeLock.test.ts +0 -205
  49. package/src/services/WorktreePerformance.test.ts +0 -71
  50. package/src/services/WorktreeService.test.ts +0 -2292
  51. package/src/test-utils.ts +0 -110
  52. package/src/usage-log.test.ts +0 -188
  53. package/src/utils/chooser.test.ts +0 -192
  54. package/src/utils/effect.test.ts +0 -30
  55. package/src/utils/interactive.pty.test.ts +0 -128
  56. package/src/utils/interactive.test.tsx +0 -860
  57. package/src/utils/process.test.ts +0 -132
  58. package/src/utils/progress.test.ts +0 -37
  59. package/src/work-view.test.ts +0 -151
  60. package/src/workbase/agent-command.test.ts +0 -128
  61. package/src/workbase/checkout-command.test.ts +0 -62
  62. package/src/workbase/delivery-command.test.ts +0 -180
  63. package/src/workbase/dependency-graph.test.ts +0 -50
  64. package/src/workbase/execution-contract.test.ts +0 -161
  65. package/src/workbase/frontmatter.test.ts +0 -67
  66. package/src/workbase/repository-reference.test.ts +0 -25
  67. package/src/workbase/schemas.test.ts +0 -543
  68. package/src/workbase/work-target.test.ts +0 -108
  69. package/src/workbase/worktree-command.test.ts +0 -61
@@ -1,1375 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { Effect } from "effect"
3
- import { FileSystemService } from "../services/FileSystemService"
4
- import { WorkbaseService } from "../services/WorkbaseService"
5
- import { EpicService } from "../services/EpicService"
6
- import { TaskService } from "../services/TaskService"
7
- import { PhaseService } from "../services/PhaseService"
8
- import { WorktreeService } from "../services/WorktreeService"
9
- import { ReadinessService } from "../services/ReadinessService"
10
- import { IntegrationService } from "../services/IntegrationService"
11
- import { captureErrors, captureLogs } from "../test-utils"
12
- import { work, workPrepare } from "./work"
13
- import type { PickWorkTarget } from "../workbase/work-target"
14
- import type { PickWorkbase } from "../workbase/workbase-choice"
15
- import type { Progress } from "../utils/progress"
16
-
17
- type ExecutionWorkspace = Effect.Effect.Success<
18
- ReturnType<WorktreeService["materialize"]>
19
- >
20
-
21
- const singlePhaseWorkspace: ExecutionWorkspace = {
22
- root: "/workbase",
23
- taskPath: "/workbase/tasks/example/TASK.md",
24
- phasePath: null,
25
- codePath: "/workbase/tasks/example/code",
26
- writablePath: "/workbase/tasks/example/code/agency",
27
- reviewPath: null,
28
- repo: "agency",
29
- repos: [],
30
- dryRun: false,
31
- checkouts: [],
32
- operations: [],
33
- }
34
-
35
- const multiPhaseWorkspace: ExecutionWorkspace = {
36
- root: "/workbase",
37
- taskPath: "/workbase/tasks/example/TASK.md",
38
- phasePath: "/workbase/tasks/example/phases/implementation/PHASE.md",
39
- codePath: "/workbase/tasks/example/phases/implementation/code",
40
- writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
41
- reviewPath: null,
42
- repo: "agency",
43
- repos: [],
44
- dryRun: false,
45
- checkouts: [],
46
- operations: [],
47
- }
48
-
49
- const taskDirectory = "/workbase/tasks/example"
50
- const phaseDirectory = `${taskDirectory}/phases/implementation`
51
-
52
- interface HarnessOptions {
53
- readonly workspace?: ExecutionWorkspace
54
- readonly materializeError?: Error
55
- readonly available?: Readonly<Record<string, boolean>>
56
- readonly chooserCommand?: readonly string[]
57
- readonly agent?: "opencode2" | "opencode" | "pi" | "claude"
58
- readonly agents?: Record<
59
- string,
60
- {
61
- command: readonly [string, ...string[]]
62
- autoCommand?: readonly [string, ...string[]]
63
- resumeCommand?: readonly [string, ...string[]]
64
- autoResumeCommand?: readonly [string, ...string[]]
65
- environment?: Record<string, string>
66
- }
67
- >
68
- readonly multiPhaseTasks?: readonly string[]
69
- readonly epicRecords?: readonly any[]
70
- readonly taskRecords?: readonly any[]
71
- readonly phaseRecords?: readonly any[]
72
- readonly outsideWorkbase?: boolean
73
- readonly registeredWorkbases?: readonly string[]
74
- readonly existingDirectories?: readonly string[]
75
- readonly guardError?: Error
76
- readonly launchError?: Error
77
- readonly workTargetIds?: readonly string[]
78
- readonly taskStatus?: "open" | "working" | "delegated" | "done" | "dropped"
79
- readonly phaseStatus?: "open" | "working" | "delegated" | "done" | "dropped"
80
- readonly taskStatuses?: Readonly<
81
- Record<string, "open" | "working" | "delegated" | "done" | "dropped">
82
- >
83
- readonly phaseStatuses?: Readonly<
84
- Record<string, "open" | "working" | "delegated" | "done" | "dropped">
85
- >
86
- readonly taskRepo?: string
87
- readonly existingPaths?: readonly string[]
88
- readonly validationIssues?: readonly { path: string; message: string }[]
89
- }
90
-
91
- const createHarness = (options: HarnessOptions = {}) => {
92
- const events: string[] = []
93
- const probes: string[] = []
94
- const statusUpdates: string[] = []
95
- const shownTasks: string[] = []
96
- const progressUpdates: string[] = []
97
- let integrationSyncs = 0
98
- const guards: Array<{ target: string; override?: boolean }> = []
99
- const launches: Array<{
100
- cli: string
101
- args: readonly string[]
102
- cwd: string
103
- }> = []
104
- const launchEnvironments: Array<Readonly<Record<string, string>>> = []
105
- const launchProcessEnvironments: Array<Record<string, string | undefined>> =
106
- []
107
- const materializeOptions: Array<
108
- Parameters<WorktreeService["materialize"]>[3]
109
- > = []
110
- const taskStatuses = { ...options.taskStatuses }
111
- const phaseStatuses = { ...options.phaseStatuses }
112
- const worktrees = {
113
- materialize: (
114
- _taskId: string,
115
- _phaseId?: string,
116
- _root?: string,
117
- commandOptions?: Parameters<WorktreeService["materialize"]>[3],
118
- ) => {
119
- events.push("materialize")
120
- materializeOptions.push(commandOptions)
121
- return options.materializeError
122
- ? Effect.fail(options.materializeError)
123
- : Effect.succeed(options.workspace ?? singlePhaseWorkspace)
124
- },
125
- }
126
- const workbase = {
127
- loadGlobalConfig: () => Effect.succeed({ agent: options.agent }),
128
- discover: (path: string) =>
129
- options.outsideWorkbase && path === "/outside"
130
- ? Effect.fail({
131
- _tag: "WorkbaseNotFoundError" as const,
132
- message: "No Agency workbase found from /outside",
133
- })
134
- : Effect.succeed("/workbase"),
135
- listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
136
- getDefault: () => Effect.succeed(undefined),
137
- loadConfig: () =>
138
- Effect.succeed({
139
- root: "/workbase",
140
- config: {
141
- version: 2 as const,
142
- chooserCommand: options.chooserCommand,
143
- agents: options.agents,
144
- },
145
- }),
146
- repositoryAliases: () => Effect.succeed(["agency"]),
147
- validate: () => {
148
- const issues = options.validationIssues ?? []
149
- return Effect.succeed({
150
- root: "/workbase",
151
- issues,
152
- epicCount: 0,
153
- taskCount: 1,
154
- phaseCount: 0,
155
- valid: issues.length === 0,
156
- })
157
- },
158
- }
159
- const epics = {
160
- show: (id: string) =>
161
- Effect.succeed({
162
- id,
163
- path: `/workbase/epics/${id}/EPIC.md`,
164
- data: { tasks: [] },
165
- }),
166
- list: () => Effect.succeed(options.epicRecords ?? []),
167
- }
168
- const tasks = {
169
- show: (id: string) => {
170
- shownTasks.push(id)
171
- return Effect.succeed({
172
- id,
173
- path: `/workbase/tasks/${id}/TASK.md`,
174
- revision: "a".repeat(64),
175
- data: options.multiPhaseTasks?.includes(id)
176
- ? { phases: [] }
177
- : {
178
- repo: options.taskRepo ?? "agency",
179
- branch: `task/${id}`,
180
- base: "main",
181
- status: taskStatuses[id] ?? options.taskStatus ?? "open",
182
- },
183
- })
184
- },
185
- list: () => Effect.succeed(options.taskRecords ?? []),
186
- setStatus: (id: string, status: string) => {
187
- statusUpdates.push(`task:${id}:${status}`)
188
- taskStatuses[id] = status as
189
- | "open"
190
- | "working"
191
- | "delegated"
192
- | "done"
193
- | "dropped"
194
- return Effect.void
195
- },
196
- }
197
- const phases = {
198
- show: (taskId: string, id: string) =>
199
- Effect.succeed({
200
- taskId,
201
- id,
202
- path: `/workbase/tasks/${taskId}/phases/${id}/PHASE.md`,
203
- revision: "b".repeat(64),
204
- data: {
205
- repo: "agency",
206
- branch: `task/${id}`,
207
- base: "main",
208
- status:
209
- phaseStatuses[`${taskId}/${id}`] ?? options.phaseStatus ?? "open",
210
- },
211
- }),
212
- list: () => Effect.succeed(options.phaseRecords ?? []),
213
- setStatus: (taskId: string, id: string, status: string) => {
214
- statusUpdates.push(`phase:${taskId}:${id}:${status}`)
215
- phaseStatuses[`${taskId}/${id}`] = status as
216
- | "open"
217
- | "working"
218
- | "delegated"
219
- | "done"
220
- | "dropped"
221
- return Effect.void
222
- },
223
- }
224
- const readiness = {
225
- getWorkTargetIds: () =>
226
- Effect.succeed(
227
- new Set(
228
- options.workTargetIds ?? [
229
- ...(options.epicRecords ?? []).map(
230
- (record: any) => `epic:${record.id}`,
231
- ),
232
- ...(options.taskRecords ?? []).map((record: any) =>
233
- "phases" in record.data
234
- ? `task:${record.id}`
235
- : `execution-unit:task/${record.id}`,
236
- ),
237
- ...(options.phaseRecords ?? []).map(
238
- (record: any) =>
239
- `execution-unit:phase/${record.taskId}/${record.id}`,
240
- ),
241
- ],
242
- ),
243
- ),
244
- guardWorkTarget: (target: string, _root: string, override?: boolean) => {
245
- if (options.guardError || override) events.push("guard")
246
- guards.push({ target, override })
247
- return options.guardError && !override
248
- ? Effect.fail(options.guardError)
249
- : Effect.void
250
- },
251
- }
252
- const integrations = {
253
- sync: () => {
254
- integrationSyncs += 1
255
- return Effect.succeed({
256
- root: "/workbase",
257
- files: [
258
- {
259
- name: "opencode",
260
- state: "managed",
261
- },
262
- ],
263
- })
264
- },
265
- syncRoot: () => {
266
- integrationSyncs += 1
267
- return Effect.succeed({
268
- root: "/workbase",
269
- files: [
270
- {
271
- name: "opencode",
272
- state: "managed",
273
- },
274
- ],
275
- })
276
- },
277
- }
278
- const fs = {
279
- isDirectory: (path: string) =>
280
- Effect.succeed(
281
- path === "/workbase/epics" || path === "/workbase/tasks"
282
- ? false
283
- : (options.existingDirectories?.includes(path) ?? true),
284
- ),
285
- readFile: (path: string) =>
286
- Effect.succeed(path.endsWith("agency.json") ? '{"version":2}\n' : ""),
287
- readDirectory: () => Effect.succeed([]),
288
- exists: (path: string) =>
289
- Effect.succeed(options.existingPaths?.includes(path) ?? false),
290
- realPath: (path: string) => Effect.succeed(path),
291
- runCommand: (args: readonly string[]) => {
292
- const cli = args[1]!
293
- events.push(`probe:${cli}`)
294
- probes.push(cli)
295
- return Effect.succeed({
296
- exitCode: options.available?.[cli] === false ? 1 : 0,
297
- stdout: "",
298
- stderr: "",
299
- })
300
- },
301
- }
302
- const launch = (
303
- cli: string,
304
- args: readonly string[],
305
- cwd: string,
306
- environment: Readonly<Record<string, string>>,
307
- ) => {
308
- events.push(`launch:${cli}`)
309
- launches.push({ cli, args, cwd })
310
- launchEnvironments.push(environment)
311
- launchProcessEnvironments.push({
312
- AGENCY_SESSION_ID: process.env.AGENCY_SESSION_ID,
313
- AGENCY_TARGET: process.env.AGENCY_TARGET,
314
- AGENCY_PROMPT: process.env.AGENCY_PROMPT,
315
- })
316
- if (options.launchError) throw options.launchError
317
- }
318
- const defaultPick: PickWorkTarget = () => Effect.succeed(null)
319
- const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
320
- const progress: Progress = {
321
- start: (message) => progressUpdates.push(`start:${message}`),
322
- succeed: (message) => progressUpdates.push(`succeed:${message}`),
323
- fail: (message) => progressUpdates.push(`fail:${message}`),
324
- }
325
- const run = (
326
- commandOptions: Parameters<typeof work>[0],
327
- pick: PickWorkTarget = defaultPick,
328
- pickBase: PickWorkbase = defaultPickWorkbase,
329
- preserveSession = false,
330
- ) => {
331
- const agencySessionId = process.env.AGENCY_SESSION_ID
332
- if (!preserveSession) delete process.env.AGENCY_SESSION_ID
333
- return Effect.runPromise(
334
- work(commandOptions, launch, pick, progress, pickBase).pipe(
335
- Effect.provideService(WorktreeService, worktrees as never),
336
- Effect.provideService(FileSystemService, fs as never),
337
- Effect.provideService(WorkbaseService, workbase as never),
338
- Effect.provideService(EpicService, epics as never),
339
- Effect.provideService(TaskService, tasks as never),
340
- Effect.provideService(PhaseService, phases as never),
341
- Effect.provideService(ReadinessService, readiness as never),
342
- Effect.provideService(IntegrationService, integrations as never),
343
- ) as Effect.Effect<void, unknown, never>,
344
- ).finally(() => {
345
- if (agencySessionId !== undefined) {
346
- process.env.AGENCY_SESSION_ID = agencySessionId
347
- }
348
- })
349
- }
350
- const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
351
- Effect.runPromise(
352
- workPrepare(commandOptions).pipe(
353
- Effect.provideService(WorktreeService, worktrees as never),
354
- Effect.provideService(FileSystemService, fs as never),
355
- Effect.provideService(WorkbaseService, workbase as never),
356
- Effect.provideService(TaskService, tasks as never),
357
- Effect.provideService(PhaseService, phases as never),
358
- Effect.provideService(ReadinessService, readiness as never),
359
- ) as Effect.Effect<void, unknown, never>,
360
- )
361
-
362
- return {
363
- events,
364
- probes,
365
- launches,
366
- launchEnvironments,
367
- launchProcessEnvironments,
368
- materializeOptions,
369
- statusUpdates,
370
- taskStatuses,
371
- phaseStatuses,
372
- shownTasks,
373
- progressUpdates,
374
- guards,
375
- get integrationSyncs() {
376
- return integrationSyncs
377
- },
378
- run,
379
- runPrepare,
380
- }
381
- }
382
-
383
- describe("work command", () => {
384
- test("reconciles managed integration files before preparing work", async () => {
385
- const harness = createHarness()
386
-
387
- await harness.run({ taskId: "example", opencode: true })
388
-
389
- expect(harness.integrationSyncs).toBe(1)
390
- })
391
-
392
- test("guards execution targets before materialization and honors --force", async () => {
393
- const blocked = createHarness({ guardError: new Error("blocked") })
394
- await expect(
395
- blocked.run({ taskId: "example", opencode: true }),
396
- ).rejects.toThrow("blocked")
397
- expect(blocked.events).toEqual(["guard"])
398
- expect(blocked.guards).toEqual([
399
- { target: "execution-unit:task/example", override: undefined },
400
- ])
401
-
402
- const forced = createHarness({ guardError: new Error("blocked") })
403
- await forced.run({ taskId: "example", opencode: true, force: true })
404
- expect(forced.events).toEqual([
405
- "guard",
406
- "materialize",
407
- "probe:opencode",
408
- "launch:opencode",
409
- ])
410
- expect(forced.guards[0]).toEqual({
411
- target: "execution-unit:task/example",
412
- override: true,
413
- })
414
- expect(forced.materializeOptions[0]?.validationAlreadyPerformed).toBe(false)
415
- })
416
-
417
- test("reuses successful readiness validation during materialization", async () => {
418
- const harness = createHarness()
419
-
420
- await harness.run({ taskId: "example", opencode: true })
421
-
422
- expect(harness.materializeOptions[0]?.validationAlreadyPerformed).toBe(true)
423
- })
424
-
425
- test("reopens forced terminal tasks through open before launching as working", async () => {
426
- for (const previousStatus of ["done", "dropped"] as const) {
427
- const harness = createHarness({
428
- taskStatuses: { example: previousStatus },
429
- })
430
- const output = await captureLogs(() =>
431
- harness.run({ taskId: "example", opencode: true, force: true }),
432
- )
433
-
434
- expect(harness.statusUpdates).toEqual([
435
- "task:example:open",
436
- "task:example:working",
437
- ])
438
- expect(harness.taskStatuses.example).toBe("working")
439
- expect(output).toEqual([
440
- `Reopened task/example from ${previousStatus} as working`,
441
- ])
442
- }
443
- })
444
-
445
- test("reopens forced terminal phases through open before launching as working", async () => {
446
- for (const previousStatus of ["done", "dropped"] as const) {
447
- const harness = createHarness({
448
- workspace: multiPhaseWorkspace,
449
- multiPhaseTasks: ["example"],
450
- phaseStatuses: { "example/implementation": previousStatus },
451
- })
452
- const output = await captureLogs(() =>
453
- harness.run({
454
- taskId: "example",
455
- phaseId: "implementation",
456
- opencode: true,
457
- force: true,
458
- }),
459
- )
460
-
461
- expect(harness.statusUpdates).toEqual([
462
- "phase:example:implementation:open",
463
- "phase:example:implementation:working",
464
- ])
465
- expect(harness.phaseStatuses["example/implementation"]).toBe("working")
466
- expect(output).toEqual([
467
- `Reopened phase/example/implementation from ${previousStatus} as working`,
468
- ])
469
- }
470
- })
471
-
472
- test("reports a forced reopen as one machine result", async () => {
473
- const harness = createHarness({ taskStatuses: { example: "done" } })
474
- const output = await captureLogs(() =>
475
- harness.run({
476
- taskId: "example",
477
- opencode: true,
478
- force: true,
479
- json: true,
480
- }),
481
- )
482
-
483
- expect(JSON.parse(output.join("\n"))).toEqual({
484
- target: "task/example",
485
- reopened: true,
486
- previousStatus: "done",
487
- status: "working",
488
- })
489
- })
490
-
491
- test("offers only launchable targets to the interactive chooser", async () => {
492
- const harness = createHarness({
493
- taskRecords: [
494
- {
495
- id: "ready",
496
- path: "/workbase/tasks/ready/TASK.md",
497
- data: { status: "open" },
498
- },
499
- {
500
- id: "blocked",
501
- path: "/workbase/tasks/blocked/TASK.md",
502
- data: { status: "open" },
503
- },
504
- ],
505
- workTargetIds: ["execution-unit:task/ready"],
506
- })
507
- let labels: readonly string[] = []
508
- const pick: PickWorkTarget = (choices) => {
509
- labels = choices.map((choice) => choice.plainLabel)
510
- return Effect.succeed(null)
511
- }
512
-
513
- await harness.run({ cwd: "/workbase" }, pick)
514
- expect(labels).toEqual(["[open] task ready"])
515
- })
516
-
517
- test("prepares without launching or changing lifecycle status", async () => {
518
- const harness = createHarness({ existingDirectories: [] })
519
-
520
- const [output] = await captureLogs(() =>
521
- harness.runPrepare({
522
- cwd: "/workbase",
523
- directory: "example",
524
- json: true,
525
- dryRun: true,
526
- }),
527
- )
528
-
529
- expect(harness.events).toEqual(["materialize"])
530
- expect(harness.launches).toEqual([])
531
- expect(harness.statusUpdates).toEqual([])
532
- expect(harness.materializeOptions[0]).toMatchObject({
533
- json: true,
534
- dryRun: true,
535
- validationAlreadyPerformed: true,
536
- })
537
- const result = JSON.parse(output!)
538
- expect(result.validationEvidence.status).toBe("refreshed")
539
- expect(result.validationEvidence.reasons).toEqual(["not-supplied"])
540
- expect(result.execution).toMatchObject({
541
- capability: "agency-execution-v1",
542
- mode: "preview",
543
- executionIdentity: { target: "execution-unit:task/example" },
544
- workspace: { state: "planned" },
545
- commands: {
546
- work: { argv: ["agency", "work", ".", "--auto"] },
547
- },
548
- })
549
-
550
- const [reusedOutput] = await captureLogs(() =>
551
- harness.runPrepare({
552
- cwd: "/workbase",
553
- directory: "example",
554
- json: true,
555
- dryRun: true,
556
- evidence: JSON.stringify(result.validationEvidence.evidence),
557
- }),
558
- )
559
- expect(JSON.parse(reusedOutput!).validationEvidence).toEqual(
560
- expect.objectContaining({ status: "reused", reasons: [] }),
561
- )
562
-
563
- const conflicting = createHarness({
564
- existingDirectories: [],
565
- taskRepo: "other",
566
- })
567
- await expect(
568
- conflicting.runPrepare({
569
- cwd: "/workbase",
570
- directory: "example",
571
- dryRun: true,
572
- evidence: JSON.stringify(result.validationEvidence.evidence),
573
- }),
574
- ).rejects.toThrow("Recalled repository conflicts")
575
- })
576
-
577
- test("resolves task and phase document paths", async () => {
578
- const taskDocument = "/workbase/tasks/example/TASK.md"
579
- const taskHarness = createHarness({
580
- existingDirectories: [],
581
- existingPaths: [taskDocument],
582
- })
583
-
584
- await taskHarness.runPrepare({
585
- directory: taskDocument,
586
- dryRun: true,
587
- silent: true,
588
- })
589
- expect(taskHarness.guards[0]?.target).toBe("execution-unit:task/example")
590
- expect(taskHarness.shownTasks).toEqual(["example", "example"])
591
-
592
- const phaseDocument =
593
- "/workbase/tasks/example/phases/implementation/PHASE.md"
594
- const phaseHarness = createHarness({
595
- existingDirectories: [],
596
- existingPaths: [phaseDocument],
597
- multiPhaseTasks: ["example"],
598
- workspace: multiPhaseWorkspace,
599
- })
600
-
601
- await phaseHarness.runPrepare({
602
- directory: phaseDocument,
603
- dryRun: true,
604
- silent: true,
605
- })
606
- expect(phaseHarness.guards[0]?.target).toBe(
607
- "execution-unit:phase/example/implementation",
608
- )
609
- expect(phaseHarness.materializeOptions).toHaveLength(1)
610
- })
611
-
612
- test("returns structured validation issues before readiness or materialization", async () => {
613
- const harness = createHarness({
614
- existingDirectories: [],
615
- validationIssues: [
616
- {
617
- path: "tasks/example/TASK.md",
618
- message: "Repository alias 'missing' is not configured",
619
- },
620
- ],
621
- })
622
-
623
- await expect(
624
- harness.runPrepare({
625
- cwd: "/workbase",
626
- directory: "example",
627
- dryRun: true,
628
- }),
629
- ).rejects.toThrow(
630
- "Workbase validation failed with 1 issue:\n- tasks/example/TASK.md: Repository alias 'missing' is not configured",
631
- )
632
- expect(harness.guards).toEqual([])
633
- expect(harness.events).toEqual([])
634
- })
635
-
636
- test("force prepares a phase blocked by an active dependency without launching or changing status", async () => {
637
- const blocked = createHarness({
638
- workspace: multiPhaseWorkspace,
639
- multiPhaseTasks: ["example"],
640
- guardError: new Error("Phase dependency is working"),
641
- })
642
- await expect(
643
- blocked.runPrepare({
644
- cwd: "/workbase",
645
- taskId: "example",
646
- phaseId: "implementation",
647
- }),
648
- ).rejects.toThrow("Phase dependency is working")
649
- expect(blocked.events).toEqual(["guard"])
650
-
651
- const forced = createHarness({
652
- workspace: multiPhaseWorkspace,
653
- multiPhaseTasks: ["example"],
654
- guardError: new Error("Phase dependency is working"),
655
- })
656
- await forced.runPrepare({
657
- cwd: "/workbase",
658
- taskId: "example",
659
- phaseId: "implementation",
660
- force: true,
661
- silent: true,
662
- })
663
-
664
- expect(forced.guards).toEqual([
665
- {
666
- target: "execution-unit:phase/example/implementation",
667
- override: true,
668
- },
669
- ])
670
- expect(forced.events).toEqual(["guard", "materialize"])
671
- expect(forced.materializeOptions[0]).toMatchObject({
672
- force: true,
673
- validationAlreadyPerformed: true,
674
- })
675
- expect(forced.launches).toEqual([])
676
- expect(forced.statusUpdates).toEqual([])
677
- })
678
-
679
- test("launches an epic agent from an epic directory", async () => {
680
- const harness = createHarness()
681
-
682
- await harness.run({
683
- cwd: "/workbase/epics/delivery",
684
- directory: ".",
685
- opencode: true,
686
- auto: true,
687
- })
688
-
689
- expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
690
- expect(harness.launches[0]).toEqual({
691
- cli: "opencode",
692
- args: [
693
- "opencode",
694
- "--prompt",
695
- "Agency worker launch target: epic:delivery. Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
696
- ],
697
- cwd: "/workbase/epics/delivery",
698
- })
699
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
700
- expect(
701
- harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT,
702
- ).toBeUndefined()
703
- })
704
-
705
- test("resolves an existing positional path before treating it as a task ID", async () => {
706
- const harness = createHarness({
707
- existingDirectories: ["/workbase/tasks/delivery"],
708
- })
709
-
710
- await harness.run({
711
- cwd: "/workbase/tasks/delivery",
712
- directory: ".",
713
- opencode: true,
714
- })
715
-
716
- expect(harness.shownTasks).toEqual(["delivery", "delivery"])
717
- expect(harness.launches[0]?.cwd).toBe(taskDirectory)
718
- })
719
-
720
- test("treats a positional value as a task ID when it is not a directory", async () => {
721
- const harness = createHarness({ existingDirectories: [] })
722
-
723
- await harness.run({
724
- cwd: "/workbase",
725
- directory: "delivery",
726
- opencode: true,
727
- })
728
-
729
- expect(harness.shownTasks).toEqual(["delivery", "delivery"])
730
- expect(harness.launches[0]?.cwd).toBe(taskDirectory)
731
- })
732
-
733
- test("launches a multi-phase task agent without materializing", async () => {
734
- const harness = createHarness({ multiPhaseTasks: ["delivery"] })
735
-
736
- await harness.run({
737
- cwd: "/workbase/tasks/delivery",
738
- directory: ".",
739
- opencode: true,
740
- auto: true,
741
- })
742
-
743
- expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
744
- expect(harness.launches[0]).toEqual({
745
- cli: "opencode",
746
- args: [
747
- "opencode",
748
- "--prompt",
749
- "Agency worker launch target: task:delivery. Work on the task. Read /workbase/tasks/delivery/TASK.md.",
750
- ],
751
- cwd: "/workbase/tasks/delivery",
752
- })
753
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
754
- })
755
-
756
- test("infers a phase from a nested checkout directory", async () => {
757
- const harness = createHarness({ workspace: multiPhaseWorkspace })
758
-
759
- await harness.run({
760
- cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
761
- directory: ".",
762
- opencode: true,
763
- auto: true,
764
- })
765
-
766
- expect(harness.events).toEqual([
767
- "materialize",
768
- "probe:opencode",
769
- "launch:opencode",
770
- ])
771
- expect(harness.launches[0]).toEqual({
772
- cli: "opencode",
773
- args: [
774
- "opencode",
775
- "--prompt",
776
- "Agency worker launch target: execution-unit:phase/example/implementation. Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
777
- ],
778
- cwd: phaseDirectory,
779
- })
780
- expect(harness.statusUpdates).toEqual([
781
- "phase:example:implementation:working",
782
- ])
783
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
784
- })
785
-
786
- test("infers a single-phase task from a nested checkout directory", async () => {
787
- const harness = createHarness()
788
-
789
- await harness.run({
790
- cwd: "/workbase/tasks/example/code/agency/src",
791
- directory: ".",
792
- opencode: true,
793
- })
794
-
795
- expect(harness.events[0]).toBe("materialize")
796
- expect(harness.launches[0]?.cwd).toBe(taskDirectory)
797
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
798
- })
799
-
800
- test("selects a target when no directory is provided", async () => {
801
- const phase = {
802
- taskId: "delivery",
803
- id: "build",
804
- path: "/workbase/tasks/delivery/phases/build/PHASE.md",
805
- data: {},
806
- }
807
- const harness = createHarness({
808
- workspace: {
809
- ...multiPhaseWorkspace,
810
- taskPath: "/workbase/tasks/delivery/TASK.md",
811
- phasePath: "/workbase/tasks/delivery/phases/build/PHASE.md",
812
- },
813
- taskRecords: [
814
- {
815
- id: "delivery",
816
- path: "/workbase/tasks/delivery/TASK.md",
817
- data: { phases: [{ id: "build" }] },
818
- },
819
- ],
820
- phaseRecords: [phase],
821
- })
822
- const pick: PickWorkTarget = (choices) =>
823
- Effect.succeed(
824
- choices.find((choice) => choice.label.includes("build"))!.target,
825
- )
826
-
827
- await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
828
-
829
- expect(harness.events).toEqual([
830
- "materialize",
831
- "probe:opencode",
832
- "launch:opencode",
833
- ])
834
- expect(harness.launches[0]?.cwd).toBe(
835
- "/workbase/tasks/delivery/phases/build",
836
- )
837
- })
838
-
839
- test("requires an explicit target when input is disabled", async () => {
840
- const harness = createHarness()
841
-
842
- await expect(
843
- harness.run({
844
- cwd: "/workbase",
845
- opencode: true,
846
- inputAllowed: false,
847
- }),
848
- ).rejects.toThrow("provide a directory, task ID, or --epic")
849
- expect(harness.events).toEqual([])
850
- })
851
-
852
- test("runs an explicit target when input is disabled", async () => {
853
- const harness = createHarness({ existingDirectories: [] })
854
-
855
- await harness.run({
856
- cwd: "/workbase",
857
- directory: "example",
858
- opencode: true,
859
- inputAllowed: false,
860
- })
861
-
862
- expect(harness.events).toEqual([
863
- "materialize",
864
- "probe:opencode",
865
- "launch:opencode",
866
- ])
867
- })
868
-
869
- test("selects a registered workbase when local discovery fails", async () => {
870
- const harness = createHarness({
871
- outsideWorkbase: true,
872
- registeredWorkbases: ["/first", "/workbase"],
873
- })
874
- const selections: string[][] = []
875
- const pickBase: PickWorkbase = (workbases) => {
876
- selections.push([...workbases])
877
- return Effect.succeed("/workbase")
878
- }
879
-
880
- await harness.run(
881
- { cwd: "/outside", taskId: "example", opencode: true },
882
- undefined,
883
- pickBase,
884
- )
885
-
886
- expect(selections).toEqual([["/first", "/workbase"]])
887
- expect(harness.events).toEqual([
888
- "materialize",
889
- "probe:opencode",
890
- "launch:opencode",
891
- ])
892
- })
893
-
894
- test("does not select a registered workbase when input is disabled", async () => {
895
- const harness = createHarness({
896
- outsideWorkbase: true,
897
- registeredWorkbases: ["/workbase"],
898
- existingDirectories: [],
899
- })
900
-
901
- await expect(
902
- harness.run({
903
- cwd: "/outside",
904
- directory: "example",
905
- opencode: true,
906
- inputAllowed: false,
907
- }),
908
- ).rejects.toThrow("provide an explicit path or run Agency from a workbase")
909
- expect(harness.events).toEqual([])
910
- })
911
-
912
- test("explains how to register a workbase when none are known", async () => {
913
- const harness = createHarness({ outsideWorkbase: true })
914
-
915
- await expect(harness.run({ cwd: "/outside" })).rejects.toThrow(
916
- "agency workbase add <path>",
917
- )
918
- expect(harness.events).toEqual([])
919
- })
920
-
921
- test("passes the configured chooser command to the shared picker", async () => {
922
- const harness = createHarness({
923
- chooserCommand: ["gum", "filter"],
924
- epicRecords: [
925
- {
926
- id: "delivery",
927
- path: "/workbase/epics/delivery/EPIC.md",
928
- data: { tasks: [] },
929
- },
930
- ],
931
- })
932
- let command: readonly string[] | undefined
933
- const pick: PickWorkTarget = (_choices, chooserCommand) => {
934
- command = chooserCommand
935
- return Effect.succeed(null)
936
- }
937
-
938
- await harness.run({ cwd: "/workbase" }, pick)
939
-
940
- expect(command).toEqual(["gum", "filter"])
941
- expect(harness.launches).toEqual([])
942
- })
943
-
944
- test("rejects conflicting agent flags before materialization", async () => {
945
- const harness = createHarness()
946
-
947
- await expect(
948
- harness.run({ taskId: "example", opencode: true, claude: true }),
949
- ).rejects.toThrow("Cannot combine --agent, --opencode, and --claude")
950
- expect(harness.events).toEqual([])
951
- })
952
-
953
- test("rejects combining explicit epic and task targets", async () => {
954
- const harness = createHarness()
955
-
956
- await expect(
957
- harness.run({ epicId: "delivery", taskId: "example" }),
958
- ).rejects.toThrow("Cannot combine --epic")
959
- expect(harness.events).toEqual([])
960
- })
961
-
962
- test("launches OpenCode in the task directory with explicit context", async () => {
963
- const harness = createHarness()
964
-
965
- await harness.run({ taskId: "example", opencode: true })
966
-
967
- expect(harness.events).toEqual([
968
- "materialize",
969
- "probe:opencode",
970
- "launch:opencode",
971
- ])
972
- expect(harness.launches).toEqual([
973
- {
974
- cli: "opencode",
975
- args: ["opencode"],
976
- cwd: taskDirectory,
977
- },
978
- ])
979
- expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe("")
980
- expect(harness.statusUpdates).toEqual(["task:example:working"])
981
- expect(harness.launchEnvironments[0]?.OPENCODE_CONFIG).toBeUndefined()
982
- expect(harness.progressUpdates).toEqual([
983
- "start:Preparing workspace...",
984
- "succeed:Workspace ready",
985
- ])
986
- })
987
-
988
- test("launches OpenCode in the phase directory with explicit context", async () => {
989
- const harness = createHarness({ workspace: multiPhaseWorkspace })
990
-
991
- await harness.run({
992
- taskId: "example",
993
- phaseId: "implementation",
994
- opencode: true,
995
- })
996
-
997
- expect(harness.launches[0]?.cwd).toBe(phaseDirectory)
998
- })
999
-
1000
- test("sends the generated prompt only with --auto", async () => {
1001
- const harness = createHarness()
1002
-
1003
- await harness.run({ taskId: "example", opencode: true, auto: true })
1004
-
1005
- expect(harness.launches[0]?.args).toEqual([
1006
- "opencode",
1007
- "--prompt",
1008
- "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
1009
- ])
1010
- expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe(
1011
- "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
1012
- )
1013
- expect(harness.launchProcessEnvironments[0]).toEqual({
1014
- AGENCY_SESSION_ID: harness.launchEnvironments[0]?.AGENCY_SESSION_ID,
1015
- AGENCY_TARGET: "execution-unit:task/example",
1016
- AGENCY_PROMPT:
1017
- "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
1018
- })
1019
- })
1020
-
1021
- test("continues existing work with the resume command", async () => {
1022
- const harness = createHarness({ taskStatus: "working" })
1023
-
1024
- await harness.run({ taskId: "example", opencode: true, auto: true })
1025
-
1026
- expect(harness.launches[0]?.args).toEqual([
1027
- "opencode",
1028
- "--continue",
1029
- "--prompt",
1030
- "Agency worker launch target: execution-unit:task/example. Continue the task. Read /workbase/tasks/example/TASK.md.",
1031
- ])
1032
- expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe(
1033
- "Agency worker launch target: execution-unit:task/example. Continue the task. Read /workbase/tasks/example/TASK.md.",
1034
- )
1035
- })
1036
-
1037
- test("resumes OpenCode deterministically when a session identity exists", async () => {
1038
- const harness = createHarness({ workspace: multiPhaseWorkspace })
1039
- process.env.AGENCY_SESSION_ID = "existing-session"
1040
- try {
1041
- await harness.run(
1042
- {
1043
- taskId: "example",
1044
- phaseId: "implementation",
1045
- opencode: true,
1046
- auto: true,
1047
- },
1048
- undefined,
1049
- undefined,
1050
- true,
1051
- )
1052
- } finally {
1053
- delete process.env.AGENCY_SESSION_ID
1054
- }
1055
-
1056
- expect(harness.launches[0]).toEqual({
1057
- cli: "opencode",
1058
- args: [
1059
- "opencode",
1060
- "--continue",
1061
- "--prompt",
1062
- "Agency worker launch target: execution-unit:phase/example/implementation. Continue the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
1063
- ],
1064
- cwd: phaseDirectory,
1065
- })
1066
- })
1067
-
1068
- test("expands a named agent with shared context", async () => {
1069
- const harness = createHarness({
1070
- available: { codex: true },
1071
- agents: {
1072
- custom: {
1073
- command: ["codex"],
1074
- autoCommand: ["codex", "--task", "{task}", "{prompt}"],
1075
- environment: {
1076
- CUSTOM_TARGET: "{target}",
1077
- AGENCY_TARGET: "cannot-override",
1078
- },
1079
- },
1080
- },
1081
- })
1082
-
1083
- await harness.run({ taskId: "example", agent: "custom", auto: true })
1084
-
1085
- expect(harness.probes).toEqual(["codex"])
1086
- expect(harness.launches[0]).toEqual({
1087
- cli: "codex",
1088
- args: [
1089
- "codex",
1090
- "--task",
1091
- "example",
1092
- "Agency worker launch target: execution-unit:task/example. Start the task. Read /workbase/tasks/example/TASK.md.",
1093
- ],
1094
- cwd: taskDirectory,
1095
- })
1096
- expect(harness.launchEnvironments[0]).toMatchObject({
1097
- AGENCY_AGENT: "custom",
1098
- AGENCY_WORKBASE: "/workbase",
1099
- AGENCY_TARGET: "execution-unit:task/example",
1100
- AGENCY_TASK_ID: "example",
1101
- AGENCY_PHASE_ID: "",
1102
- CUSTOM_TARGET: "execution-unit:task/example",
1103
- })
1104
- })
1105
-
1106
- test("prints the exact command contract without launching and omits secrets", async () => {
1107
- const harness = createHarness({
1108
- available: { agent: true },
1109
- agents: {
1110
- custom: {
1111
- command: ["agent"],
1112
- autoCommand: ["agent", "{prompt}"],
1113
- environment: {
1114
- VISIBLE: "{task}",
1115
- API_TOKEN: "do-not-print",
1116
- },
1117
- },
1118
- },
1119
- })
1120
-
1121
- const output = await captureLogs(() =>
1122
- harness.run({
1123
- taskId: "example",
1124
- agent: "custom",
1125
- printCommand: true,
1126
- }),
1127
- )
1128
- const printed = JSON.parse(output.join("\n"))
1129
-
1130
- expect(harness.launches).toEqual([])
1131
- expect(harness.statusUpdates).toEqual([])
1132
- expect(printed.cwd).toBe(taskDirectory)
1133
- expect(printed.argv).toEqual(["agent"])
1134
- expect(printed.environment.AGENCY_PROMPT).toBe("")
1135
- expect(printed.environment.VISIBLE).toBe("example")
1136
- expect(printed.environment.API_TOKEN).toBeUndefined()
1137
- })
1138
-
1139
- test("prints the phase directory in the command contract", async () => {
1140
- const harness = createHarness({ workspace: multiPhaseWorkspace })
1141
- const output = await captureLogs(() =>
1142
- harness.run({
1143
- taskId: "example",
1144
- phaseId: "implementation",
1145
- opencode: true,
1146
- printCommand: true,
1147
- }),
1148
- )
1149
-
1150
- expect(JSON.parse(output.join("\n")).cwd).toBe(phaseDirectory)
1151
- })
1152
-
1153
- test("does not reopen a forced terminal target in print-only mode", async () => {
1154
- const harness = createHarness({ taskStatuses: { example: "done" } })
1155
-
1156
- await captureLogs(() =>
1157
- harness.run({
1158
- taskId: "example",
1159
- opencode: true,
1160
- force: true,
1161
- printCommand: true,
1162
- }),
1163
- )
1164
-
1165
- expect(harness.statusUpdates).toEqual([])
1166
- expect(harness.taskStatuses.example).toBe("done")
1167
- })
1168
-
1169
- test("leaves managed OpenCode access to the project plugin", async () => {
1170
- const harness = createHarness()
1171
- const output = await captureLogs(() =>
1172
- harness.run({ taskId: "example", opencode: true, printCommand: true }),
1173
- )
1174
- const printed = JSON.parse(output.join("\n"))
1175
-
1176
- expect(printed.environment.OPENCODE_CONFIG).toBeUndefined()
1177
- expect(printed.environment.OPENCODE_CONFIG_CONTENT).toBeUndefined()
1178
- })
1179
-
1180
- test("provides the writable checkout to plugins without changing the project", async () => {
1181
- const harness = createHarness()
1182
- await harness.run({ taskId: "example", opencode: true })
1183
-
1184
- expect(harness.launches[0]).toEqual({
1185
- cli: "opencode",
1186
- args: ["opencode"],
1187
- cwd: taskDirectory,
1188
- })
1189
- expect(harness.launchEnvironments[0]?.AGENCY_WRITABLE_CHECKOUT).toBe(
1190
- "/workbase/tasks/example/code/agency",
1191
- )
1192
- expect(
1193
- harness.launchEnvironments[0]?.OPENCODE_CONFIG_CONTENT,
1194
- ).toBeUndefined()
1195
- })
1196
-
1197
- test("automatically prefers opencode2", async () => {
1198
- const harness = createHarness()
1199
-
1200
- await harness.run({ taskId: "example" })
1201
-
1202
- expect(harness.probes).toEqual(["opencode2"])
1203
- expect(harness.launches[0]).toEqual({
1204
- cli: "opencode2",
1205
- args: ["opencode2"],
1206
- cwd: taskDirectory,
1207
- })
1208
- })
1209
-
1210
- test("automatically falls back from opencode2 to opencode", async () => {
1211
- const harness = createHarness({ available: { opencode2: false } })
1212
-
1213
- await harness.run({ taskId: "example" })
1214
-
1215
- expect(harness.probes).toEqual(["opencode2", "opencode"])
1216
- expect(harness.launches[0]).toEqual({
1217
- cli: "opencode",
1218
- args: ["opencode"],
1219
- cwd: taskDirectory,
1220
- })
1221
- })
1222
-
1223
- test("automatically falls back from OpenCode to Claude", async () => {
1224
- const harness = createHarness({
1225
- available: { opencode2: false, opencode: false, pi: false },
1226
- })
1227
-
1228
- await harness.run({ taskId: "example" })
1229
-
1230
- expect(harness.probes).toEqual(["opencode2", "opencode", "pi", "claude"])
1231
- expect(harness.launches[0]).toEqual({
1232
- cli: "claude",
1233
- args: ["claude"],
1234
- cwd: taskDirectory,
1235
- })
1236
- })
1237
-
1238
- test("automatically falls back from OpenCode to Pi", async () => {
1239
- const harness = createHarness({
1240
- available: { opencode2: false, opencode: false },
1241
- })
1242
-
1243
- await harness.run({ taskId: "example" })
1244
-
1245
- expect(harness.probes).toEqual(["opencode2", "opencode", "pi"])
1246
- expect(harness.launches[0]).toMatchObject({ cli: "pi", args: ["pi"] })
1247
- })
1248
-
1249
- test("uses the global agent before automatic detection", async () => {
1250
- const harness = createHarness({ agent: "pi" })
1251
-
1252
- await harness.run({ taskId: "example" })
1253
-
1254
- expect(harness.probes).toEqual(["pi"])
1255
- expect(harness.launches[0]).toMatchObject({ cli: "pi", args: ["pi"] })
1256
- })
1257
-
1258
- test("lets an invocation agent override the global agent", async () => {
1259
- const harness = createHarness({ agent: "pi" })
1260
-
1261
- await harness.run({ taskId: "example", agent: "claude" })
1262
-
1263
- expect(harness.probes).toEqual(["claude"])
1264
- expect(harness.launches[0]).toMatchObject({
1265
- cli: "claude",
1266
- args: ["claude"],
1267
- })
1268
- })
1269
-
1270
- test("does not fall back when OpenCode is explicitly required", async () => {
1271
- const harness = createHarness({ available: { opencode: false } })
1272
-
1273
- await expect(
1274
- harness.run({ taskId: "example", opencode: true }),
1275
- ).rejects.toThrow("opencode CLI tool not found")
1276
- expect(harness.probes).toEqual(["opencode"])
1277
- expect(harness.launches).toEqual([])
1278
- expect(harness.statusUpdates).toEqual([])
1279
- })
1280
-
1281
- test("launches explicitly requested Claude", async () => {
1282
- const harness = createHarness()
1283
-
1284
- await harness.run({ taskId: "example", claude: true })
1285
-
1286
- expect(harness.probes).toEqual(["claude"])
1287
- expect(harness.launches[0]).toEqual({
1288
- cli: "claude",
1289
- args: ["claude"],
1290
- cwd: taskDirectory,
1291
- })
1292
- })
1293
-
1294
- test("fails when neither agent tool is available", async () => {
1295
- const harness = createHarness({
1296
- available: {
1297
- opencode2: false,
1298
- opencode: false,
1299
- pi: false,
1300
- claude: false,
1301
- },
1302
- })
1303
-
1304
- await expect(harness.run({ taskId: "example" })).rejects.toThrow(
1305
- "claude CLI tool not found",
1306
- )
1307
- expect(harness.probes).toEqual(["opencode2", "opencode", "pi", "claude"])
1308
- expect(harness.launches).toEqual([])
1309
- })
1310
-
1311
- test("does not probe or launch when materialization fails", async () => {
1312
- const harness = createHarness({
1313
- materializeError: new Error("materialization failed"),
1314
- })
1315
-
1316
- await expect(harness.run({ taskId: "example" })).rejects.toThrow(
1317
- "materialization failed",
1318
- )
1319
- expect(harness.events).toEqual(["materialize"])
1320
- expect(harness.progressUpdates).toEqual([
1321
- "start:Preparing workspace...",
1322
- "fail:Workspace preparation failed",
1323
- ])
1324
- })
1325
-
1326
- test("does not reopen a terminal target when preparation fails", async () => {
1327
- const harness = createHarness({
1328
- taskStatuses: { example: "dropped" },
1329
- materializeError: new Error("materialization failed"),
1330
- })
1331
-
1332
- await expect(
1333
- harness.run({ taskId: "example", force: true }),
1334
- ).rejects.toThrow("materialization failed")
1335
- expect(harness.statusUpdates).toEqual([])
1336
- expect(harness.taskStatuses.example).toBe("dropped")
1337
- })
1338
-
1339
- test("retains working when launch fails after a forced reopen", async () => {
1340
- const harness = createHarness({
1341
- taskStatuses: { example: "done" },
1342
- launchError: new Error("launch failed"),
1343
- })
1344
-
1345
- await expect(
1346
- harness.run({ taskId: "example", force: true, silent: true }),
1347
- ).rejects.toThrow("launch failed")
1348
- expect(harness.statusUpdates).toEqual([
1349
- "task:example:open",
1350
- "task:example:working",
1351
- ])
1352
- expect(harness.taskStatuses.example).toBe("working")
1353
- })
1354
-
1355
- test("respects silent and verbose logging options", async () => {
1356
- const verboseHarness = createHarness()
1357
- const verboseLogs = await captureErrors(() =>
1358
- verboseHarness.run({ taskId: "example", verbose: true }),
1359
- )
1360
- expect(verboseLogs).toEqual([
1361
- "Launching command: opencode2 (cwd: /workbase/tasks/example)",
1362
- ])
1363
- expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
1364
-
1365
- const silentHarness = createHarness()
1366
- const silentLogs = await captureErrors(() =>
1367
- silentHarness.run({
1368
- taskId: "example",
1369
- verbose: true,
1370
- silent: true,
1371
- }),
1372
- )
1373
- expect(silentLogs).toEqual([])
1374
- })
1375
- })