@a5c-ai/kradle 5.0.1-staging.3abdf9534c25

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 (295) hide show
  1. package/Dockerfile +31 -0
  2. package/README.md +187 -0
  3. package/bin/kradle-demo.mjs +23 -0
  4. package/bin/kradle-server.mjs +14 -0
  5. package/dist/kradle-controller-ui.json +3482 -0
  6. package/dist/kradle-lifecycle.json +201 -0
  7. package/dist/kradle-runtime-snapshot.json +3125 -0
  8. package/dist/kradle-summary.json +724 -0
  9. package/docs/README.md +61 -0
  10. package/docs/agents/README.md +83 -0
  11. package/docs/agents/acceptance-test-matrix.md +193 -0
  12. package/docs/agents/agent-mux-adapter-contract.md +167 -0
  13. package/docs/agents/agent-mux-source-map.md +310 -0
  14. package/docs/agents/agent-run-memory-import-spec.md +256 -0
  15. package/docs/agents/agent-stack-management-spec.md +421 -0
  16. package/docs/agents/api-contract-spec.md +309 -0
  17. package/docs/agents/artifacts-writeback-spec.md +145 -0
  18. package/docs/agents/chart-packaging-spec.md +128 -0
  19. package/docs/agents/ci-orchestration-spec.md +140 -0
  20. package/docs/agents/context-assembly-spec.md +219 -0
  21. package/docs/agents/controller-reconciliation-spec.md +255 -0
  22. package/docs/agents/crd-schema-spec.md +315 -0
  23. package/docs/agents/decision-log-open-questions.md +169 -0
  24. package/docs/agents/developer-implementation-checklist.md +329 -0
  25. package/docs/agents/dispatching-design.md +262 -0
  26. package/docs/agents/gaps-agent-mux-to-kradle-crds.md +298 -0
  27. package/docs/agents/glossary.md +66 -0
  28. package/docs/agents/implementation-blueprint.md +324 -0
  29. package/docs/agents/implementation-rollout-slices.md +251 -0
  30. package/docs/agents/memory-context-integration-spec.md +194 -0
  31. package/docs/agents/memory-ontology-schema-spec.md +253 -0
  32. package/docs/agents/memory-operations-runbook.md +121 -0
  33. package/docs/agents/mvp-vertical-slice-spec.md +146 -0
  34. package/docs/agents/observability-audit-spec.md +265 -0
  35. package/docs/agents/operator-runbook.md +174 -0
  36. package/docs/agents/org-memory-api-payload-examples.md +333 -0
  37. package/docs/agents/org-memory-controller-sequence-spec.md +181 -0
  38. package/docs/agents/org-memory-e2e-fixture-plan.md +161 -0
  39. package/docs/agents/org-memory-ui-implementation-map.md +114 -0
  40. package/docs/agents/org-memory-vertical-slice-spec.md +168 -0
  41. package/docs/agents/org-resource-model-delta-spec.md +111 -0
  42. package/docs/agents/org-route-resource-model-spec.md +183 -0
  43. package/docs/agents/org-scoping-namespace-spec.md +114 -0
  44. package/docs/agents/rbac-secrets-management-spec.md +406 -0
  45. package/docs/agents/repository-page-integration-spec.md +255 -0
  46. package/docs/agents/resource-contract-examples.md +808 -0
  47. package/docs/agents/resource-relationship-map.md +190 -0
  48. package/docs/agents/security-threat-model.md +188 -0
  49. package/docs/agents/shared-memory-company-brain-spec.md +358 -0
  50. package/docs/agents/storage-migration-spec.md +168 -0
  51. package/docs/agents/subagent-orchestration-spec.md +152 -0
  52. package/docs/agents/system-overview.md +88 -0
  53. package/docs/agents/tools-mcp-skills-spec.md +189 -0
  54. package/docs/agents/traceability-matrix.md +79 -0
  55. package/docs/agents/ui-flow-spec.md +211 -0
  56. package/docs/agents/ui-ux-system-spec.md +426 -0
  57. package/docs/agents/workspace-lifecycle-spec.md +166 -0
  58. package/docs/architecture-spec.md +78 -0
  59. package/docs/architecture-v2.md +2759 -0
  60. package/docs/components/control-plane.md +78 -0
  61. package/docs/components/data-plane.md +69 -0
  62. package/docs/components/hooks-events.md +67 -0
  63. package/docs/components/identity-rbac-policy.md +73 -0
  64. package/docs/components/kubevela-oam.md +70 -0
  65. package/docs/components/operations-publishing.md +81 -0
  66. package/docs/components/runners-ci.md +66 -0
  67. package/docs/components/web-ui.md +94 -0
  68. package/docs/crd-behaviors-and-relationships.md +3926 -0
  69. package/docs/external/README.md +47 -0
  70. package/docs/external/bidirectional-sync-design.md +134 -0
  71. package/docs/external/cicd-interface.md +64 -0
  72. package/docs/external/external-backend-controllers.md +170 -0
  73. package/docs/external/external-backend-crds.md +234 -0
  74. package/docs/external/external-backend-ui-spec.md +151 -0
  75. package/docs/external/external-backend-ux-flows.md +115 -0
  76. package/docs/external/external-object-mapping.md +125 -0
  77. package/docs/external/git-forge-interface.md +68 -0
  78. package/docs/external/github-integration-design.md +151 -0
  79. package/docs/external/issue-tracking-interface.md +66 -0
  80. package/docs/external/provider-capability-manifests.md +204 -0
  81. package/docs/external/provider-catalog.md +139 -0
  82. package/docs/external/provider-rollout-testing.md +78 -0
  83. package/docs/external/research-results.md +48 -0
  84. package/docs/external/security-auth-permissions.md +81 -0
  85. package/docs/external/sync-state-machines.md +108 -0
  86. package/docs/external/unified-external-backend-model.md +107 -0
  87. package/docs/external/user-facing-changes.md +67 -0
  88. package/docs/gaps.md +161 -0
  89. package/docs/install.md +94 -0
  90. package/docs/integration-and-design-decisions.md +1530 -0
  91. package/docs/kradle-design.md +334 -0
  92. package/docs/local-minikube.md +55 -0
  93. package/docs/ontology/README.md +32 -0
  94. package/docs/ontology/bounded-contexts.md +29 -0
  95. package/docs/ontology/events-and-hooks.md +32 -0
  96. package/docs/ontology/oam-kubevela.md +32 -0
  97. package/docs/ontology/operations-and-release.md +25 -0
  98. package/docs/ontology/personas-and-actors.md +32 -0
  99. package/docs/ontology/policies-and-invariants.md +33 -0
  100. package/docs/ontology/problem-space.md +30 -0
  101. package/docs/ontology/resource-contracts.md +40 -0
  102. package/docs/ontology/resource-taxonomy.md +42 -0
  103. package/docs/ontology/runners-and-ci.md +29 -0
  104. package/docs/ontology/solution-space.md +24 -0
  105. package/docs/ontology/storage-and-data-boundaries.md +29 -0
  106. package/docs/ontology/validation-matrix.md +24 -0
  107. package/docs/ontology/web-ui-excellent-flows.md +32 -0
  108. package/docs/ontology/workflows.md +39 -0
  109. package/docs/ontology/world.md +35 -0
  110. package/docs/openapi.yaml +1291 -0
  111. package/docs/product-requirements.md +62 -0
  112. package/docs/requirements-v2.md +235 -0
  113. package/docs/roadmap-mvp.md +87 -0
  114. package/docs/sdk-api-reference.md +1108 -0
  115. package/docs/system-requirements.md +90 -0
  116. package/docs/system-spec-v2.md +1230 -0
  117. package/docs/tests/README.md +53 -0
  118. package/docs/tests/agent-qa-plan.md +63 -0
  119. package/docs/tests/browser-ui-tests.md +62 -0
  120. package/docs/tests/ci-quality-gates.md +48 -0
  121. package/docs/tests/coverage-model.md +64 -0
  122. package/docs/tests/e2e-scenario-tests.md +53 -0
  123. package/docs/tests/fixtures-test-data.md +63 -0
  124. package/docs/tests/observability-reliability-tests.md +54 -0
  125. package/docs/tests/product-test-matrix.md +145 -0
  126. package/docs/tests/qa-adoption-roadmap.md +130 -0
  127. package/docs/tests/qa-automation-plan.md +101 -0
  128. package/docs/tests/security-compliance-tests.md +57 -0
  129. package/docs/tests/test-framework-tools.md +88 -0
  130. package/docs/tests/test-suite-layout.md +121 -0
  131. package/docs/tests/unit-integration-tests.md +48 -0
  132. package/docs/todo-kyverno +714 -0
  133. package/docs/todos.md +4 -0
  134. package/docs/user-stories.md +78 -0
  135. package/docs/web-console-spec.md +533 -0
  136. package/examples/minikube-demo.yaml +190 -0
  137. package/examples/oam-application.yaml +23 -0
  138. package/examples/policy-kyverno-pr-title.yaml +18 -0
  139. package/package.json +66 -0
  140. package/scripts/build.mjs +29 -0
  141. package/scripts/setup-minikube.mjs +65 -0
  142. package/scripts/smoke.mjs +37 -0
  143. package/scripts/validate-doc-coverage.mjs +152 -0
  144. package/scripts/validate-package.mjs +95 -0
  145. package/scripts/validate-ui.mjs +305 -0
  146. package/src/agent-adapter-controller.js +169 -0
  147. package/src/agent-approval-controller.js +170 -0
  148. package/src/agent-context-bundles.js +242 -0
  149. package/src/agent-dispatch-controller.js +549 -0
  150. package/src/agent-gateway-config-controller.js +147 -0
  151. package/src/agent-identity-migration.js +115 -0
  152. package/src/agent-memory-controller.js +357 -0
  153. package/src/agent-memory-import.js +327 -0
  154. package/src/agent-memory-query.js +292 -0
  155. package/src/agent-memory-repository-source-controller.js +255 -0
  156. package/src/agent-mux-client.js +589 -0
  157. package/src/agent-permission-review.js +250 -0
  158. package/src/agent-persona-controller.js +135 -0
  159. package/src/agent-project-controller.js +117 -0
  160. package/src/agent-prompt-composition.js +55 -0
  161. package/src/agent-provider-config-controller.js +151 -0
  162. package/src/agent-secret-config-grant-controller.js +282 -0
  163. package/src/agent-session-transcript-controller.js +189 -0
  164. package/src/agent-stack-controller.js +421 -0
  165. package/src/agent-subagent-controller.js +160 -0
  166. package/src/agent-transport-binding-controller.js +121 -0
  167. package/src/agent-trigger-controller.js +387 -0
  168. package/src/agent-workspace-controller.js +702 -0
  169. package/src/agent-writeback-controller.js +302 -0
  170. package/src/api-controller.js +621 -0
  171. package/src/argocd-gitops.js +43 -0
  172. package/src/artifact-registry-controller.js +542 -0
  173. package/src/assistant-runtime.js +284 -0
  174. package/src/async-controller.js +207 -0
  175. package/src/audit-controller.js +191 -0
  176. package/src/auth.js +310 -0
  177. package/src/component-catalog.js +41 -0
  178. package/src/control-plane.js +136 -0
  179. package/src/controller-client.js +112 -0
  180. package/src/controller-ui.js +620 -0
  181. package/src/data-plane.js +179 -0
  182. package/src/event-bus.js +397 -0
  183. package/src/external/conflict-controller.js +225 -0
  184. package/src/external/github/auth.js +96 -0
  185. package/src/external/github/cicd.js +180 -0
  186. package/src/external/github/git-forge.js +240 -0
  187. package/src/external/github/index.js +144 -0
  188. package/src/external/github/issue-tracking.js +163 -0
  189. package/src/external/provider-adapter.js +161 -0
  190. package/src/external/provider-resource-factory.js +221 -0
  191. package/src/external/sync-controller.js +235 -0
  192. package/src/external/webhook-controller.js +144 -0
  193. package/src/external/write-controller.js +283 -0
  194. package/src/gitea-backend.js +131 -0
  195. package/src/gitea-service.js +173 -0
  196. package/src/handoff.js +98 -0
  197. package/src/health-probes.js +134 -0
  198. package/src/hooks-events.js +63 -0
  199. package/src/hooks-lifecycle.js +117 -0
  200. package/src/http-server.js +409 -0
  201. package/src/identity-policy.js +86 -0
  202. package/src/index.js +71 -0
  203. package/src/jitsi-agent-bridge.js +141 -0
  204. package/src/jitsi-meeting-controller.js +291 -0
  205. package/src/jitsi-sync-controller.js +198 -0
  206. package/src/kradle-inference-service-controller.js +246 -0
  207. package/src/kubernetes-controller-async.js +531 -0
  208. package/src/kubernetes-controller.js +904 -0
  209. package/src/kubernetes-resource-gateway.js +48 -0
  210. package/src/model-route-controller.js +364 -0
  211. package/src/notification-controller.js +178 -0
  212. package/src/operations.js +112 -0
  213. package/src/org-scoping.js +5 -0
  214. package/src/resource-model.js +282 -0
  215. package/src/runner-controller.js +272 -0
  216. package/src/runners-ci.js +48 -0
  217. package/src/runtime.js +196 -0
  218. package/src/snapshot-cache.js +157 -0
  219. package/src/virtual-model-controller.js +538 -0
  220. package/src/virtual-model-hook-bridge.js +200 -0
  221. package/src/web-ui.js +40 -0
  222. package/tests/agent-adapter-controller.test.js +361 -0
  223. package/tests/agent-approval-controller.test.js +173 -0
  224. package/tests/agent-context-bundles.test.js +278 -0
  225. package/tests/agent-dispatch-controller.test.js +679 -0
  226. package/tests/agent-gateway-config-controller.test.js +386 -0
  227. package/tests/agent-identity-migration.test.js +87 -0
  228. package/tests/agent-memory-controller.test.js +461 -0
  229. package/tests/agent-memory-import-snapshot.test.js +477 -0
  230. package/tests/agent-memory-query.test.js +404 -0
  231. package/tests/agent-memory-repository-source.test.js +514 -0
  232. package/tests/agent-mux-client.test.js +389 -0
  233. package/tests/agent-mux-integration.test.js +971 -0
  234. package/tests/agent-permission-review-v2.test.js +317 -0
  235. package/tests/agent-permission-review.test.js +209 -0
  236. package/tests/agent-persona-controller.test.js +127 -0
  237. package/tests/agent-project-controller.test.js +302 -0
  238. package/tests/agent-prompt-composition.test.js +76 -0
  239. package/tests/agent-provider-config-controller.test.js +376 -0
  240. package/tests/agent-resources.test.js +303 -0
  241. package/tests/agent-secret-config-grant.test.js +231 -0
  242. package/tests/agent-session-transcript-controller.test.js +499 -0
  243. package/tests/agent-stack-controller.test.js +283 -0
  244. package/tests/agent-subagent-controller.test.js +201 -0
  245. package/tests/agent-transport-binding-controller.test.js +294 -0
  246. package/tests/agent-trigger-controller.test.js +271 -0
  247. package/tests/agent-trigger-routes.test.js +190 -0
  248. package/tests/agent-trigger-sources.test.js +245 -0
  249. package/tests/agent-workspace-controller.test.js +181 -0
  250. package/tests/agent-writeback.test.js +292 -0
  251. package/tests/approval-persistence.test.js +171 -0
  252. package/tests/artifact-registry.test.js +511 -0
  253. package/tests/assistant-runtime.test.js +506 -0
  254. package/tests/async-controller.test.js +252 -0
  255. package/tests/audit-controller.test.js +227 -0
  256. package/tests/codespace-controller.test.js +318 -0
  257. package/tests/controller-client.test.js +133 -0
  258. package/tests/deployment.test.js +527 -0
  259. package/tests/e2e/lifecycle.test.js +120 -0
  260. package/tests/event-bus-integration.test.js +355 -0
  261. package/tests/external-github-forge.test.js +560 -0
  262. package/tests/external-github-issues-cicd.test.js +520 -0
  263. package/tests/external-integration.test.js +470 -0
  264. package/tests/external-persistence.test.js +415 -0
  265. package/tests/external-provider-adapter.test.js +365 -0
  266. package/tests/external-resource-model.test.js +223 -0
  267. package/tests/external-webhook-sync.test.js +287 -0
  268. package/tests/external-write-conflict.test.js +353 -0
  269. package/tests/gitea-service.test.js +253 -0
  270. package/tests/health-check-real.test.js +165 -0
  271. package/tests/health-probes.test.js +90 -0
  272. package/tests/hooks-lifecycle.test.js +364 -0
  273. package/tests/integration/full-flow.test.js +266 -0
  274. package/tests/jitsi-agent-bridge.test.js +119 -0
  275. package/tests/jitsi-helm-integration.test.js +77 -0
  276. package/tests/jitsi-meeting-controller.test.js +170 -0
  277. package/tests/jitsi-resource-model.test.js +73 -0
  278. package/tests/jitsi-sync-controller.test.js +112 -0
  279. package/tests/kradle-inference-service.test.js +689 -0
  280. package/tests/kradle.test.js +779 -0
  281. package/tests/memory-search-wiring.test.js +270 -0
  282. package/tests/model-route-controller.test.js +733 -0
  283. package/tests/notification-controller.test.js +196 -0
  284. package/tests/notification-integration.test.js +179 -0
  285. package/tests/org-scoping.test.js +687 -0
  286. package/tests/runner-controller.test.js +327 -0
  287. package/tests/runner-integration.test.js +231 -0
  288. package/tests/session-cookie-hmac.test.js +151 -0
  289. package/tests/snapshot-performance.test.js +315 -0
  290. package/tests/sse-events.test.js +107 -0
  291. package/tests/virtual-model-controller.test.js +877 -0
  292. package/tests/virtual-model-hook-bridge.test.js +384 -0
  293. package/tests/webhook-trigger.test.js +198 -0
  294. package/tests/workspace-volumes.test.js +312 -0
  295. package/tests/writeback-persistence.test.js +207 -0
@@ -0,0 +1,679 @@
1
+ import assert from 'node:assert/strict';
2
+ import test, { describe, it } from 'node:test';
3
+ import { createAgentDispatchController, createAgentMuxClient, createResource } from '../src/index.js';
4
+
5
+ function makeStack(name, spec = {}) {
6
+ return createResource('AgentStack', { name, namespace: 'kradle-org-default' }, {
7
+ organizationRef: 'default',
8
+ baseAgent: 'claude-code',
9
+ adapter: 'claude-code',
10
+ provider: 'anthropic',
11
+ runtimeIdentity: { serviceAccountRef: 'sa-default' },
12
+ ...spec
13
+ });
14
+ }
15
+
16
+ function createMockResourceGateway() {
17
+ const applied = [];
18
+ return {
19
+ applied,
20
+ async apply(resource) { applied.push(resource); return resource; },
21
+ async get() { return null; },
22
+ async delete() {},
23
+ };
24
+ }
25
+
26
+ function makeServiceAccount(name) {
27
+ return createResource('AgentServiceAccount', { name, namespace: 'kradle-org-default' }, {
28
+ organizationRef: 'default',
29
+ namespace: 'kradle-org-default',
30
+ serviceAccountName: name
31
+ });
32
+ }
33
+
34
+ function makeRoleBinding(name, subject) {
35
+ return createResource('AgentRoleBinding', { name, namespace: 'kradle-org-default' }, {
36
+ organizationRef: 'default',
37
+ subject,
38
+ roleRef: 'agent-developer',
39
+ scope: 'namespace'
40
+ });
41
+ }
42
+
43
+ function makeSecretGrant(name, subject, purpose) {
44
+ return createResource('AgentSecretGrant', { name, namespace: 'kradle-org-default' }, {
45
+ organizationRef: 'default',
46
+ subject,
47
+ secretRef: 'secret-' + purpose,
48
+ purpose
49
+ });
50
+ }
51
+
52
+ function buildValidResources(stackName) {
53
+ return {
54
+ AgentStack: [makeStack(stackName)],
55
+ AgentServiceAccount: [makeServiceAccount('sa-default')],
56
+ AgentRoleBinding: [makeRoleBinding('rb-1', 'sa-default')],
57
+ AgentSecretGrant: [makeSecretGrant('sg-model', 'sa-default', 'model-provider')]
58
+ };
59
+ }
60
+
61
+ function addIdentityResources(resources, { definitionName = 'aria-reviewer', personaName = 'aria', stackName = 'dispatch-stack' } = {}) {
62
+ return {
63
+ ...resources,
64
+ AgentDefinition: [
65
+ createResource('AgentDefinition', { name: definitionName, namespace: 'kradle-org-default' }, {
66
+ organizationRef: 'default',
67
+ personaRef: personaName,
68
+ stackRef: stackName,
69
+ roleContext: 'Review pull requests for authentication security.'
70
+ })
71
+ ],
72
+ AgentPersona: [
73
+ createResource('AgentPersona', { name: personaName, namespace: 'kradle-org-default' }, {
74
+ organizationRef: 'default',
75
+ displayName: 'Aria',
76
+ personality: { communicationStyle: 'direct', tone: 'professional' },
77
+ role: { title: 'Senior Reviewer', domain: 'Security' },
78
+ soul: { ref: 'aria-soul' }
79
+ })
80
+ ],
81
+ AgentSoul: [
82
+ createResource('AgentSoul', { name: 'aria-soul', namespace: 'kradle-org-default' }, {
83
+ organizationRef: 'default',
84
+ personaRef: personaName,
85
+ content: 'You are Aria, a security-focused code reviewer.'
86
+ })
87
+ ]
88
+ };
89
+ }
90
+
91
+ test('Successful dispatch with Agent Mux available', async () => {
92
+ // Use a mux client with a mock resource gateway so job submission succeeds
93
+ const gw = createMockResourceGateway();
94
+ const muxClient = createAgentMuxClient({ resourceGateway: gw });
95
+ const resources = buildValidResources('dispatch-stack');
96
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient });
97
+
98
+ const result = await controller.createManualDispatch({
99
+ repository: 'test-repo',
100
+ ref: 'main',
101
+ agentStack: 'dispatch-stack',
102
+ actor: 'test-user',
103
+ namespace: 'kradle-org-default',
104
+ organizationRef: 'default',
105
+ resources
106
+ });
107
+
108
+ assert.equal(result.error, false, 'Dispatch should succeed');
109
+ assert.ok(result.run, 'Result should include run resource');
110
+ assert.ok(result.attempt, 'Result should include attempt resource');
111
+ assert.ok(result.contextBundle, 'Result should include contextBundle resource');
112
+ assert.ok(result.permissionSnapshot, 'Result should include permissionSnapshot');
113
+ assert.equal(result.run.kind, 'AgentDispatchRun');
114
+ assert.equal(result.attempt.kind, 'AgentDispatchAttempt');
115
+ assert.equal(result.run.status.phase, 'Running', 'Run phase should be Running when job submits');
116
+ assert.ok(result.attempt.status.jobName, 'Attempt should have jobName');
117
+ assert.ok(result.attempt.status.jobSubmitted, 'Attempt should have jobSubmitted=true');
118
+ assert.ok(result.attempt.status.startedAt, 'Attempt should have startedAt timestamp');
119
+ assert.ok(result.run.spec.jobRef, 'Run should have jobRef');
120
+ assert.ok(result.jobResult, 'Result should include jobResult');
121
+ assert.equal(result.jobResult.submitted, true, 'Job should be submitted');
122
+ // Verify the K8s Job was applied to the gateway
123
+ assert.equal(gw.applied.length, 1, 'One Job should be applied');
124
+ assert.equal(gw.applied[0].kind, 'Job', 'Applied resource should be a Job');
125
+ });
126
+
127
+ test('Meeting-aware dispatch injects meeting context only for Jitsi-capable stacks', async () => {
128
+ const gw = createMockResourceGateway();
129
+ const muxClient = createAgentMuxClient({ resourceGateway: gw });
130
+ const resources = buildValidResources('meeting-stack');
131
+ resources.AgentStack[0].spec.jitsiCapability = true;
132
+ resources.AgentStack[0].spec.jitsiConfig = { participantName: 'Standup Bot', role: 'observer', capabilities: { chat: 'readwrite' } };
133
+ resources.JitsiMeeting = [
134
+ createResource('JitsiMeeting', { name: 'daily', namespace: 'kradle-org-default' }, {
135
+ organizationRef: 'default',
136
+ providerRef: 'jitsi-prod',
137
+ roomId: 'daily-default',
138
+ ttlMinutes: 30,
139
+ }, {
140
+ phase: 'Active',
141
+ roomUrl: 'https://meet.example/daily-default',
142
+ })
143
+ ];
144
+ const controller = createAgentDispatchController({
145
+ agentMuxClient: muxClient,
146
+ });
147
+
148
+ const result = await controller.createManualDispatch({
149
+ repository: 'test-repo',
150
+ ref: 'main',
151
+ agentStack: 'meeting-stack',
152
+ meetingRef: 'daily',
153
+ actor: 'test-user',
154
+ namespace: 'kradle-org-default',
155
+ organizationRef: 'default',
156
+ resources
157
+ });
158
+
159
+ assert.equal(result.error, false);
160
+ assert.equal(result.run.spec.meetingRef, 'daily');
161
+ assert.equal(result.run.spec.meetingContext.roomId, 'daily-default');
162
+ assert.equal(result.run.spec.meetingContext.jwt, undefined);
163
+ assert.ok(gw.applied[0].spec.template.spec.containers.some((container) => container.name === 'jitsi-agent-sidecar'));
164
+ const sidecar = gw.applied[0].spec.template.spec.containers.find((container) => container.name === 'jitsi-agent-sidecar');
165
+ assert.match(sidecar.env.find((entry) => entry.name === 'JITSI_JWT').value, /^kradle-jitsi\./);
166
+ });
167
+
168
+ test('Non-Jitsi stack with meetingRef is rejected before job creation', async () => {
169
+ const gw = createMockResourceGateway();
170
+ const muxClient = createAgentMuxClient({ resourceGateway: gw });
171
+ const resources = buildValidResources('plain-stack');
172
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient });
173
+
174
+ const result = await controller.createManualDispatch({
175
+ repository: 'test-repo',
176
+ ref: 'main',
177
+ agentStack: 'plain-stack',
178
+ meetingRef: 'daily',
179
+ actor: 'test-user',
180
+ namespace: 'kradle-org-default',
181
+ organizationRef: 'default',
182
+ resources
183
+ });
184
+
185
+ assert.equal(result.error, true);
186
+ assert.equal(result.reason, 'meeting-not-supported');
187
+ assert.equal(gw.applied.length, 0);
188
+ });
189
+
190
+ test('Dispatch with Agent Mux unavailable (no resource gateway)', async () => {
191
+ // When no resourceGateway is provided, job submission throws
192
+ const muxClient = createAgentMuxClient({});
193
+ const resources = buildValidResources('dispatch-stack');
194
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient });
195
+
196
+ const result = await controller.createManualDispatch({
197
+ repository: 'test-repo',
198
+ ref: 'main',
199
+ agentStack: 'dispatch-stack',
200
+ actor: 'test-user',
201
+ namespace: 'kradle-org-default',
202
+ organizationRef: 'default',
203
+ resources
204
+ });
205
+
206
+ assert.equal(result.error, false, 'Dispatch should still succeed (queued)');
207
+ assert.equal(result.run.status.phase, 'Queued', 'Run phase should be Queued when job submission fails');
208
+ assert.ok(result.run.status.conditions, 'Run should have conditions');
209
+ const jobCondition = result.run.status.conditions.find(c => c.type === 'JobSubmitted');
210
+ assert.ok(jobCondition, 'Should have JobSubmitted condition');
211
+ assert.equal(jobCondition.status, 'False', 'JobSubmitted should be False');
212
+ });
213
+
214
+ test('Dispatch denied by permission review', async () => {
215
+ const resources = {
216
+ AgentStack: [makeStack('denied-stack', { runtimeIdentity: { serviceAccountRef: 'sa-missing' } })],
217
+ // No service account, no role binding, no secret grant — permission review will deny
218
+ };
219
+ const controller = createAgentDispatchController();
220
+
221
+ const result = await controller.createManualDispatch({
222
+ repository: 'test-repo',
223
+ ref: 'main',
224
+ agentStack: 'denied-stack',
225
+ actor: 'test-user',
226
+ namespace: 'kradle-org-default',
227
+ organizationRef: 'default',
228
+ resources
229
+ });
230
+
231
+ assert.equal(result.error, true, 'Dispatch should be denied');
232
+ assert.equal(result.reason, 'permission-denied', 'Reason should be permission-denied');
233
+ assert.ok(result.review, 'Result should include the review details');
234
+ assert.equal(result.review.decision, 'denied');
235
+ });
236
+
237
+ test('Stack not found', async () => {
238
+ const resources = buildValidResources('existing-stack');
239
+ const controller = createAgentDispatchController();
240
+
241
+ const result = await controller.createManualDispatch({
242
+ repository: 'test-repo',
243
+ ref: 'main',
244
+ agentStack: 'nonexistent-stack',
245
+ actor: 'test-user',
246
+ namespace: 'kradle-org-default',
247
+ organizationRef: 'default',
248
+ resources
249
+ });
250
+
251
+ assert.equal(result.error, true, 'Dispatch should fail');
252
+ assert.equal(result.reason, 'stack-not-found', 'Reason should be stack-not-found');
253
+ assert.ok(result.message.includes('nonexistent-stack'), 'Message should name the missing stack');
254
+ });
255
+
256
+ test('AgentDefinition dispatch resolves to AgentStack before permission review and job creation', async () => {
257
+ const gw = createMockResourceGateway();
258
+ const muxClient = createAgentMuxClient({ resourceGateway: gw });
259
+ const resources = addIdentityResources(buildValidResources('definition-stack'), { stackName: 'definition-stack' });
260
+ const reviewed = [];
261
+ const permissionReviewer = {
262
+ reviewPermissions(input) {
263
+ reviewed.push(input);
264
+ return { decision: 'allow', reasons: [], grants: [], capabilities: {}, agentStack: input.agentStack };
265
+ },
266
+ createPermissionSnapshot(review) {
267
+ return { decision: review.decision, agentStack: review.agentStack };
268
+ }
269
+ };
270
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient, permissionReviewer });
271
+
272
+ const result = await controller.createManualDispatch({
273
+ repository: 'test-repo',
274
+ ref: 'main',
275
+ agentDefinition: 'aria-reviewer',
276
+ actor: 'test-user',
277
+ namespace: 'kradle-org-default',
278
+ organizationRef: 'default',
279
+ resources
280
+ });
281
+
282
+ assert.equal(result.error, false);
283
+ assert.equal(reviewed[0].agentStack, 'definition-stack');
284
+ assert.equal(result.run.spec.agentDefinition, 'aria-reviewer');
285
+ assert.equal(result.run.spec.agentStack, 'definition-stack');
286
+ assert.equal(result.attempt.spec.agentStackSnapshot.baseAgent, 'claude-code');
287
+ assert.equal(result.attempt.spec.agentDefinitionSnapshot.personaRef, 'aria');
288
+ assert.equal(result.attempt.spec.agentPersonaSnapshot.displayName, 'Aria');
289
+ assert.ok(gw.applied[0].spec.template.spec.containers[0].env.some((entry) => entry.name === 'KRADLE_STACK_NAME' && entry.value === 'definition-stack'));
290
+ });
291
+
292
+ test('AgentDefinition dispatch composes identity prompt for the job while legacy stack dispatch stays unchanged', async () => {
293
+ const gw = createMockResourceGateway();
294
+ const stackWithPrompt = makeStack('prompt-stack', { systemPrompt: 'Legacy stack system.', developerPrompt: 'Legacy developer.', taskPrompt: 'Legacy task.' });
295
+ const resources = addIdentityResources({
296
+ AgentStack: [stackWithPrompt],
297
+ AgentServiceAccount: [makeServiceAccount('sa-default')],
298
+ AgentRoleBinding: [makeRoleBinding('rb-1', 'sa-default')],
299
+ AgentSecretGrant: [makeSecretGrant('sg-model', 'sa-default', 'model-provider')]
300
+ }, { stackName: 'prompt-stack' });
301
+ const controller = createAgentDispatchController({ agentMuxClient: createAgentMuxClient({ resourceGateway: gw }) });
302
+
303
+ const result = await controller.createManualDispatch({
304
+ repository: 'test-repo',
305
+ ref: 'main',
306
+ agentDefinition: 'aria-reviewer',
307
+ actor: 'test-user',
308
+ namespace: 'kradle-org-default',
309
+ organizationRef: 'default',
310
+ resources
311
+ });
312
+
313
+ assert.equal(result.error, false);
314
+ assert.ok(result.executionConfig.prompt.system.includes('You are Aria, a security-focused code reviewer.'));
315
+ assert.ok(result.executionConfig.prompt.system.includes('Aria'));
316
+ assert.ok(result.executionConfig.prompt.system.includes('Review pull requests for authentication security.'));
317
+ assert.ok(result.executionConfig.prompt.system.includes('Legacy stack system.'));
318
+ assert.equal(result.executionConfig.prompt.developer, 'Legacy developer.');
319
+ assert.equal(result.executionConfig.prompt.task, 'Legacy task.');
320
+ });
321
+
322
+ test('Legacy stack dispatch emits deprecation warning for inline identity fields', async () => {
323
+ const warnings = [];
324
+ const gw = createMockResourceGateway();
325
+ const resources = buildValidResources('legacy-prompt-stack');
326
+ resources.AgentStack[0].spec.systemPrompt = 'Legacy identity prompt.';
327
+ const controller = createAgentDispatchController({
328
+ agentMuxClient: createAgentMuxClient({ resourceGateway: gw }),
329
+ logger: { warn(message) { warnings.push(message); } },
330
+ });
331
+
332
+ const result = await controller.createManualDispatch({
333
+ repository: 'test-repo',
334
+ ref: 'main',
335
+ agentStack: 'legacy-prompt-stack',
336
+ actor: 'test-user',
337
+ namespace: 'kradle-org-default',
338
+ organizationRef: 'default',
339
+ resources
340
+ });
341
+
342
+ assert.equal(result.error, false);
343
+ assert.equal(warnings.length, 1);
344
+ assert.match(warnings[0], /AgentStack "legacy-prompt-stack" has inline prompts or skills/);
345
+ assert.deepEqual(result.warnings, warnings);
346
+ });
347
+
348
+ test('Context bundle referenced correctly', async () => {
349
+ const muxClient = createAgentMuxClient({ gateway: '', enabled: false });
350
+ const resources = buildValidResources('ref-stack');
351
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient });
352
+
353
+ const result = await controller.createManualDispatch({
354
+ repository: 'test-repo',
355
+ ref: 'main',
356
+ agentStack: 'ref-stack',
357
+ actor: 'test-user',
358
+ namespace: 'kradle-org-default',
359
+ organizationRef: 'default',
360
+ resources
361
+ });
362
+
363
+ assert.equal(result.error, false, 'Dispatch should succeed');
364
+ assert.ok(result.contextBundle.spec.digest, 'Context bundle should have a digest');
365
+ assert.equal(
366
+ result.attempt.spec.contextBundleDigest,
367
+ result.contextBundle.spec.digest,
368
+ 'Attempt contextBundleDigest should match context bundle digest'
369
+ );
370
+ assert.equal(
371
+ result.run.spec.contextBundleRef,
372
+ result.contextBundle.metadata.name,
373
+ 'Run contextBundleRef should match context bundle name'
374
+ );
375
+ });
376
+
377
+ function makeMemoryRepository(name) {
378
+ return createResource('AgentMemoryRepository', { name, namespace: 'kradle-org-default' }, {
379
+ organizationRef: 'default',
380
+ repositoryRef: 'memory-repo',
381
+ defaultBranch: 'main',
382
+ layoutProfile: 'standard',
383
+ });
384
+ }
385
+
386
+ test('Dispatch with AgentMemoryRepository creates memorySnapshot', async () => {
387
+ const muxClient = createAgentMuxClient({ gateway: '', enabled: false });
388
+ const resources = {
389
+ ...buildValidResources('mem-stack'),
390
+ AgentMemoryRepository: [makeMemoryRepository('org-memory')],
391
+ };
392
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient });
393
+
394
+ const result = await controller.createManualDispatch({
395
+ repository: 'test-repo',
396
+ ref: 'main',
397
+ agentStack: 'mem-stack',
398
+ actor: 'test-user',
399
+ namespace: 'kradle-org-default',
400
+ organizationRef: 'default',
401
+ resources,
402
+ });
403
+
404
+ assert.equal(result.error, false, 'Dispatch should succeed');
405
+ assert.ok(result.memorySnapshot, 'Result should include memorySnapshot');
406
+ assert.equal(result.memorySnapshot.kind, 'AgentMemorySnapshot', 'memorySnapshot should be an AgentMemorySnapshot');
407
+ assert.equal(result.memorySnapshot.spec.memoryRepository, 'org-memory', 'memorySnapshot should reference the memory repo');
408
+ assert.equal(result.memorySnapshot.status.phase, 'Pinned', 'memorySnapshot should be Pinned');
409
+ assert.equal(result.run.spec.memorySnapshotRef, result.memorySnapshot.metadata.name, 'Run should reference memorySnapshot');
410
+ });
411
+
412
+ test('Dispatch without memory repos has null memorySnapshot', async () => {
413
+ const muxClient = createAgentMuxClient({ gateway: '', enabled: false });
414
+ const resources = buildValidResources('no-mem-stack');
415
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient });
416
+
417
+ const result = await controller.createManualDispatch({
418
+ repository: 'test-repo',
419
+ ref: 'main',
420
+ agentStack: 'no-mem-stack',
421
+ actor: 'test-user',
422
+ namespace: 'kradle-org-default',
423
+ organizationRef: 'default',
424
+ resources,
425
+ });
426
+
427
+ assert.equal(result.error, false, 'Dispatch should succeed');
428
+ assert.equal(result.memorySnapshot, null, 'memorySnapshot should be null when no AgentMemoryRepository');
429
+ assert.equal(result.run.spec.memorySnapshotRef, undefined, 'Run should not have memorySnapshotRef');
430
+ });
431
+
432
+ test('Dispatch with requires-approval returns early with awaitingApproval', async () => {
433
+ // Build resources where the secret grant has requiredApproval set,
434
+ // which causes the permission reviewer to return 'requires-approval'
435
+ const stack = makeStack('approval-stack');
436
+ const resources = {
437
+ AgentStack: [stack],
438
+ AgentServiceAccount: [makeServiceAccount('sa-default')],
439
+ AgentRoleBinding: [makeRoleBinding('rb-1', 'sa-default')],
440
+ AgentSecretGrant: [makeSecretGrant('sg-model', 'sa-default', 'model-provider')],
441
+ };
442
+ // Add requiredApproval to the secret grant so permission review returns 'requires-approval'
443
+ resources.AgentSecretGrant[0].spec.requiredApproval = 'manager';
444
+
445
+ const controller = createAgentDispatchController();
446
+
447
+ const result = await controller.createManualDispatch({
448
+ repository: 'test-repo',
449
+ ref: 'main',
450
+ agentStack: 'approval-stack',
451
+ actor: 'test-user',
452
+ namespace: 'kradle-org-default',
453
+ organizationRef: 'default',
454
+ resources,
455
+ });
456
+
457
+ assert.equal(result.error, false, 'Dispatch should not error — it is awaiting approval');
458
+ assert.equal(result.awaitingApproval, true, 'Result should indicate awaitingApproval');
459
+ assert.equal(result.run.status.phase, 'AwaitingApproval', 'Run phase should be AwaitingApproval');
460
+ assert.ok(result.approval, 'Result should include the approval resource');
461
+ assert.equal(result.approval.kind, 'AgentApproval', 'Approval should be an AgentApproval resource');
462
+ assert.equal(result.approval.status.phase, 'Pending', 'Approval should be in Pending phase');
463
+ assert.ok(result.permissionSnapshot, 'Result should include permissionSnapshot');
464
+ // No contextBundle or attempt since we returned early
465
+ assert.equal(result.contextBundle, undefined, 'No contextBundle when awaiting approval');
466
+ assert.equal(result.attempt, undefined, 'No attempt when awaiting approval');
467
+ });
468
+
469
+ test('Successful launch creates jobRef on run', async () => {
470
+ const gw = createMockResourceGateway();
471
+ const muxClient = createAgentMuxClient({ resourceGateway: gw });
472
+ const resources = buildValidResources('transcript-stack');
473
+ const controller = createAgentDispatchController({ agentMuxClient: muxClient });
474
+
475
+ const result = await controller.createManualDispatch({
476
+ repository: 'test-repo',
477
+ ref: 'main',
478
+ agentStack: 'transcript-stack',
479
+ actor: 'test-user',
480
+ namespace: 'kradle-org-default',
481
+ organizationRef: 'default',
482
+ resources,
483
+ });
484
+
485
+ assert.equal(result.error, false, 'Dispatch should succeed');
486
+ assert.equal(result.run.status.phase, 'Running', 'Run phase should be Running');
487
+ assert.ok(result.run.spec.jobRef, 'Run should have jobRef in spec');
488
+ assert.ok(result.attempt.status.jobName, 'Attempt should have jobName');
489
+ assert.equal(result.attempt.status.jobSubmitted, true, 'Job should be submitted');
490
+ assert.equal(result.jobResult.submitted, true, 'jobResult.submitted should be true');
491
+ });
492
+
493
+ // ─── Budget Enforcement Tests ───────────────────────────────────────────────
494
+
495
+ describe('checkBudget', () => {
496
+ const controller = createAgentDispatchController();
497
+
498
+ function makeRunWithBudget(name, budget = {}) {
499
+ const run = createResource('AgentDispatchRun', { name, namespace: 'default' }, {
500
+ organizationRef: 'default', repository: 'repo', sourceRefs: [], agentStack: 'stack', taskKind: 'diagnostic',
501
+ budget,
502
+ });
503
+ run.status = { phase: 'Running' };
504
+ return run;
505
+ }
506
+
507
+ it('returns exceeded=false when no budget set and event has no usage', () => {
508
+ const run = makeRunWithBudget('r1');
509
+ const result = controller.checkBudget(run, { type: 'message', role: 'assistant', content: 'hi' });
510
+ assert.equal(result.exceeded, false);
511
+ });
512
+
513
+ it('returns exceeded=false when tokens under maxTokens limit', () => {
514
+ const run = makeRunWithBudget('r2', { maxTokens: 10000 });
515
+ const result = controller.checkBudget(run, { usage: { inputTokens: 100, outputTokens: 50 } });
516
+ assert.equal(result.exceeded, false);
517
+ assert.equal(result.totalTokens, 150);
518
+ });
519
+
520
+ it('returns exceeded=true reason=token_limit when tokens exceed maxTokens', () => {
521
+ const run = makeRunWithBudget('r3', { maxTokens: 100 });
522
+ const result = controller.checkBudget(run, { usage: { inputTokens: 80, outputTokens: 50 } });
523
+ assert.equal(result.exceeded, true);
524
+ assert.equal(result.reason, 'token_limit');
525
+ assert.equal(result.current, 130);
526
+ assert.equal(result.limit, 100);
527
+ });
528
+
529
+ it('returns exceeded=false when cost under maxCostUsd', () => {
530
+ const run = makeRunWithBudget('r4', { maxCostUsd: 1.0 });
531
+ // claude-sonnet: 0.003/1k input, 0.015/1k output → 100 input + 50 output = 0.0003 + 0.00075 = ~$0.00105
532
+ const result = controller.checkBudget(run, { usage: { inputTokens: 100, outputTokens: 50 } });
533
+ assert.equal(result.exceeded, false);
534
+ });
535
+
536
+ it('returns exceeded=true reason=cost_limit when cost exceeds maxCostUsd', () => {
537
+ const run = makeRunWithBudget('r5', { maxCostUsd: 0.001 });
538
+ // 1M input tokens at $0.003/1k = $3.00 → exceeds $0.001
539
+ const result = controller.checkBudget(run, { usage: { inputTokens: 1000000, outputTokens: 0 } });
540
+ assert.equal(result.exceeded, true);
541
+ assert.equal(result.reason, 'cost_limit');
542
+ });
543
+
544
+ it('accumulates existing run.status.tokenUsage.totalTokens with event tokens', () => {
545
+ const run = makeRunWithBudget('r6', { maxTokens: 10000 });
546
+ run.status.tokenUsage = { inputTokens: 900, outputTokens: 100, totalTokens: 1000 };
547
+ const result = controller.checkBudget(run, { usage: { inputTokens: 50, outputTokens: 50 } });
548
+ assert.equal(result.exceeded, false);
549
+ assert.equal(result.totalTokens, 1100);
550
+ });
551
+
552
+ it('accumulates existing run.status.costUsd with event cost', () => {
553
+ const run = makeRunWithBudget('r7', { maxCostUsd: 100 });
554
+ run.status.costUsd = 50;
555
+ const result = controller.checkBudget(run, { usage: { inputTokens: 1000, outputTokens: 500 } });
556
+ assert.equal(result.exceeded, false);
557
+ assert.ok(result.totalCost > 50, 'totalCost should include existing costUsd');
558
+ });
559
+
560
+ it('uses Infinity limits when budget not set in spec', () => {
561
+ const run = makeRunWithBudget('r8');
562
+ // Very large usage — should not exceed Infinity
563
+ const result = controller.checkBudget(run, { usage: { inputTokens: 999999999, outputTokens: 999999999 } });
564
+ assert.equal(result.exceeded, false);
565
+ });
566
+ });
567
+
568
+ describe('persistSessionEvent — budget enforcement', () => {
569
+ const controller = createAgentDispatchController();
570
+
571
+ function makeRunWithBudget(name, budget = {}) {
572
+ const run = createResource('AgentDispatchRun', { name, namespace: 'default' }, {
573
+ organizationRef: 'default', repository: 'repo', sourceRefs: [], agentStack: 'stack', taskKind: 'diagnostic',
574
+ budget,
575
+ });
576
+ run.status = { phase: 'Running' };
577
+ return run;
578
+ }
579
+
580
+ function makeAttempt(runName) {
581
+ const attempt = createResource('AgentDispatchAttempt', { name: `${runName}-attempt-1` }, {
582
+ organizationRef: 'default', agentDispatchRun: runName, attemptReason: 'initial', agentStackSnapshot: {},
583
+ });
584
+ attempt.status = { agentMuxSessionId: 'sess-1', agentMuxRunId: 'amux-1' };
585
+ return attempt;
586
+ }
587
+
588
+ it('returns Failed run with failureReason=budget_exceeded when token limit exceeded', () => {
589
+ const run = makeRunWithBudget('bp1', { maxTokens: 10 });
590
+ const attempt = makeAttempt('bp1');
591
+ const result = controller.persistSessionEvent(
592
+ { type: 'message', role: 'assistant', content: 'hi', usage: { inputTokens: 5, outputTokens: 10 } },
593
+ run, attempt
594
+ );
595
+ assert.equal(result.run.status.phase, 'Failed');
596
+ assert.equal(result.run.status.failureReason, 'budget_exceeded');
597
+ assert.ok(result.run.status.budgetExceeded);
598
+ assert.equal(result.run.status.budgetExceeded.reason, 'token_limit');
599
+ });
600
+
601
+ it('budget-exceeded notification has reason=budget_exceeded', () => {
602
+ const run = makeRunWithBudget('bp2', { maxTokens: 1 });
603
+ const attempt = makeAttempt('bp2');
604
+ const result = controller.persistSessionEvent(
605
+ { type: 'message', role: 'assistant', content: 'hi', usage: { inputTokens: 5, outputTokens: 5 } },
606
+ run, attempt
607
+ );
608
+ assert.ok(result.notification, 'notification should be returned');
609
+ assert.equal(result.notification.reason, 'budget_exceeded');
610
+ assert.equal(result.notification.status, 'failed');
611
+ });
612
+
613
+ it('normal event with usage accumulates tokenUsage on run.status', () => {
614
+ const run = makeRunWithBudget('bp3', { maxTokens: 100000 });
615
+ const attempt = makeAttempt('bp3');
616
+ controller.persistSessionEvent(
617
+ { type: 'message', role: 'assistant', content: 'hello', usage: { inputTokens: 100, outputTokens: 50 } },
618
+ run, attempt
619
+ );
620
+ assert.ok(run.status.tokenUsage, 'run.status.tokenUsage should be set');
621
+ assert.equal(run.status.tokenUsage.inputTokens, 100);
622
+ assert.equal(run.status.tokenUsage.outputTokens, 50);
623
+ assert.equal(run.status.tokenUsage.totalTokens, 150);
624
+ });
625
+
626
+ it('event with usage accumulates costUsd on run.status', () => {
627
+ const run = makeRunWithBudget('bp4', { maxCostUsd: 1000 });
628
+ const attempt = makeAttempt('bp4');
629
+ controller.persistSessionEvent(
630
+ { type: 'message', role: 'assistant', content: 'hello', usage: { inputTokens: 1000, outputTokens: 500 }, model: 'claude-sonnet-4-20250514' },
631
+ run, attempt
632
+ );
633
+ // 1000 input at $0.003/1k + 500 output at $0.015/1k = $0.003 + $0.0075 = $0.0105
634
+ assert.ok(run.status.costUsd > 0, 'run.status.costUsd should be set');
635
+ assert.ok(Math.abs(run.status.costUsd - 0.0105) < 0.0001, `costUsd should be ~0.0105 but got ${run.status.costUsd}`);
636
+ });
637
+
638
+ it('budget check prevents event from being appended when limit exceeded', () => {
639
+ const run = makeRunWithBudget('bp5', { maxTokens: 10 });
640
+ const attempt = makeAttempt('bp5');
641
+ // Pre-set transcript to check it is NOT mutated
642
+ const existingTranscript = { spec: { messages: [] } };
643
+ const result = controller.persistSessionEvent(
644
+ { type: 'message', role: 'assistant', content: 'hi', usage: { inputTokens: 100, outputTokens: 100 } },
645
+ run, attempt, { transcript: existingTranscript }
646
+ );
647
+ // Budget exceeded before appending — messages should still be empty
648
+ assert.equal(result.transcript.spec.messages.length, 0);
649
+ assert.equal(result.run.status.phase, 'Failed');
650
+ });
651
+ });
652
+
653
+ describe('estimateCost — via checkBudget', () => {
654
+ const controller = createAgentDispatchController();
655
+
656
+ function makeRunWithBudget(name, budget = {}) {
657
+ const run = createResource('AgentDispatchRun', { name, namespace: 'default' }, {
658
+ organizationRef: 'default', repository: 'repo', sourceRefs: [], agentStack: 'stack', taskKind: 'diagnostic', budget,
659
+ });
660
+ run.status = { phase: 'Running' };
661
+ return run;
662
+ }
663
+
664
+ it('cost is 0 when event has no usage', () => {
665
+ const run = makeRunWithBudget('ec1');
666
+ const result = controller.checkBudget(run, { type: 'message', role: 'assistant', content: 'hi' });
667
+ assert.equal(result.totalCost, 0);
668
+ });
669
+
670
+ it('cost computed correctly for claude-sonnet-4-20250514 rates', () => {
671
+ const run = makeRunWithBudget('ec2');
672
+ // 1000 input @ $0.003/1k + 1000 output @ $0.015/1k = $0.003 + $0.015 = $0.018
673
+ const result = controller.checkBudget(run, {
674
+ usage: { inputTokens: 1000, outputTokens: 1000 },
675
+ model: 'claude-sonnet-4-20250514',
676
+ });
677
+ assert.ok(Math.abs(result.totalCost - 0.018) < 0.0001, `totalCost should be ~0.018 but got ${result.totalCost}`);
678
+ });
679
+ });