@markjaquith/agency 3.2.14 → 3.3.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 (75) hide show
  1. package/README.md +20 -3
  2. package/cli-main.ts +1 -0
  3. package/package.json +7 -2
  4. package/src/cli-parser.ts +13 -0
  5. package/src/commands/work.ts +70 -2
  6. package/src/services/ReadinessService.ts +20 -0
  7. package/src/workbase/execution-contract.ts +10 -1
  8. package/src/workbase/schemas.ts +12 -0
  9. package/fixtures/protocol/orchestration-recipes.json +0 -53
  10. package/pi-extensions/agency.test.ts +0 -117
  11. package/src/check-commits.test.ts +0 -58
  12. package/src/cli-parser.test.ts +0 -945
  13. package/src/cli.test.ts +0 -1889
  14. package/src/commands/act.test.ts +0 -418
  15. package/src/commands/archive.test.ts +0 -236
  16. package/src/commands/context.test.ts +0 -711
  17. package/src/commands/description.test.ts +0 -103
  18. package/src/commands/doctor.test.ts +0 -185
  19. package/src/commands/epic.test.ts +0 -196
  20. package/src/commands/init.test.ts +0 -117
  21. package/src/commands/integration.test.ts +0 -165
  22. package/src/commands/pr.test.ts +0 -248
  23. package/src/commands/push.test.ts +0 -56
  24. package/src/commands/read-only.test.ts +0 -161
  25. package/src/commands/repo.test.ts +0 -199
  26. package/src/commands/restore.test.ts +0 -106
  27. package/src/commands/status.test.ts +0 -113
  28. package/src/commands/sync.test.ts +0 -200
  29. package/src/commands/task-phase.test.ts +0 -410
  30. package/src/commands/task.test.ts +0 -281
  31. package/src/commands/validate.test.ts +0 -186
  32. package/src/commands/work.test.ts +0 -1375
  33. package/src/commands/workbase.test.ts +0 -170
  34. package/src/graph-schema.test.ts +0 -124
  35. package/src/protocol.test.ts +0 -163
  36. package/src/readiness.test.ts +0 -105
  37. package/src/services/ArchiveBulkService.test.ts +0 -384
  38. package/src/services/ArchiveService.test.ts +0 -1092
  39. package/src/services/EpicService.test.ts +0 -74
  40. package/src/services/FileSystemService.test.ts +0 -81
  41. package/src/services/GraphMutationService.test.ts +0 -486
  42. package/src/services/GraphService.test.ts +0 -494
  43. package/src/services/IntegrationService.test.ts +0 -1091
  44. package/src/services/LifecycleTransaction.test.ts +0 -145
  45. package/src/services/PullRequestService.test.ts +0 -716
  46. package/src/services/PushService.test.ts +0 -408
  47. package/src/services/ReadinessService.test.ts +0 -290
  48. package/src/services/RepositoryService.test.ts +0 -789
  49. package/src/services/ReviewService.test.ts +0 -408
  50. package/src/services/SyncService.test.ts +0 -1377
  51. package/src/services/TaskPhaseService.test.ts +0 -852
  52. package/src/services/VersionControlService.test.ts +0 -62
  53. package/src/services/WorkbaseService.test.ts +0 -910
  54. package/src/services/WorktreeLock.test.ts +0 -205
  55. package/src/services/WorktreePerformance.test.ts +0 -71
  56. package/src/services/WorktreeService.test.ts +0 -2292
  57. package/src/test-utils.ts +0 -110
  58. package/src/usage-log.test.ts +0 -188
  59. package/src/utils/chooser.test.ts +0 -192
  60. package/src/utils/effect.test.ts +0 -30
  61. package/src/utils/interactive.pty.test.ts +0 -128
  62. package/src/utils/interactive.test.tsx +0 -860
  63. package/src/utils/process.test.ts +0 -132
  64. package/src/utils/progress.test.ts +0 -37
  65. package/src/work-view.test.ts +0 -151
  66. package/src/workbase/agent-command.test.ts +0 -128
  67. package/src/workbase/checkout-command.test.ts +0 -62
  68. package/src/workbase/delivery-command.test.ts +0 -180
  69. package/src/workbase/dependency-graph.test.ts +0 -50
  70. package/src/workbase/execution-contract.test.ts +0 -161
  71. package/src/workbase/frontmatter.test.ts +0 -67
  72. package/src/workbase/repository-reference.test.ts +0 -25
  73. package/src/workbase/schemas.test.ts +0 -543
  74. package/src/workbase/work-target.test.ts +0 -108
  75. 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
- })