@minicor/mcp-server 4.8.0 → 4.9.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 (37) hide show
  1. package/README.md +82 -3
  2. package/dist/__live__/blueprints.live.test.d.ts +2 -0
  3. package/dist/__live__/blueprints.live.test.d.ts.map +1 -0
  4. package/dist/__live__/blueprints.live.test.js +610 -0
  5. package/dist/__live__/blueprints.live.test.js.map +1 -0
  6. package/dist/__tests__/blueprints-tools.test.d.ts +2 -0
  7. package/dist/__tests__/blueprints-tools.test.d.ts.map +1 -0
  8. package/dist/__tests__/blueprints-tools.test.js +1190 -0
  9. package/dist/__tests__/blueprints-tools.test.js.map +1 -0
  10. package/dist/__tests__/jobs-tools.test.js +76 -0
  11. package/dist/__tests__/jobs-tools.test.js.map +1 -1
  12. package/dist/__tests__/server-surface.test.js +19 -0
  13. package/dist/__tests__/server-surface.test.js.map +1 -1
  14. package/dist/bootstrap-perms.d.ts +12 -0
  15. package/dist/bootstrap-perms.d.ts.map +1 -0
  16. package/dist/bootstrap-perms.js +92 -0
  17. package/dist/bootstrap-perms.js.map +1 -0
  18. package/dist/bootstrap.js +2 -63
  19. package/dist/bootstrap.js.map +1 -1
  20. package/dist/middleware-service-client.d.ts +286 -0
  21. package/dist/middleware-service-client.d.ts.map +1 -1
  22. package/dist/middleware-service-client.js +143 -1
  23. package/dist/middleware-service-client.js.map +1 -1
  24. package/dist/server-surface.d.ts.map +1 -1
  25. package/dist/server-surface.js +26 -1
  26. package/dist/server-surface.js.map +1 -1
  27. package/dist/sync.js +5 -2
  28. package/dist/sync.js.map +1 -1
  29. package/dist/tools/blueprints.d.ts +20 -0
  30. package/dist/tools/blueprints.d.ts.map +1 -0
  31. package/dist/tools/blueprints.js +1193 -0
  32. package/dist/tools/blueprints.js.map +1 -0
  33. package/dist/tools/jobs.d.ts.map +1 -1
  34. package/dist/tools/jobs.js +57 -5
  35. package/dist/tools/jobs.js.map +1 -1
  36. package/package.json +2 -1
  37. package/skills/general/blueprint-vm-walkthrough.md +162 -0
@@ -0,0 +1,1190 @@
1
+ /**
2
+ * Tool-level tests for the blueprint suite: resolve_blueprint_state's
3
+ * decision table (unfolded states -> next calls), the baseVersion CAS 409
4
+ * surfacing, the synthesize tri-state 409 classification, the apply-proposal
5
+ * swap guard, answer-question convergence, and blueprint_build's needs_input
6
+ * precondition.
7
+ *
8
+ * Uses an in-memory transport with a mocked fetch that routes by URL,
9
+ * mirroring jobs-tools.test.ts.
10
+ */
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { createMinicorServer } from "../lib.js";
15
+ const MW_BASE = "https://mw.test";
16
+ const BP = "/api/workspaces/11/blueprints";
17
+ function jsonResponse(body, status = 200) {
18
+ return new Response(JSON.stringify(body), {
19
+ status,
20
+ headers: { "Content-Type": "application/json" },
21
+ });
22
+ }
23
+ async function startServer() {
24
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
25
+ const embedded = await createMinicorServer(serverTransport, {
26
+ localTools: false,
27
+ credentials: {
28
+ access_token: "test-token",
29
+ refresh_token: "test-refresh-token",
30
+ expires_at: Date.now() + 3_600_000,
31
+ },
32
+ baseUrl: "https://api.test.com",
33
+ codeMode: false,
34
+ });
35
+ const client = new Client({ name: "test-client", version: "1.0.0" });
36
+ await client.connect(clientTransport);
37
+ return { client, embedded };
38
+ }
39
+ function parseToolJson(result) {
40
+ return JSON.parse(result.content[0].text);
41
+ }
42
+ /** Route a mocked fetch by URL suffix (query stripped). */
43
+ function routedFetch(routes) {
44
+ return vi.fn(async (input) => {
45
+ const path = new URL(String(input)).pathname;
46
+ for (const [suffix, body] of Object.entries(routes)) {
47
+ if (path.endsWith(suffix))
48
+ return jsonResponse(body);
49
+ }
50
+ throw new Error(`Unexpected fetch: ${String(input)}`);
51
+ });
52
+ }
53
+ /** A GET /blueprints/:id response (Blueprint & BlueprintStateInfo). */
54
+ function bpFixture(overrides = {}) {
55
+ return {
56
+ id: "bp-1",
57
+ workspaceId: 11,
58
+ name: "COI intake",
59
+ document: { goal: "", steps: [], openQuestions: [] },
60
+ librarySeq: 0,
61
+ specBasedOnSeq: 0,
62
+ lastSyncedVersion: null,
63
+ pendingProposal: null,
64
+ middlewareId: null,
65
+ routeId: null,
66
+ buildId: null,
67
+ state: "never_synced",
68
+ specStale: false,
69
+ jobStale: false,
70
+ jobEditedDirectly: false,
71
+ hasPendingProposal: false,
72
+ buildStatus: null,
73
+ headVersion: 0,
74
+ drift: { newEntries: [], unsyncedVersions: [] },
75
+ ...overrides,
76
+ };
77
+ }
78
+ describe("blueprint tools", () => {
79
+ const savedMwUrl = process.env.MIDDLEWARE_SERVICE_URL;
80
+ beforeEach(() => {
81
+ process.env.MIDDLEWARE_SERVICE_URL = MW_BASE;
82
+ });
83
+ afterEach(() => {
84
+ if (savedMwUrl === undefined)
85
+ delete process.env.MIDDLEWARE_SERVICE_URL;
86
+ else
87
+ process.env.MIDDLEWARE_SERVICE_URL = savedMwUrl;
88
+ vi.unstubAllGlobals();
89
+ });
90
+ // ── baseVersion CAS ────────────────────────────────────────
91
+ it("blueprint_update surfaces the baseVersion CAS 409 as a structured version_conflict", async () => {
92
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse({ message: "Blueprint has moved to version 7 (edit was based on 4)" }, 409));
93
+ vi.stubGlobal("fetch", fetchMock);
94
+ const { client, embedded } = await startServer();
95
+ try {
96
+ const result = await client.callTool({
97
+ name: "blueprint_update",
98
+ arguments: {
99
+ workspaceId: 11,
100
+ blueprintId: "bp-1",
101
+ patch: { goal: "New goal" },
102
+ baseVersion: 4,
103
+ },
104
+ });
105
+ const [url, init] = fetchMock.mock.calls[0];
106
+ expect(String(url)).toBe(`${MW_BASE}${BP}/bp-1`);
107
+ expect(init.method).toBe("PATCH");
108
+ expect(JSON.parse(String(init.body)).baseVersion).toBe(4);
109
+ const payload = parseToolJson(result);
110
+ expect(payload.status).toBe("blocked");
111
+ expect(payload.blocked.reason).toBe("version_conflict");
112
+ expect(payload.blocked.retryable).toBe(true);
113
+ expect(payload.nextActions.join("\n")).toContain("blueprint_get");
114
+ }
115
+ finally {
116
+ await client.close();
117
+ await embedded.close();
118
+ }
119
+ });
120
+ it("blueprint_update refuses document edits without baseVersion (CAS protocol)", async () => {
121
+ const fetchMock = vi.fn();
122
+ vi.stubGlobal("fetch", fetchMock);
123
+ const { client, embedded } = await startServer();
124
+ try {
125
+ const result = await client.callTool({
126
+ name: "blueprint_update",
127
+ arguments: {
128
+ workspaceId: 11,
129
+ blueprintId: "bp-1",
130
+ patch: { goal: "New goal" },
131
+ },
132
+ });
133
+ // Guarded at the tool layer — no request reaches the service.
134
+ expect(fetchMock).not.toHaveBeenCalled();
135
+ const payload = parseToolJson(result);
136
+ expect(payload.status).toBe("blocked");
137
+ expect(payload.blocked.reason).toBe("base_version_required");
138
+ expect(payload.nextActions.join("\n")).toContain("blueprint_get");
139
+ }
140
+ finally {
141
+ await client.close();
142
+ await embedded.close();
143
+ }
144
+ });
145
+ it("blueprint_update allows a conflict-free rename without baseVersion", async () => {
146
+ const fetchMock = vi.fn(async (_input, init) => init?.method === "PATCH"
147
+ ? jsonResponse(bpFixture({ name: "Renamed", headVersion: 2, state: "never_synced" }))
148
+ : jsonResponse(bpFixture({ name: "Renamed", headVersion: 2 })));
149
+ vi.stubGlobal("fetch", fetchMock);
150
+ const { client, embedded } = await startServer();
151
+ try {
152
+ const result = await client.callTool({
153
+ name: "blueprint_update",
154
+ arguments: { workspaceId: 11, blueprintId: "bp-1", name: "Renamed" },
155
+ });
156
+ const payload = parseToolJson(result);
157
+ expect(payload.status).toBe("updated");
158
+ }
159
+ finally {
160
+ await client.close();
161
+ await embedded.close();
162
+ }
163
+ });
164
+ // ── synthesize 409 classification ──────────────────────────
165
+ // These bodies carry NO `code` — they are the legacy string-matching
166
+ // fallback for deployments that predate structured error codes.
167
+ it.each([
168
+ [
169
+ "A proposal is already pending review — apply or reject it first (or pass replace=true)",
170
+ "proposal_pending",
171
+ true,
172
+ ],
173
+ [
174
+ "2 entries are still being enriched — retry when they are ready",
175
+ "entries_enriching",
176
+ true,
177
+ ],
178
+ [
179
+ "Synthesis is not configured (MINICOR_LLMS_API_KEY missing)",
180
+ "llm_unconfigured",
181
+ false,
182
+ ],
183
+ ])("blueprint_synthesize classifies the 409 '%s' as %s", async (message, reason, retryable) => {
184
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse({ message }, 409));
185
+ vi.stubGlobal("fetch", fetchMock);
186
+ const { client, embedded } = await startServer();
187
+ try {
188
+ const result = await client.callTool({
189
+ name: "blueprint_synthesize",
190
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
191
+ });
192
+ const payload = parseToolJson(result);
193
+ expect(payload.status).toBe("blocked");
194
+ expect(payload.blocked.reason).toBe(reason);
195
+ expect(payload.blocked.retryable).toBe(retryable);
196
+ expect(payload.nextActions.length).toBeGreaterThan(0);
197
+ }
198
+ finally {
199
+ await client.close();
200
+ await embedded.close();
201
+ }
202
+ });
203
+ it.each([
204
+ // The code drives classification even when the message is reworded —
205
+ // the strings below would NOT match the legacy fallback patterns.
206
+ ["proposal_pending", 409, "a draft awaits your verdict", "proposal_pending", true],
207
+ ["entries_enriching", 409, "the library is still cooking", "entries_enriching", true],
208
+ ["synthesis_not_configured", 409, "no LLM key on this deployment", "llm_unconfigured", false],
209
+ ["llm_gateway_failed", 502, "upstream fell over", "llm_gateway_failed", true],
210
+ ["synthesis_unparseable", 502, "model returned prose", "synthesis_unparseable", true],
211
+ ["patch_shape_invalid", 502, "patch broke the document shape", "patch_shape_invalid", true],
212
+ ])("blueprint_synthesize classifies code %s (HTTP %i) structurally, ignoring the message", async (code, status, message, reason, retryable) => {
213
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse({ code, message }, status));
214
+ vi.stubGlobal("fetch", fetchMock);
215
+ const { client, embedded } = await startServer();
216
+ try {
217
+ const result = await client.callTool({
218
+ name: "blueprint_synthesize",
219
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
220
+ });
221
+ const payload = parseToolJson(result);
222
+ expect(payload.status).toBe("blocked");
223
+ expect(payload.blocked.reason).toBe(reason);
224
+ expect(payload.blocked.retryable).toBe(retryable);
225
+ expect(payload.nextActions.length).toBeGreaterThan(0);
226
+ }
227
+ finally {
228
+ await client.close();
229
+ await embedded.close();
230
+ }
231
+ });
232
+ it("blueprint_synthesize returns the pending proposal with prefilled apply coordinates", async () => {
233
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse(bpFixture({
234
+ hasPendingProposal: true,
235
+ pendingProposal: {
236
+ patch: {},
237
+ basedOnSeq: 6,
238
+ baseVersion: 3,
239
+ summary: "Added refund flow",
240
+ citations: [],
241
+ createdAt: "2026-08-12T00:00:00Z",
242
+ },
243
+ })));
244
+ vi.stubGlobal("fetch", fetchMock);
245
+ const { client, embedded } = await startServer();
246
+ try {
247
+ const result = await client.callTool({
248
+ name: "blueprint_synthesize",
249
+ arguments: { workspaceId: 11, blueprintId: "bp-1", replace: true },
250
+ });
251
+ const [url, init] = fetchMock.mock.calls[0];
252
+ expect(String(url)).toBe(`${MW_BASE}${BP}/bp-1/synthesize`);
253
+ expect(JSON.parse(String(init.body))).toEqual({ replace: true });
254
+ const payload = parseToolJson(result);
255
+ expect(payload.status).toBe("proposal_ready");
256
+ expect(payload.nextActions.join("\n")).toContain("baseVersion=3 proposalBasedOnSeq=6");
257
+ }
258
+ finally {
259
+ await client.close();
260
+ await embedded.close();
261
+ }
262
+ });
263
+ // ── apply-proposal swap guard ──────────────────────────────
264
+ it("blueprint_apply_proposal refuses (without POSTing) when the pending proposal was swapped", async () => {
265
+ const fetchMock = routedFetch({
266
+ "/blueprints/bp-1": bpFixture({
267
+ hasPendingProposal: true,
268
+ pendingProposal: {
269
+ patch: {},
270
+ basedOnSeq: 9,
271
+ baseVersion: 5,
272
+ summary: "Proposal B",
273
+ citations: [],
274
+ createdAt: "2026-08-12T00:00:00Z",
275
+ },
276
+ }),
277
+ });
278
+ vi.stubGlobal("fetch", fetchMock);
279
+ const { client, embedded } = await startServer();
280
+ try {
281
+ const result = await client.callTool({
282
+ name: "blueprint_apply_proposal",
283
+ arguments: {
284
+ workspaceId: 11,
285
+ blueprintId: "bp-1",
286
+ baseVersion: 2,
287
+ proposalBasedOnSeq: 3,
288
+ },
289
+ });
290
+ // Only the re-read GET — the apply POST must not have happened.
291
+ expect(fetchMock).toHaveBeenCalledTimes(1);
292
+ const payload = parseToolJson(result);
293
+ expect(payload.status).toBe("blocked");
294
+ expect(payload.blocked.reason).toBe("proposal_swapped");
295
+ expect(payload.nextActions.join("\n")).toContain("blueprint_get_proposal");
296
+ }
297
+ finally {
298
+ await client.close();
299
+ await embedded.close();
300
+ }
301
+ });
302
+ it("blueprint_apply_proposal applies when the reviewed coordinates match", async () => {
303
+ const pendingProposal = {
304
+ patch: {},
305
+ basedOnSeq: 3,
306
+ baseVersion: 2,
307
+ summary: "Proposal A",
308
+ citations: [],
309
+ createdAt: "2026-08-12T00:00:00Z",
310
+ };
311
+ const fetchMock = routedFetch({
312
+ "/blueprints/bp-1/proposal/apply": bpFixture({
313
+ headVersion: 3,
314
+ state: "job_stale",
315
+ document: {
316
+ goal: "Do the thing",
317
+ steps: [{ id: "s1" }],
318
+ openQuestions: [],
319
+ },
320
+ }),
321
+ "/blueprints/bp-1": bpFixture({
322
+ hasPendingProposal: true,
323
+ pendingProposal,
324
+ }),
325
+ });
326
+ vi.stubGlobal("fetch", fetchMock);
327
+ const { client, embedded } = await startServer();
328
+ try {
329
+ const result = await client.callTool({
330
+ name: "blueprint_apply_proposal",
331
+ arguments: {
332
+ workspaceId: 11,
333
+ blueprintId: "bp-1",
334
+ baseVersion: 2,
335
+ proposalBasedOnSeq: 3,
336
+ },
337
+ });
338
+ const applyCall = fetchMock.mock.calls.find(([u]) => String(u).endsWith("/proposal/apply"));
339
+ expect(applyCall).toBeTruthy();
340
+ // The reviewed proposal's identity must ride along so the service can
341
+ // atomically refuse a swapped proposal (409 proposal_mismatch).
342
+ const [, applyInit] = applyCall;
343
+ expect(JSON.parse(String(applyInit.body))).toEqual({ basedOnSeq: 3 });
344
+ const payload = parseToolJson(result);
345
+ expect(payload.status).toBe("applied");
346
+ expect(payload.headVersion).toBe(3);
347
+ expect(payload.nextActions.join("\n")).toContain("blueprint_build");
348
+ }
349
+ finally {
350
+ await client.close();
351
+ await embedded.close();
352
+ }
353
+ });
354
+ it("blueprint_apply_proposal surfaces the service-side 409 proposal_mismatch as proposal_swapped", async () => {
355
+ // The pre-flight GET passes (coordinates match), but a resynthesize
356
+ // lands in the check-to-apply window — the service's atomic basedOnSeq
357
+ // check refuses with code proposal_mismatch.
358
+ const pendingProposal = {
359
+ patch: {},
360
+ basedOnSeq: 3,
361
+ baseVersion: 2,
362
+ summary: "Proposal A",
363
+ citations: [],
364
+ createdAt: "2026-08-12T00:00:00Z",
365
+ };
366
+ const fetchMock = vi.fn(async (input) => {
367
+ const path = new URL(String(input)).pathname;
368
+ if (path.endsWith("/proposal/apply")) {
369
+ return jsonResponse({
370
+ code: "proposal_mismatch",
371
+ message: "The pending proposal is not the one you reviewed (basedOnSeq 7 vs expected 3) — it was replaced since. Review the current proposal and retry",
372
+ }, 409);
373
+ }
374
+ return jsonResponse(bpFixture({ hasPendingProposal: true, pendingProposal }));
375
+ });
376
+ vi.stubGlobal("fetch", fetchMock);
377
+ const { client, embedded } = await startServer();
378
+ try {
379
+ const result = await client.callTool({
380
+ name: "blueprint_apply_proposal",
381
+ arguments: {
382
+ workspaceId: 11,
383
+ blueprintId: "bp-1",
384
+ baseVersion: 2,
385
+ proposalBasedOnSeq: 3,
386
+ },
387
+ });
388
+ const payload = parseToolJson(result);
389
+ expect(payload.status).toBe("blocked");
390
+ expect(payload.blocked.reason).toBe("proposal_swapped");
391
+ expect(payload.blocked.retryable).toBe(true);
392
+ expect(payload.nextActions.join("\n")).toContain("blueprint_get_proposal");
393
+ }
394
+ finally {
395
+ await client.close();
396
+ await embedded.close();
397
+ }
398
+ });
399
+ it("blueprint_apply_proposal keeps treating a code-less spec-moved 409 as spec_moved (legacy fallback)", async () => {
400
+ const pendingProposal = {
401
+ patch: {},
402
+ basedOnSeq: 3,
403
+ baseVersion: 2,
404
+ summary: "Proposal A",
405
+ citations: [],
406
+ createdAt: "2026-08-12T00:00:00Z",
407
+ };
408
+ const fetchMock = vi.fn(async (input) => {
409
+ const path = new URL(String(input)).pathname;
410
+ if (path.endsWith("/proposal/apply")) {
411
+ return jsonResponse({
412
+ message: "The spec has changed since this proposal was synthesized (version 5 vs 2). Reject it and resynthesize",
413
+ }, 409);
414
+ }
415
+ return jsonResponse(bpFixture({ hasPendingProposal: true, pendingProposal }));
416
+ });
417
+ vi.stubGlobal("fetch", fetchMock);
418
+ const { client, embedded } = await startServer();
419
+ try {
420
+ const result = await client.callTool({
421
+ name: "blueprint_apply_proposal",
422
+ arguments: {
423
+ workspaceId: 11,
424
+ blueprintId: "bp-1",
425
+ baseVersion: 2,
426
+ proposalBasedOnSeq: 3,
427
+ },
428
+ });
429
+ const payload = parseToolJson(result);
430
+ expect(payload.status).toBe("blocked");
431
+ expect(payload.blocked.reason).toBe("spec_moved");
432
+ expect(payload.nextActions.join("\n")).toContain("blueprint_reject_proposal");
433
+ }
434
+ finally {
435
+ await client.close();
436
+ await embedded.close();
437
+ }
438
+ });
439
+ // ── raw-entity write responses get the computed state re-read ──
440
+ // The live service's PATCH /:id and POST /:id/proposal/apply return the raw
441
+ // blueprint row WITHOUT headVersion/state (unlike GET) — the tools must
442
+ // re-read instead of reporting headVersion: undefined (the next CAS base).
443
+ it("blueprint_update re-reads for headVersion when PATCH returns the raw row", async () => {
444
+ const rawRow = {
445
+ id: "bp-1",
446
+ workspaceId: 11,
447
+ name: "COI intake",
448
+ document: { goal: "New goal", steps: [], openQuestions: [] },
449
+ librarySeq: 0,
450
+ specBasedOnSeq: 0,
451
+ };
452
+ const fetchMock = vi.fn(async (_input, init) => init?.method === "PATCH"
453
+ ? jsonResponse(rawRow)
454
+ : jsonResponse(bpFixture({ headVersion: 5, state: "job_stale" })));
455
+ vi.stubGlobal("fetch", fetchMock);
456
+ const { client, embedded } = await startServer();
457
+ try {
458
+ const result = await client.callTool({
459
+ name: "blueprint_update",
460
+ arguments: {
461
+ workspaceId: 11,
462
+ blueprintId: "bp-1",
463
+ patch: { goal: "New goal" },
464
+ baseVersion: 4,
465
+ },
466
+ });
467
+ // PATCH, then the re-read GET.
468
+ expect(fetchMock).toHaveBeenCalledTimes(2);
469
+ const payload = parseToolJson(result);
470
+ expect(payload.status).toBe("updated");
471
+ expect(payload.headVersion).toBe(5);
472
+ }
473
+ finally {
474
+ await client.close();
475
+ await embedded.close();
476
+ }
477
+ });
478
+ it("blueprint_apply_proposal re-reads for headVersion when apply returns the raw row", async () => {
479
+ const pendingProposal = {
480
+ patch: {},
481
+ basedOnSeq: 3,
482
+ baseVersion: 2,
483
+ summary: "Proposal A",
484
+ citations: [],
485
+ createdAt: "2026-08-12T00:00:00Z",
486
+ };
487
+ let applied = false;
488
+ const fetchMock = vi.fn(async (input) => {
489
+ const path = new URL(String(input)).pathname;
490
+ if (path.endsWith("/proposal/apply")) {
491
+ applied = true;
492
+ // Raw row: no headVersion, no state.
493
+ return jsonResponse({
494
+ id: "bp-1",
495
+ workspaceId: 11,
496
+ document: { goal: "Do the thing", steps: [{ id: "s1" }], openQuestions: [] },
497
+ });
498
+ }
499
+ // Guard re-read before apply, computed-state re-read after.
500
+ return jsonResponse(applied
501
+ ? bpFixture({ headVersion: 3, state: "job_stale" })
502
+ : bpFixture({ hasPendingProposal: true, pendingProposal }));
503
+ });
504
+ vi.stubGlobal("fetch", fetchMock);
505
+ const { client, embedded } = await startServer();
506
+ try {
507
+ const result = await client.callTool({
508
+ name: "blueprint_apply_proposal",
509
+ arguments: {
510
+ workspaceId: 11,
511
+ blueprintId: "bp-1",
512
+ baseVersion: 2,
513
+ proposalBasedOnSeq: 3,
514
+ },
515
+ });
516
+ const payload = parseToolJson(result);
517
+ expect(payload.status).toBe("applied");
518
+ expect(payload.headVersion).toBe(3);
519
+ expect(payload.state).toBe("job_stale");
520
+ }
521
+ finally {
522
+ await client.close();
523
+ await embedded.close();
524
+ }
525
+ });
526
+ it("blueprint_update marks the response partial when the post-write re-read ALSO fails", async () => {
527
+ const rawRow = {
528
+ id: "bp-1",
529
+ workspaceId: 11,
530
+ name: "COI intake",
531
+ document: { goal: "New goal", steps: [], openQuestions: [] },
532
+ librarySeq: 0,
533
+ specBasedOnSeq: 0,
534
+ };
535
+ const fetchMock = vi.fn(async (_input, init) => init?.method === "PATCH"
536
+ ? jsonResponse(rawRow)
537
+ : jsonResponse({ message: "boom" }, 500));
538
+ vi.stubGlobal("fetch", fetchMock);
539
+ const { client, embedded } = await startServer();
540
+ try {
541
+ const result = await client.callTool({
542
+ name: "blueprint_update",
543
+ arguments: {
544
+ workspaceId: 11,
545
+ blueprintId: "bp-1",
546
+ patch: { goal: "New goal" },
547
+ baseVersion: 4,
548
+ },
549
+ });
550
+ const payload = parseToolJson(result);
551
+ // The write succeeded — but the response must NOT claim a usable CAS
552
+ // coordinate it doesn't have.
553
+ expect(payload.status).toBe("updated");
554
+ expect(payload.headVersion).toBeNull();
555
+ expect(payload.warning).toContain("state re-read failed");
556
+ expect(payload.warning).toContain("blueprint_get");
557
+ }
558
+ finally {
559
+ await client.close();
560
+ await embedded.close();
561
+ }
562
+ });
563
+ // ── reject 404 = success-shaped ────────────────────────────
564
+ it("blueprint_reject_proposal treats a 404 as already_gone (success-shaped)", async () => {
565
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse({ message: "No pending proposal" }, 404));
566
+ vi.stubGlobal("fetch", fetchMock);
567
+ const { client, embedded } = await startServer();
568
+ try {
569
+ const result = await client.callTool({
570
+ name: "blueprint_reject_proposal",
571
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
572
+ });
573
+ const payload = parseToolJson(result);
574
+ expect(payload.status).toBe("already_gone");
575
+ }
576
+ finally {
577
+ await client.close();
578
+ await embedded.close();
579
+ }
580
+ });
581
+ // ── answer-question convergence + driftNote ────────────────
582
+ it("blueprint_answer_question treats the not-open 404 as convergence", async () => {
583
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse({ message: "The question is not open" }, 404));
584
+ vi.stubGlobal("fetch", fetchMock);
585
+ const { client, embedded } = await startServer();
586
+ try {
587
+ const result = await client.callTool({
588
+ name: "blueprint_answer_question",
589
+ arguments: {
590
+ workspaceId: 11,
591
+ blueprintId: "bp-1",
592
+ question: "Which account?",
593
+ answer: "The ops account",
594
+ },
595
+ });
596
+ const payload = parseToolJson(result);
597
+ expect(payload.status).toBe("already_answered");
598
+ expect(payload.note).toContain("CONVERGENCE");
599
+ }
600
+ finally {
601
+ await client.close();
602
+ await embedded.close();
603
+ }
604
+ });
605
+ it("blueprint_answer_question propagates a blueprint-missing 404 instead of shaping it as convergence", async () => {
606
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse({ message: "Blueprint bp-1 not found" }, 404));
607
+ vi.stubGlobal("fetch", fetchMock);
608
+ const { client, embedded } = await startServer();
609
+ try {
610
+ const result = await client.callTool({
611
+ name: "blueprint_answer_question",
612
+ arguments: {
613
+ workspaceId: 11,
614
+ blueprintId: "bp-1",
615
+ question: "Which account?",
616
+ answer: "The ops account",
617
+ },
618
+ });
619
+ const textOut = result.content[0].text;
620
+ expect(textOut).toContain("Blueprint not found");
621
+ expect(textOut).not.toContain("already_answered");
622
+ }
623
+ finally {
624
+ await client.close();
625
+ await embedded.close();
626
+ }
627
+ });
628
+ it("blueprint_apply_proposal propagates a blueprint-missing 404 instead of shaping it as convergence", async () => {
629
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse({ message: "Blueprint bp-1 not found" }, 404));
630
+ vi.stubGlobal("fetch", fetchMock);
631
+ const { client, embedded } = await startServer();
632
+ try {
633
+ const result = await client.callTool({
634
+ name: "blueprint_apply_proposal",
635
+ arguments: {
636
+ workspaceId: 11,
637
+ blueprintId: "bp-1",
638
+ baseVersion: 1,
639
+ proposalBasedOnSeq: 1,
640
+ },
641
+ });
642
+ const textOut = result.content[0].text;
643
+ expect(textOut).toContain("Blueprint not found");
644
+ expect(textOut).not.toContain("no_pending_proposal");
645
+ }
646
+ finally {
647
+ await client.close();
648
+ await embedded.close();
649
+ }
650
+ });
651
+ it("blueprint_answer_question carries the driftNote and remaining questions on success", async () => {
652
+ const fetchMock = vi.fn(async (_input, _init) => jsonResponse(bpFixture({
653
+ document: {
654
+ goal: "g",
655
+ steps: [],
656
+ openQuestions: ["Second question?"],
657
+ },
658
+ })));
659
+ vi.stubGlobal("fetch", fetchMock);
660
+ const { client, embedded } = await startServer();
661
+ try {
662
+ const result = await client.callTool({
663
+ name: "blueprint_answer_question",
664
+ arguments: {
665
+ workspaceId: 11,
666
+ blueprintId: "bp-1",
667
+ question: "First question?",
668
+ answer: "Answered",
669
+ },
670
+ });
671
+ const [url, init] = fetchMock.mock.calls[0];
672
+ expect(String(url)).toBe(`${MW_BASE}${BP}/bp-1/questions/answer`);
673
+ expect(JSON.parse(String(init.body))).toEqual({
674
+ question: "First question?",
675
+ answer: "Answered",
676
+ });
677
+ const payload = parseToolJson(result);
678
+ expect(payload.status).toBe("answered");
679
+ expect(payload.driftNote).toContain("spec_stale");
680
+ expect(payload.nextActions.join("\n")).toContain("Second question?");
681
+ }
682
+ finally {
683
+ await client.close();
684
+ await embedded.close();
685
+ }
686
+ });
687
+ // ── build precondition + handoff artifact ──────────────────
688
+ it("blueprint_build blocks (without syncing) when the linked build run is needs_input", async () => {
689
+ const fetchMock = routedFetch({
690
+ "/blueprints/bp-1": bpFixture({
691
+ state: "building",
692
+ buildStatus: "needs_input",
693
+ middlewareId: "mw-1",
694
+ routeId: "route-1",
695
+ buildId: "b-1",
696
+ }),
697
+ });
698
+ vi.stubGlobal("fetch", fetchMock);
699
+ const { client, embedded } = await startServer();
700
+ try {
701
+ const result = await client.callTool({
702
+ name: "blueprint_build",
703
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
704
+ });
705
+ // Only the precondition GET — no POST /sync.
706
+ expect(fetchMock).toHaveBeenCalledTimes(1);
707
+ const payload = parseToolJson(result);
708
+ expect(payload.status).toBe("blocked");
709
+ expect(payload.blocked.reason).toBe("build_needs_input");
710
+ expect(payload.nextActions.join("\n")).toContain("answer_build_question");
711
+ }
712
+ finally {
713
+ await client.close();
714
+ await embedded.close();
715
+ }
716
+ });
717
+ it("blueprint_build returns the handoff triple and points at the jobs loop", async () => {
718
+ const fetchMock = routedFetch({
719
+ "/blueprints/bp-1/sync": {
720
+ middlewareId: "mw-1",
721
+ routeId: "route-1",
722
+ buildId: "b-2",
723
+ testCaseIds: ["tc-1", "tc-2"],
724
+ },
725
+ "/blueprints/bp-1": bpFixture({
726
+ state: "job_stale",
727
+ document: { goal: "Do the thing", steps: [{ id: "s1" }] },
728
+ headVersion: 2,
729
+ }),
730
+ });
731
+ vi.stubGlobal("fetch", fetchMock);
732
+ const { client, embedded } = await startServer();
733
+ try {
734
+ const result = await client.callTool({
735
+ name: "blueprint_build",
736
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
737
+ });
738
+ const payload = parseToolJson(result);
739
+ expect(payload.status).toBe("build_queued");
740
+ expect(payload.middlewareId).toBe("mw-1");
741
+ expect(payload.routeId).toBe("route-1");
742
+ expect(payload.buildId).toBe("b-2");
743
+ expect(payload.testCaseIds).toEqual(["tc-1", "tc-2"]);
744
+ expect(payload.nextActions.join("\n")).toContain("run_tests");
745
+ }
746
+ finally {
747
+ await client.close();
748
+ await embedded.close();
749
+ }
750
+ });
751
+ it("blueprint_build_status surfaces a build fetch failure instead of pretending there is no build", async () => {
752
+ const fetchMock = vi.fn(async (input) => {
753
+ const path = new URL(String(input)).pathname;
754
+ if (path.includes("/builds/")) {
755
+ return jsonResponse({ message: "upstream timeout" }, 502);
756
+ }
757
+ return jsonResponse(bpFixture({
758
+ state: "building",
759
+ middlewareId: "mw-1",
760
+ routeId: "route-1",
761
+ buildId: "b-1",
762
+ }));
763
+ });
764
+ vi.stubGlobal("fetch", fetchMock);
765
+ const { client, embedded } = await startServer();
766
+ try {
767
+ const result = await client.callTool({
768
+ name: "blueprint_build_status",
769
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
770
+ });
771
+ const payload = parseToolJson(result);
772
+ expect(payload.build).toBeNull();
773
+ expect(payload.buildFetchError).toContain("upstream timeout");
774
+ expect(payload.nextActions.join("\n")).toContain("READ failure");
775
+ }
776
+ finally {
777
+ await client.close();
778
+ await embedded.close();
779
+ }
780
+ });
781
+ // ── resolve_blueprint_state decision table ─────────────────
782
+ describe("resolve_blueprint_state", () => {
783
+ async function resolve(routes) {
784
+ const fetchMock = routedFetch(routes);
785
+ vi.stubGlobal("fetch", fetchMock);
786
+ const { client, embedded } = await startServer();
787
+ try {
788
+ const result = await client.callTool({
789
+ name: "resolve_blueprint_state",
790
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
791
+ });
792
+ return parseToolJson(result);
793
+ }
794
+ finally {
795
+ await client.close();
796
+ await embedded.close();
797
+ }
798
+ }
799
+ it("a failed entries read degrades explicitly instead of reporting no_context", async () => {
800
+ const fetchMock = vi.fn(async (input) => {
801
+ const path = new URL(String(input)).pathname;
802
+ if (path.endsWith("/entries")) {
803
+ return jsonResponse({ message: "db unavailable" }, 503);
804
+ }
805
+ return jsonResponse(bpFixture());
806
+ });
807
+ vi.stubGlobal("fetch", fetchMock);
808
+ const { client, embedded } = await startServer();
809
+ try {
810
+ const result = await client.callTool({
811
+ name: "resolve_blueprint_state",
812
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
813
+ });
814
+ const payload = parseToolJson(result);
815
+ // The empty spec + unreadable library must NOT unfold to no_context
816
+ // (which would direct the agent to feed a library it never saw).
817
+ expect(payload.status).toBe("degraded");
818
+ expect(payload.state).toBeUndefined();
819
+ expect(payload.error).toContain("db unavailable");
820
+ expect(payload.nextActions.join("\n")).toContain("resolve_blueprint_state");
821
+ }
822
+ finally {
823
+ await client.close();
824
+ await embedded.close();
825
+ }
826
+ });
827
+ it("a failed linked-build read degrades explicitly instead of hiding the build", async () => {
828
+ const fetchMock = vi.fn(async (input) => {
829
+ const path = new URL(String(input)).pathname;
830
+ if (path.includes("/builds/")) {
831
+ return jsonResponse({ message: "db unavailable" }, 503);
832
+ }
833
+ if (path.endsWith("/entries"))
834
+ return jsonResponse([]);
835
+ return jsonResponse(bpFixture({
836
+ document: { goal: "g", steps: [{ id: "s1" }], openQuestions: [] },
837
+ middlewareId: "mw-1",
838
+ routeId: "route-1",
839
+ buildId: "b-1",
840
+ state: "job_stale",
841
+ lastSyncedVersion: 1,
842
+ }));
843
+ });
844
+ vi.stubGlobal("fetch", fetchMock);
845
+ const { client, embedded } = await startServer();
846
+ try {
847
+ const result = await client.callTool({
848
+ name: "resolve_blueprint_state",
849
+ arguments: { workspaceId: 11, blueprintId: "bp-1" },
850
+ });
851
+ const payload = parseToolJson(result);
852
+ // An unreadable linked build must NOT unfold to a normal lifecycle
853
+ // state — needs_input questions and stall facts would silently
854
+ // vanish.
855
+ expect(payload.status).toBe("degraded");
856
+ expect(payload.state).toBeUndefined();
857
+ expect(payload.error).toContain("b-1");
858
+ expect(payload.nextActions.join("\n")).toContain("resolve_blueprint_state");
859
+ }
860
+ finally {
861
+ await client.close();
862
+ await embedded.close();
863
+ }
864
+ });
865
+ it("empty blueprint -> no_context -> feed the library", async () => {
866
+ const payload = await resolve({
867
+ "/blueprints/bp-1/entries": [],
868
+ "/blueprints/bp-1": bpFixture(),
869
+ });
870
+ expect(payload.state).toBe("no_context");
871
+ expect(payload.nextActions.join("\n")).toContain("blueprint_add_entry");
872
+ });
873
+ it("pending entries -> enriching -> poll the library", async () => {
874
+ const payload = await resolve({
875
+ "/blueprints/bp-1/entries": [
876
+ { id: "e1", kind: "video", title: "Loom", status: "pending", seq: 1 },
877
+ ],
878
+ "/blueprints/bp-1": bpFixture({
879
+ librarySeq: 1,
880
+ specStale: true,
881
+ state: "spec_stale",
882
+ }),
883
+ });
884
+ expect(payload.state).toBe("enriching");
885
+ expect(payload.nextActions.join("\n")).toContain("blueprint_list_entries");
886
+ expect(payload.resources.pending).toHaveLength(1);
887
+ });
888
+ it("ready library + empty spec -> no_spec -> synthesize", async () => {
889
+ const payload = await resolve({
890
+ "/blueprints/bp-1/entries": [
891
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
892
+ ],
893
+ "/blueprints/bp-1": bpFixture({
894
+ librarySeq: 1,
895
+ specStale: true,
896
+ state: "spec_stale",
897
+ }),
898
+ });
899
+ expect(payload.state).toBe("no_spec");
900
+ expect(payload.nextActions.join("\n")).toContain("blueprint_synthesize");
901
+ });
902
+ it("pending proposal -> proposal_pending with prefilled apply coordinates", async () => {
903
+ const payload = await resolve({
904
+ "/blueprints/bp-1/entries": [
905
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
906
+ ],
907
+ "/blueprints/bp-1": bpFixture({
908
+ hasPendingProposal: true,
909
+ pendingProposal: {
910
+ patch: {},
911
+ basedOnSeq: 4,
912
+ baseVersion: 1,
913
+ summary: "First draft",
914
+ citations: [
915
+ { entryId: "e1", reason: "goal", provenance: "extracted" },
916
+ ],
917
+ createdAt: "2026-08-12T00:00:00Z",
918
+ },
919
+ }),
920
+ });
921
+ expect(payload.state).toBe("proposal_pending");
922
+ expect(payload.nextActions.join("\n")).toContain("baseVersion=1 proposalBasedOnSeq=4");
923
+ expect(payload.proposal.citations).toBe(1);
924
+ });
925
+ it("open questions -> needs_answers with one answer call per question, then ONE synthesize", async () => {
926
+ const payload = await resolve({
927
+ "/blueprints/bp-1/entries": [
928
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
929
+ ],
930
+ "/blueprints/bp-1": bpFixture({
931
+ document: {
932
+ goal: "Do the thing",
933
+ steps: [{ id: "s1" }],
934
+ openQuestions: ["Which account?", "What about refunds?"],
935
+ },
936
+ }),
937
+ });
938
+ expect(payload.state).toBe("needs_answers");
939
+ const actions = payload.nextActions.join("\n");
940
+ expect(actions).toContain('question="Which account?"');
941
+ expect(actions).toContain('question="What about refunds?"');
942
+ expect(actions).toContain("blueprint_synthesize once");
943
+ });
944
+ it("needs_input build -> answer_build_question prefilled + question_needs_human blocker", async () => {
945
+ const payload = await resolve({
946
+ "/routes/route-1/builds/b-1": {
947
+ id: "b-1",
948
+ status: "needs_input",
949
+ questions: [{ id: "q1", text: "Which credentials?" }],
950
+ },
951
+ "/blueprints/bp-1/entries": [
952
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
953
+ ],
954
+ "/blueprints/bp-1": bpFixture({
955
+ state: "building",
956
+ buildStatus: "needs_input",
957
+ middlewareId: "mw-1",
958
+ routeId: "route-1",
959
+ buildId: "b-1",
960
+ document: { goal: "g", steps: [{ id: "s1" }] },
961
+ }),
962
+ });
963
+ expect(payload.state).toBe("needs_input");
964
+ const actions = payload.nextActions.join("\n");
965
+ expect(actions).toContain("answer_build_question workspaceId=11 middlewareId=mw-1 routeId=route-1 buildId=b-1 questionId=q1");
966
+ expect(payload.blockers.map((b) => b.kind)).toContain("question_needs_human");
967
+ });
968
+ it("UNLINKED blueprint whose library moved past a non-empty spec -> spec_stale, not job_stale", async () => {
969
+ // The service folds !routeId to never_synced regardless of drift; the
970
+ // specStale boolean is what must drive the synthesize-first branch.
971
+ const payload = await resolve({
972
+ "/blueprints/bp-1/entries": [
973
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
974
+ { id: "e2", kind: "note", title: "Late addition", status: "ready", seq: 2 },
975
+ ],
976
+ "/blueprints/bp-1": bpFixture({
977
+ state: "never_synced",
978
+ specStale: true,
979
+ headVersion: 1,
980
+ librarySeq: 2,
981
+ document: { goal: "g", steps: [{ id: "s1" }], openQuestions: [] },
982
+ drift: {
983
+ newEntries: [
984
+ { id: "e2", kind: "note", title: "Late addition", seq: 2 },
985
+ ],
986
+ unsyncedVersions: [],
987
+ },
988
+ }),
989
+ });
990
+ expect(payload.state).toBe("spec_stale");
991
+ expect(payload.nextActions.join("\n")).toContain("blueprint_synthesize");
992
+ });
993
+ it("never_synced with a ready spec -> job_stale -> blueprint_build", async () => {
994
+ const payload = await resolve({
995
+ "/blueprints/bp-1/entries": [
996
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
997
+ ],
998
+ "/blueprints/bp-1": bpFixture({
999
+ document: {
1000
+ goal: "Do the thing",
1001
+ steps: [{ id: "s1" }],
1002
+ openQuestions: [],
1003
+ },
1004
+ headVersion: 2,
1005
+ }),
1006
+ });
1007
+ expect(payload.state).toBe("job_stale");
1008
+ expect(payload.nextActions.join("\n")).toContain("blueprint_build");
1009
+ });
1010
+ it("spec_stale from answer notes only -> flags answerNotesOnly and one synthesize", async () => {
1011
+ const payload = await resolve({
1012
+ "/blueprints/bp-1/entries": [
1013
+ {
1014
+ id: "e2",
1015
+ kind: "note",
1016
+ title: "Answer: Which account?",
1017
+ status: "ready",
1018
+ seq: 5,
1019
+ content: { answersQuestion: "Which account?" },
1020
+ },
1021
+ ],
1022
+ "/blueprints/bp-1": bpFixture({
1023
+ state: "spec_stale",
1024
+ specStale: true,
1025
+ routeId: "route-1",
1026
+ middlewareId: "mw-1",
1027
+ document: { goal: "g", steps: [{ id: "s1" }], openQuestions: [] },
1028
+ drift: {
1029
+ newEntries: [
1030
+ {
1031
+ id: "e2",
1032
+ kind: "note",
1033
+ title: "Answer: Which account?",
1034
+ seq: 5,
1035
+ },
1036
+ ],
1037
+ unsyncedVersions: [],
1038
+ },
1039
+ }),
1040
+ });
1041
+ expect(payload.state).toBe("spec_stale");
1042
+ expect(payload.drift.answerNotesOnly).toBe(true);
1043
+ expect(payload.nextActions.join("\n")).toContain("answer notes ONLY");
1044
+ });
1045
+ it("job_ahead -> import-cases fold-back or rebuild", async () => {
1046
+ const payload = await resolve({
1047
+ "/blueprints/bp-1/entries": [
1048
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
1049
+ ],
1050
+ "/blueprints/bp-1": bpFixture({
1051
+ state: "job_ahead",
1052
+ jobEditedDirectly: true,
1053
+ routeId: "route-1",
1054
+ middlewareId: "mw-1",
1055
+ document: { goal: "g", steps: [{ id: "s1" }] },
1056
+ }),
1057
+ });
1058
+ expect(payload.state).toBe("job_ahead");
1059
+ const actions = payload.nextActions.join("\n");
1060
+ expect(actions).toContain("blueprint_import_cases");
1061
+ expect(actions).toContain("blueprint_build");
1062
+ });
1063
+ it("failed enrichment surfaces an enrichment_failed blocker", async () => {
1064
+ const payload = await resolve({
1065
+ "/blueprints/bp-1/entries": [
1066
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
1067
+ {
1068
+ id: "e2",
1069
+ kind: "video",
1070
+ title: "Broken Loom",
1071
+ status: "failed",
1072
+ error: "fetch failed",
1073
+ seq: 2,
1074
+ },
1075
+ ],
1076
+ "/blueprints/bp-1": bpFixture({
1077
+ document: { goal: "g", steps: [{ id: "s1" }] },
1078
+ }),
1079
+ });
1080
+ const blocker = payload.blockers.find((b) => b.kind === "enrichment_failed");
1081
+ expect(blocker).toBeTruthy();
1082
+ expect(blocker.entries[0].error).toBe("fetch failed");
1083
+ });
1084
+ it("a build queued past the stall threshold surfaces builder_stalled with queuedForMs", async () => {
1085
+ const payload = await resolve({
1086
+ "/routes/route-1/builds/b-1": {
1087
+ id: "b-1",
1088
+ status: "queued",
1089
+ createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
1090
+ },
1091
+ "/blueprints/bp-1/entries": [
1092
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
1093
+ ],
1094
+ "/blueprints/bp-1": bpFixture({
1095
+ state: "building",
1096
+ buildStatus: "queued",
1097
+ middlewareId: "mw-1",
1098
+ routeId: "route-1",
1099
+ buildId: "b-1",
1100
+ document: { goal: "g", steps: [{ id: "s1" }] },
1101
+ }),
1102
+ });
1103
+ expect(payload.state).toBe("building");
1104
+ const blocker = payload.blockers.find((b) => b.kind === "builder_stalled");
1105
+ expect(blocker).toBeTruthy();
1106
+ expect(blocker.queuedForMs).toBeGreaterThan(2 * 60 * 1000);
1107
+ });
1108
+ it("in_sync -> nothing to do, with the job-side handoff", async () => {
1109
+ const payload = await resolve({
1110
+ "/routes/route-1/builds/b-1": { id: "b-1", status: "green" },
1111
+ "/blueprints/bp-1/entries": [
1112
+ { id: "e1", kind: "doc", title: "SOP", status: "ready", seq: 1 },
1113
+ ],
1114
+ "/blueprints/bp-1": bpFixture({
1115
+ state: "in_sync",
1116
+ routeId: "route-1",
1117
+ middlewareId: "mw-1",
1118
+ buildId: "b-1",
1119
+ buildStatus: "green",
1120
+ document: { goal: "g", steps: [{ id: "s1" }] },
1121
+ }),
1122
+ });
1123
+ expect(payload.state).toBe("in_sync");
1124
+ expect(payload.nextActions.join("\n")).toContain("resolve_job_state workspaceId=11 middlewareId=mw-1 routeId=route-1");
1125
+ });
1126
+ });
1127
+ // ── resolve_job_state ownership block ──────────────────────
1128
+ it("resolve_job_state flags a blueprint-owned route and points at the spec", async () => {
1129
+ const fetchMock = routedFetch({
1130
+ "/route-1/test-cases": [],
1131
+ "/route-1/test-runs": {
1132
+ content: [],
1133
+ totalElements: 0,
1134
+ totalPages: 0,
1135
+ number: 0,
1136
+ size: 3,
1137
+ },
1138
+ "/route-1/executions": {
1139
+ content: [],
1140
+ totalElements: 0,
1141
+ totalPages: 0,
1142
+ number: 0,
1143
+ size: 10,
1144
+ },
1145
+ "/route-1/builds": [],
1146
+ "/routes": [{ id: "route-1", method: "POST", path: "/coi" }],
1147
+ "/api/workspaces/11/blueprints": [
1148
+ {
1149
+ id: "bp-9",
1150
+ name: "COI intake",
1151
+ state: "in_sync",
1152
+ middlewareId: "mw-1",
1153
+ routeId: "route-1",
1154
+ },
1155
+ ],
1156
+ });
1157
+ vi.stubGlobal("fetch", fetchMock);
1158
+ const { client, embedded } = await startServer();
1159
+ try {
1160
+ const result = await client.callTool({
1161
+ name: "resolve_job_state",
1162
+ arguments: {
1163
+ workspaceId: 11,
1164
+ middlewareId: "mw-1",
1165
+ routeId: "route-1",
1166
+ },
1167
+ });
1168
+ const payload = parseToolJson(result);
1169
+ expect(payload.blueprint).toEqual({
1170
+ blueprintId: "bp-9",
1171
+ state: "in_sync",
1172
+ hint: expect.stringContaining("resolve_blueprint_state blueprintId=bp-9"),
1173
+ });
1174
+ const actions = payload.nextActions.join("\n");
1175
+ expect(actions).toContain("OWNED by blueprint bp-9");
1176
+ // The route has no test cases and no definition — on an owned route the
1177
+ // resolver must route BOTH gaps through the spec, never recommend the
1178
+ // direct job edits its own ownership warning forbids.
1179
+ expect(actions).not.toContain("add_test_case (input + assertions)");
1180
+ expect(actions).not.toContain("teach_job (example input + intent)");
1181
+ expect(actions).not.toContain("update_job variant=draft with a step graph");
1182
+ expect(actions).toContain("blueprint_build blueprintId=bp-9");
1183
+ }
1184
+ finally {
1185
+ await client.close();
1186
+ await embedded.close();
1187
+ }
1188
+ });
1189
+ });
1190
+ //# sourceMappingURL=blueprints-tools.test.js.map