@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,492 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { delimiter, join } from 'node:path'
5
+ import type { StreamPart } from '../../store/streams/types'
6
+ import type { AgentRunner, AgentSessionHandle, AgentTurnResult, Transcript } from '../types'
7
+ import type { OneShotCompletion, OneShotCompletionInput } from './one-shot'
8
+ import { AGENT_BIN_DIR } from './session-env'
9
+
10
+ export type AgentRunnerContractScenario =
11
+ | 'success'
12
+ | 'retryable-failure'
13
+ | 'permanent-failure'
14
+ | 'exhaustion-failure'
15
+ | 'cancel-start'
16
+ | 'cancel-continue'
17
+ | 'stream-turn'
18
+
19
+ export const CONTRACT_INVOCATION = 'agent-runner-contract'
20
+ export const CONTRACT_SKILL = 'ab-runner-contract'
21
+ export const CONTRACT_FOLLOW_UP = 'contract follow-up turn'
22
+ export const CONTRACT_RETRYABLE_FAILURE = 'contract worker exited unexpectedly'
23
+ export const CONTRACT_PERMANENT_FAILURE = 'contract authentication failed'
24
+ export const CONTRACT_EXHAUSTION_FAILURE = 'contract usage quota exhausted'
25
+ export const CONTRACT_ONE_SHOT_PROMPT = 'contract one-shot prompt'
26
+ export const CONTRACT_ONE_SHOT_TEXT = 'contract-one-shot-result'
27
+ export const CONTRACT_STREAM_TOOL = 'contract-tool'
28
+
29
+ /** An observation made below the adapter boundary. Contract fixtures normalize
30
+ * their SDK-specific calls to this shape so the suite can verify delivery and
31
+ * effective process environment without reaching into an adapter. */
32
+ export interface AgentRunnerContractTurnObservation {
33
+ /** Undefined for start; the exact follow-up string for continue. */
34
+ message?: string
35
+ env: Record<string, string>
36
+ }
37
+
38
+ export interface AgentRunnerContractOneShotObservation {
39
+ prompt: string
40
+ cwd: string
41
+ env: Record<string, string>
42
+ model?: string
43
+ }
44
+
45
+ export interface AgentRunnerContractOneShotHarness {
46
+ completion: OneShotCompletion
47
+ observation: () => AgentRunnerContractOneShotObservation | undefined
48
+ }
49
+
50
+ export interface AgentRunnerContractHarness {
51
+ runner: AgentRunner
52
+ /** A valid, explicit model identifier for this adapter. */
53
+ model: string
54
+ workspacePath: string
55
+ /** Independent observations from the script/SDK seam, in turn order. */
56
+ turns: () => readonly AgentRunnerContractTurnObservation[]
57
+ /** Adapter-specific disposal observation, when the transport exposes one. */
58
+ disposed?: () => number
59
+ /** Present only when the runtime declares the optional capability. */
60
+ oneShot?: AgentRunnerContractOneShotHarness
61
+ /** Stream-part observation (SPEC §9): every part the adapter appended via
62
+ * the per-turn emitter, across turns, in order. Present only when the
63
+ * offline fake supports streaming. */
64
+ stream?: () => readonly StreamPart[]
65
+ cleanup?: () => Promise<void>
66
+ }
67
+
68
+ export type AgentRunnerContractFactory = (
69
+ scenario: AgentRunnerContractScenario,
70
+ ) => Promise<AgentRunnerContractHarness> | AgentRunnerContractHarness
71
+
72
+ async function withHarness(
73
+ factory: AgentRunnerContractFactory,
74
+ scenario: AgentRunnerContractScenario,
75
+ run: (harness: AgentRunnerContractHarness) => Promise<void>,
76
+ ): Promise<void> {
77
+ const harness = await factory(scenario)
78
+ let failure: unknown
79
+ try {
80
+ await run(harness)
81
+ } catch (error) {
82
+ failure = error
83
+ }
84
+
85
+ try {
86
+ await harness.cleanup?.()
87
+ } catch (cleanupError) {
88
+ if (failure !== undefined) {
89
+ throw new AggregateError(
90
+ [failure, cleanupError],
91
+ 'AgentRunner contract assertion and cleanup both failed',
92
+ )
93
+ }
94
+ throw cleanupError
95
+ }
96
+ if (failure !== undefined) throw failure
97
+ }
98
+
99
+ /** Run assertions while guaranteeing that every successfully launched handle
100
+ * is ended. Assertion and end failures are both retained. */
101
+ async function withEndedSession<T>(
102
+ runner: AgentRunner,
103
+ session: AgentSessionHandle,
104
+ run: () => Promise<T>,
105
+ ): Promise<{ value: T; transcript: Transcript }> {
106
+ let value: T | undefined
107
+ let failure: unknown
108
+ try {
109
+ value = await run()
110
+ } catch (error) {
111
+ failure = error
112
+ }
113
+
114
+ let transcript: Transcript | undefined
115
+ try {
116
+ transcript = await runner.end(session)
117
+ } catch (endError) {
118
+ if (failure !== undefined) {
119
+ throw new AggregateError(
120
+ [failure, endError],
121
+ 'AgentRunner contract assertion and session end both failed',
122
+ )
123
+ }
124
+ throw endError
125
+ }
126
+ if (failure !== undefined) throw failure
127
+ return { value: value as T, transcript }
128
+ }
129
+
130
+ function expectUsage(usage: { inputTokens: number; outputTokens: number; turns: number }): void {
131
+ for (const value of [usage.inputTokens, usage.outputTokens, usage.turns]) {
132
+ expect(Number.isFinite(value)).toBe(true)
133
+ expect(Number.isInteger(value)).toBe(true)
134
+ expect(value).toBeGreaterThanOrEqual(0)
135
+ }
136
+ expect(usage.turns).toBeGreaterThan(0)
137
+ }
138
+
139
+ function expectTypedCompleted(result: AgentTurnResult): void {
140
+ expect(result.kind).toBe('completed')
141
+ expect(typeof result.text).toBe('string')
142
+ expectUsage(result.usage)
143
+ }
144
+
145
+ function expectTranscript(transcript: Transcript, runner: AgentRunner, model: string): void {
146
+ expect(transcript.content.trim().length).toBeGreaterThan(0)
147
+ expect(transcript.metadata.runner).toBe(runner.name)
148
+ expect(transcript.metadata.model).toBe(model)
149
+ expectUsage(transcript.metadata.usage)
150
+ }
151
+
152
+ async function waitForTurn(harness: AgentRunnerContractHarness, count: number): Promise<void> {
153
+ const deadline = Date.now() + 1000
154
+ while (harness.turns().length < count) {
155
+ if (Date.now() >= deadline)
156
+ throw new Error(`runner did not start turn ${count} within one second`)
157
+ await Bun.sleep(1)
158
+ }
159
+ }
160
+
161
+ async function invokeManagedCli(env: Record<string, string>): Promise<void> {
162
+ const path = env.PATH
163
+ expect(path).toBeDefined()
164
+ expect(path!.split(delimiter)[0]).toBe(AGENT_BIN_DIR)
165
+
166
+ const proc = Bun.spawn(['ab', '--help'], {
167
+ cwd: process.cwd(),
168
+ env,
169
+ stdout: 'pipe',
170
+ stderr: 'pipe',
171
+ })
172
+ const [stdout, stderr, exitCode] = await Promise.all([
173
+ new Response(proc.stdout).text(),
174
+ new Response(proc.stderr).text(),
175
+ proc.exited,
176
+ ])
177
+ expect(exitCode).toBe(0)
178
+ expect(stderr).toBe('')
179
+ expect(stdout).toContain('ab — agent-driven software delivery')
180
+ expect(stdout).not.toContain('host-conflicting-ab')
181
+ }
182
+
183
+ /**
184
+ * Reusable AgentRunner semantics. Runtime transport, stream extraction,
185
+ * resumption mechanics, and disposal tests remain beside each adapter; this is
186
+ * the behavioral floor a future runtime plugin can run unchanged.
187
+ */
188
+ export function describeAgentRunnerContract(
189
+ name: string,
190
+ factory: AgentRunnerContractFactory,
191
+ ): void {
192
+ describe(`AgentRunner contract: ${name}`, () => {
193
+ test('start/continue are typed, refresh ambient auth, resolve managed ab, and end with a complete transcript', async () => {
194
+ await withHarness(factory, 'success', async (harness) => {
195
+ const conflictDir = await mkdtemp(join(tmpdir(), 'ab-runner-contract-path-'))
196
+ try {
197
+ const conflictingAb = join(conflictDir, 'ab')
198
+ await writeFile(conflictingAb, '#!/bin/sh\necho host-conflicting-ab\nexit 91\n')
199
+ await chmod(conflictingAb, 0o755)
200
+
201
+ const startEnv = {
202
+ PATH: [conflictDir, process.env.PATH ?? '']
203
+ .filter((entry) => entry !== '')
204
+ .join(delimiter),
205
+ AB_CONTRACT_START_ONLY: 'retained',
206
+ AB_CONTRACT_COLLIDE: 'start-value',
207
+ AB_PHASE: 'implement@1',
208
+ }
209
+ const started = await harness.runner.start({
210
+ skill: CONTRACT_SKILL,
211
+ invocation: CONTRACT_INVOCATION,
212
+ workspacePath: harness.workspacePath,
213
+ model: harness.model,
214
+ env: startEnv,
215
+ })
216
+
217
+ const { transcript } = await withEndedSession(
218
+ harness.runner,
219
+ started.session,
220
+ async () => {
221
+ expect(started.session.id.trim().length).toBeGreaterThan(0)
222
+ expect(started.session.runner).toBe(harness.runner.name)
223
+ expect(started.session.model).toBe(harness.model)
224
+ expectTypedCompleted(started.result)
225
+
226
+ const continued = await harness.runner.continue(started.session, CONTRACT_FOLLOW_UP, {
227
+ env: {
228
+ AB_CONTRACT_COLLIDE: 'continue-value',
229
+ AB_CONTRACT_CONTINUE_ONLY: 'fresh',
230
+ AB_PHASE: 'implement@2',
231
+ },
232
+ })
233
+ expectTypedCompleted(continued)
234
+
235
+ const turns = harness.turns()
236
+ expect(turns).toHaveLength(2)
237
+ expect(turns[0]?.message).toBeUndefined()
238
+ expect(turns[1]?.message).toBe(CONTRACT_FOLLOW_UP)
239
+ expect(turns[0]?.env.AB_CONTRACT_COLLIDE).toBe('start-value')
240
+ expect(turns[0]?.env.AB_CONTRACT_START_ONLY).toBe('retained')
241
+ expect(turns[1]?.env.AB_CONTRACT_COLLIDE).toBe('continue-value')
242
+ expect(turns[1]?.env.AB_CONTRACT_START_ONLY).toBe('retained')
243
+ expect(turns[1]?.env.AB_CONTRACT_CONTINUE_ONLY).toBe('fresh')
244
+ expect(turns[1]?.env.AB_PHASE).toBe('implement@2')
245
+ await invokeManagedCli(turns[0]!.env)
246
+ await invokeManagedCli(turns[1]!.env)
247
+ },
248
+ )
249
+ expectTranscript(transcript, harness.runner, harness.model)
250
+ } finally {
251
+ await rm(conflictDir, { recursive: true, force: true })
252
+ }
253
+ })
254
+ })
255
+
256
+ test('an aborted start terminates promptly and retains an endable transcript', async () => {
257
+ await withHarness(factory, 'cancel-start', async (harness) => {
258
+ const controller = new AbortController()
259
+ const pending = harness.runner.start({
260
+ skill: CONTRACT_SKILL,
261
+ invocation: CONTRACT_INVOCATION,
262
+ workspacePath: harness.workspacePath,
263
+ model: harness.model,
264
+ env: { AB_PHASE: 'implement@1' },
265
+ signal: controller.signal,
266
+ })
267
+ await waitForTurn(harness, 1)
268
+ controller.abort(new Error('contract operator cancellation'))
269
+ const started = await Promise.race([
270
+ pending,
271
+ Bun.sleep(1000).then(() => {
272
+ throw new Error('cancelled start did not settle within one second')
273
+ }),
274
+ ])
275
+ const { transcript } = await withEndedSession(harness.runner, started.session, async () => {
276
+ expect(started.result).toMatchObject({
277
+ kind: 'failed',
278
+ failure: { permanent: false },
279
+ })
280
+ })
281
+ expectTranscript(transcript, harness.runner, harness.model)
282
+ if (harness.disposed !== undefined) expect(harness.disposed()).toBe(1)
283
+ await expect(harness.runner.end(started.session)).rejects.toThrow()
284
+ })
285
+ })
286
+
287
+ test('an aborted continued turn terminates promptly and the whole session remains disposable', async () => {
288
+ await withHarness(factory, 'cancel-continue', async (harness) => {
289
+ const started = await harness.runner.start({
290
+ skill: CONTRACT_SKILL,
291
+ invocation: CONTRACT_INVOCATION,
292
+ workspacePath: harness.workspacePath,
293
+ model: harness.model,
294
+ env: { AB_PHASE: 'implement@1' },
295
+ })
296
+ expectTypedCompleted(started.result)
297
+ const controller = new AbortController()
298
+ const pending = harness.runner.continue(started.session, CONTRACT_FOLLOW_UP, {
299
+ env: { AB_PHASE: 'implement@2' },
300
+ signal: controller.signal,
301
+ })
302
+ await waitForTurn(harness, 2)
303
+ controller.abort(new Error('contract operator cancellation'))
304
+ const { transcript } = await withEndedSession(harness.runner, started.session, async () => {
305
+ const result = await Promise.race([
306
+ pending,
307
+ Bun.sleep(1000).then(() => {
308
+ throw new Error('cancelled continuation did not settle within one second')
309
+ }),
310
+ ])
311
+ expect(result).toMatchObject({ kind: 'failed', failure: { permanent: false } })
312
+ })
313
+ expectTranscript(transcript, harness.runner, harness.model)
314
+ if (harness.disposed !== undefined) expect(harness.disposed()).toBe(1)
315
+ })
316
+ })
317
+
318
+ test('an unclassified provider failure stays retryable and keeps an endable handle', async () => {
319
+ await withHarness(factory, 'retryable-failure', async (harness) => {
320
+ const started = await harness.runner.start({
321
+ skill: CONTRACT_SKILL,
322
+ invocation: CONTRACT_INVOCATION,
323
+ workspacePath: harness.workspacePath,
324
+ model: harness.model,
325
+ env: { AB_PHASE: 'implement@1' },
326
+ })
327
+ const { transcript } = await withEndedSession(harness.runner, started.session, async () => {
328
+ expect(started.result).toMatchObject({
329
+ kind: 'failed',
330
+ failure: {
331
+ message: CONTRACT_RETRYABLE_FAILURE,
332
+ permanent: false,
333
+ },
334
+ })
335
+ expectUsage(started.result.usage)
336
+ })
337
+ expectTranscript(transcript, harness.runner, harness.model)
338
+ })
339
+ })
340
+
341
+ test('positive authentication/permission evidence is a permanent credential failure', async () => {
342
+ await withHarness(factory, 'permanent-failure', async (harness) => {
343
+ const started = await harness.runner.start({
344
+ skill: CONTRACT_SKILL,
345
+ invocation: CONTRACT_INVOCATION,
346
+ workspacePath: harness.workspacePath,
347
+ model: harness.model,
348
+ env: { AB_PHASE: 'implement@1' },
349
+ })
350
+ const { transcript } = await withEndedSession(harness.runner, started.session, async () => {
351
+ expect(started.result).toMatchObject({
352
+ kind: 'failed',
353
+ failure: {
354
+ message: CONTRACT_PERMANENT_FAILURE,
355
+ permanent: true,
356
+ cause: 'credentials',
357
+ },
358
+ })
359
+ expectUsage(started.result.usage)
360
+ })
361
+ expectTranscript(transcript, harness.runner, harness.model)
362
+ })
363
+ })
364
+
365
+ test('quota evidence is permanent for retry policy but identified as provider exhaustion', async () => {
366
+ await withHarness(factory, 'exhaustion-failure', async (harness) => {
367
+ const started = await harness.runner.start({
368
+ skill: CONTRACT_SKILL,
369
+ invocation: CONTRACT_INVOCATION,
370
+ workspacePath: harness.workspacePath,
371
+ model: harness.model,
372
+ env: { AB_PHASE: 'implement@1' },
373
+ })
374
+ const { transcript } = await withEndedSession(harness.runner, started.session, async () => {
375
+ expect(started.result).toMatchObject({
376
+ kind: 'failed',
377
+ failure: {
378
+ message: CONTRACT_EXHAUSTION_FAILURE,
379
+ permanent: true,
380
+ cause: 'exhaustion',
381
+ },
382
+ })
383
+ expectUsage(started.result.usage)
384
+ })
385
+ expectTranscript(transcript, harness.runner, harness.model)
386
+ })
387
+ })
388
+
389
+ test('declared one-shot completion forwards its exact input and returns typed text', async () => {
390
+ await withHarness(factory, 'success', async (harness) => {
391
+ if (harness.oneShot === undefined) return
392
+ const input: OneShotCompletionInput = {
393
+ prompt: CONTRACT_ONE_SHOT_PROMPT,
394
+ cwd: harness.workspacePath,
395
+ env: { AB_CONTRACT_ONE_SHOT: 'scoped-value' },
396
+ model: harness.model,
397
+ }
398
+ const result = await harness.oneShot.completion.complete(input)
399
+ expect(result).toEqual({ text: CONTRACT_ONE_SHOT_TEXT })
400
+ const observed = harness.oneShot.observation()
401
+ expect(observed).toBeDefined()
402
+ expect(observed?.prompt).toBe(input.prompt)
403
+ expect(observed?.cwd).toBe(input.cwd)
404
+ expect(observed?.env.AB_CONTRACT_ONE_SHOT).toBe('scoped-value')
405
+ expect(observed?.model).toBe(input.model)
406
+ })
407
+ })
408
+
409
+ // ── Session streams (SPEC §9) ── exercised only when the offline fake
410
+ // supports the streaming boundary; a runner without it skips cleanly.
411
+
412
+ test('a representative turn streams prompt, text, one tool call with input and output, and finish-step', async () => {
413
+ await withHarness(factory, 'stream-turn', async (harness) => {
414
+ if (harness.stream === undefined) return
415
+ const parts: StreamPart[] = []
416
+ const started = await harness.runner.start({
417
+ skill: CONTRACT_SKILL,
418
+ invocation: CONTRACT_INVOCATION,
419
+ workspacePath: harness.workspacePath,
420
+ model: harness.model,
421
+ env: { AB_PHASE: 'implement@1' },
422
+ stream: { append: (appended: StreamPart[]) => parts.push(...appended) },
423
+ })
424
+ expectTypedCompleted(started.result)
425
+ const types = parts.map((part) => part.type)
426
+ // The turn's two sides are visible: its prompt precedes the output.
427
+ expect(types[0]).toBe('data-ab-prompt')
428
+ expect(types).toContain('start')
429
+ // At minimum: a completed assistant message, one tool call with its
430
+ // input and output, and the step bracket around them.
431
+ expect(types).toContain('start-step')
432
+ expect(types).toContain('finish-step')
433
+ expect(types).toContain('text-start')
434
+ expect(types).toContain('text-delta')
435
+ expect(types).toContain('text-end')
436
+ const toolInput = parts.find((part) => part.type === 'tool-input-available') as unknown as
437
+ | { toolName: string }
438
+ | undefined
439
+ expect(toolInput?.toolName).toBeDefined()
440
+ expect(parts.some((part) => part.type === 'tool-output-available')).toBe(true)
441
+ expect(parts.at(-1)?.type).toBe('finish')
442
+ // The prompt part carries this turn's skill invocation.
443
+ const prompt = parts.find((part) => part.type === 'data-ab-prompt') as unknown as
444
+ | { data: { text: string } }
445
+ | undefined
446
+ expect(prompt?.data.text).toContain(CONTRACT_SKILL)
447
+ })
448
+ })
449
+
450
+ test('a failed turn emits an error part before the stream closes', async () => {
451
+ await withHarness(factory, 'retryable-failure', async (harness) => {
452
+ if (harness.stream === undefined) return
453
+ const parts: StreamPart[] = []
454
+ await harness.runner.start({
455
+ skill: CONTRACT_SKILL,
456
+ invocation: CONTRACT_INVOCATION,
457
+ workspacePath: harness.workspacePath,
458
+ model: harness.model,
459
+ env: { AB_PHASE: 'implement@1' },
460
+ stream: { append: (appended: StreamPart[]) => parts.push(...appended) },
461
+ })
462
+ expect(parts.at(-1)?.type).toBe('error')
463
+ })
464
+ })
465
+
466
+ test('a cancelled turn emits an abort part', async () => {
467
+ await withHarness(factory, 'cancel-start', async (harness) => {
468
+ if (harness.stream === undefined) return
469
+ const parts: StreamPart[] = []
470
+ const controller = new AbortController()
471
+ const pending = harness.runner.start({
472
+ skill: CONTRACT_SKILL,
473
+ invocation: CONTRACT_INVOCATION,
474
+ workspacePath: harness.workspacePath,
475
+ model: harness.model,
476
+ env: { AB_PHASE: 'implement@1' },
477
+ signal: controller.signal,
478
+ stream: { append: (appended: StreamPart[]) => parts.push(...appended) },
479
+ })
480
+ await waitForTurn(harness, 1)
481
+ controller.abort(new Error('contract operator cancellation'))
482
+ await Promise.race([
483
+ pending,
484
+ Bun.sleep(1000).then(() => {
485
+ throw new Error('cancelled streaming turn did not settle within one second')
486
+ }),
487
+ ])
488
+ expect(parts.at(-1)?.type).toBe('abort')
489
+ })
490
+ })
491
+ })
492
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * ScriptedAgentRunner (SPEC §9): the deterministic AgentRunner used by every
3
+ * kernel/process/integration test. A single script function plays the agent;
4
+ * the runner records every turn into public journals so tests can assert the
5
+ * exact session traffic — and `end()` produces a Transcript like any real
6
+ * adapter, because transcripts come back through the interface, guaranteed.
7
+ */
8
+ import {
9
+ agentInvocation,
10
+ type AgentContinueOpts,
11
+ type AgentFailureCause,
12
+ type AgentRunner,
13
+ type AgentSessionHandle,
14
+ type AgentStartOpts,
15
+ type AgentTurnResult,
16
+ type Transcript,
17
+ } from '../types'
18
+ import { sessionEnv } from './session-env'
19
+
20
+ /** What the script sees on each invocation — one call per turn. */
21
+ export interface ScriptContext {
22
+ /** This TURN's opts: on `continue`, the refreshed ambient env (§10, D8)
23
+ * merged over the start env — exactly what a real adapter launches with. */
24
+ opts: AgentStartOpts
25
+ session: AgentSessionHandle
26
+ /** 1 on `start`, incremented on each `continue`. */
27
+ turn: number
28
+ /** The `continue` message; undefined on turn 1 (start has no message). */
29
+ message?: string
30
+ /** All prior `continue` messages, oldest first (excludes this turn's). */
31
+ history: string[]
32
+ }
33
+
34
+ export type Script = (ctx: ScriptContext) => Promise<AgentTurnResult> | AgentTurnResult
35
+
36
+ /** Default completed turn (text '', usage 1/1/1) — keeps scripts terse. */
37
+ export function defaultTurnResult(text = ''): AgentTurnResult {
38
+ return {
39
+ kind: 'completed',
40
+ text,
41
+ usage: { inputTokens: 1, outputTokens: 1, turns: 1 },
42
+ }
43
+ }
44
+
45
+ /** Script a provider/runner-declared failure while preserving the same endable
46
+ * fake session and transcript behavior as a real adapter. */
47
+ export function failedTurnResult(
48
+ message: string,
49
+ permanent: boolean,
50
+ text = '',
51
+ cause?: AgentFailureCause,
52
+ ): AgentTurnResult {
53
+ return {
54
+ kind: 'failed',
55
+ text,
56
+ usage: { inputTokens: 0, outputTokens: 0, turns: 1 },
57
+ failure: { message, permanent, ...(cause !== undefined ? { cause } : {}) },
58
+ }
59
+ }
60
+
61
+ export interface RecordedTurn {
62
+ turn: number
63
+ /** Undefined on turn 1 — the start prompt is derived from `opts`. */
64
+ message?: string
65
+ result: AgentTurnResult
66
+ }
67
+
68
+ /** Per-session journal, public for assertions. */
69
+ export interface SessionJournal {
70
+ session: AgentSessionHandle
71
+ opts: AgentStartOpts
72
+ turns: RecordedTurn[]
73
+ /** Messages passed to `continue`, in order. */
74
+ messages: string[]
75
+ ended: boolean
76
+ }
77
+
78
+ export class ScriptedAgentRunner implements AgentRunner {
79
+ readonly name = 'scripted'
80
+ /** Journals keyed by session id (`s_1`, `s_2`, …), in creation order. */
81
+ readonly sessions = new Map<string, SessionJournal>()
82
+
83
+ private readonly script: Script
84
+ private nextSession = 0
85
+
86
+ constructor(opts: { script: Script }) {
87
+ this.script = opts.script
88
+ }
89
+
90
+ async start(
91
+ opts: AgentStartOpts,
92
+ ): Promise<{ session: AgentSessionHandle; result: AgentTurnResult }> {
93
+ this.nextSession += 1
94
+ const session: AgentSessionHandle = {
95
+ id: `s_${this.nextSession}`,
96
+ runner: this.name,
97
+ ...(opts.model !== undefined ? { model: opts.model } : {}),
98
+ }
99
+ const journal: SessionJournal = {
100
+ session,
101
+ opts,
102
+ turns: [],
103
+ messages: [],
104
+ ended: false,
105
+ }
106
+ this.sessions.set(session.id, journal)
107
+
108
+ const result = await this.runScript(
109
+ {
110
+ // Scripts observe the same effective launch environment as production
111
+ // adapters; the journal below intentionally retains the caller's raw
112
+ // start options as the session identity.
113
+ opts: { ...opts, env: sessionEnv(opts.env) },
114
+ session,
115
+ turn: 1,
116
+ history: [],
117
+ },
118
+ opts.signal,
119
+ )
120
+ journal.turns.push({ turn: 1, result })
121
+ return { session, result }
122
+ }
123
+
124
+ async continue(
125
+ session: AgentSessionHandle,
126
+ message: string,
127
+ opts?: AgentContinueOpts,
128
+ ): Promise<AgentTurnResult> {
129
+ const journal = this.liveJournal(session, 'continue')
130
+ const history = [...journal.messages]
131
+ const turn = journal.turns.length + 1
132
+ // Mirror the real adapters (§10, D8): the continued turn runs under the
133
+ // refreshed ambient env merged over the start env, so the script's fake
134
+ // CLI resolves exactly what a real agent's `ab` would. The journal keeps
135
+ // the START opts — they are the session's identity. §9: the turn streams
136
+ // through THIS turn's emitter only — the start turn's emitter (if any)
137
+ // belongs to its own bracket and never receives this turn's parts.
138
+ const scoped = opts?.env !== undefined ? { ...journal.opts.env, ...opts.env } : journal.opts.env
139
+ const turnOpts = {
140
+ ...journal.opts,
141
+ env: sessionEnv(scoped),
142
+ stream: opts?.stream,
143
+ ...(opts?.signal !== undefined ? { signal: opts.signal } : {}),
144
+ }
145
+
146
+ const result = await this.runScript(
147
+ {
148
+ opts: turnOpts,
149
+ session: journal.session,
150
+ turn,
151
+ message,
152
+ history,
153
+ },
154
+ opts?.signal,
155
+ )
156
+ journal.messages.push(message)
157
+ journal.turns.push({ turn, message, result })
158
+ return result
159
+ }
160
+
161
+ async end(session: AgentSessionHandle): Promise<Transcript> {
162
+ const journal = this.liveJournal(session, 'end')
163
+ journal.ended = true
164
+
165
+ const usage = { inputTokens: 0, outputTokens: 0, turns: 0 }
166
+ for (const { result } of journal.turns) {
167
+ usage.inputTokens += result.usage.inputTokens
168
+ usage.outputTokens += result.usage.outputTokens
169
+ usage.turns += result.usage.turns
170
+ }
171
+
172
+ return {
173
+ content: JSON.stringify(
174
+ {
175
+ session: journal.session.id,
176
+ skill: journal.opts.skill,
177
+ invocation: agentInvocation(journal.opts),
178
+ ...(journal.opts.buildSlug !== undefined ? { buildSlug: journal.opts.buildSlug } : {}),
179
+ turns: journal.turns,
180
+ },
181
+ null,
182
+ 2,
183
+ ),
184
+ metadata: {
185
+ runner: this.name,
186
+ ...(journal.session.model !== undefined ? { model: journal.session.model } : {}),
187
+ usage,
188
+ },
189
+ }
190
+ }
191
+
192
+ private async runScript(ctx: ScriptContext, signal?: AbortSignal): Promise<AgentTurnResult> {
193
+ const running = Promise.resolve(this.script(ctx))
194
+ if (signal === undefined) return running
195
+ if (signal.aborted) return failedTurnResult('scripted runner: turn aborted', false)
196
+ return new Promise<AgentTurnResult>((resolve, reject) => {
197
+ const aborted = (): void => resolve(failedTurnResult('scripted runner: turn aborted', false))
198
+ signal.addEventListener('abort', aborted, { once: true })
199
+ running.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted))
200
+ })
201
+ }
202
+
203
+ private liveJournal(session: AgentSessionHandle, op: 'continue' | 'end'): SessionJournal {
204
+ const journal = this.sessions.get(session.id)
205
+ if (!journal) {
206
+ throw new Error(`${this.name}: ${op} on unknown session "${session.id}"`)
207
+ }
208
+ if (journal.ended) {
209
+ throw new Error(`${this.name}: ${op} on ended session "${session.id}"`)
210
+ }
211
+ return journal
212
+ }
213
+ }