@taiga-ai/mcp-server 0.11.0 → 0.12.0-dev.417

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.
@@ -36,6 +36,7 @@ const ALL_TOOL_NAMES = [
36
36
  'taiga_get_product',
37
37
  'taiga_get_product_context',
38
38
  'taiga_get_product_document',
39
+ 'taiga_get_queue',
39
40
  'taiga_list_backlog_items',
40
41
  'taiga_list_builds',
41
42
  'taiga_list_documents',
@@ -51,9 +52,12 @@ const ALL_TOOL_NAMES = [
51
52
  'taiga_resolve_product',
52
53
  'taiga_resume_backlog_item',
53
54
  'taiga_resume_build',
55
+ 'taiga_set_auto_build',
54
56
  'taiga_stop_build',
55
57
  'taiga_update_document',
56
58
  'taiga_update_factory_document',
59
+ 'taiga_wait_for_backlog_item',
60
+ 'taiga_wait_for_build',
57
61
  ];
58
62
 
59
63
  const PRODUCT_ID = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5c';
@@ -100,10 +104,13 @@ function sentBody(fetchSpy: ReturnType<typeof vi.fn>, method: string, pathPart:
100
104
 
101
105
  /** What the API answers when it refuses: the status and the error envelope its error handler sends. */
102
106
  class Refusal {
103
- constructor(
104
- readonly status: number,
105
- readonly body: { error: string; code: string; message: string }
106
- ) {}
107
+ readonly status: number;
108
+ readonly body: { error: string; code: string; message: string };
109
+
110
+ constructor(status: number, body: { error: string; code: string; message: string }) {
111
+ this.status = status;
112
+ this.body = body;
113
+ }
107
114
  }
108
115
  const refusal = (status: number, code: string, message: string) => new Refusal(status, { error: code, code, message });
109
116
 
@@ -114,8 +121,8 @@ function makeFetchRouter(routes: Record<string, unknown>) {
114
121
  // Most specific first: a route keyed with its query string (the two
115
122
  // document timelines share a path) wins over one keyed by path alone.
116
123
  const body = routes[`${method} ${path}${search}`] ?? routes[`${method} ${path}`] ?? routes[path];
117
- // A route given as a function answers or throws like fetch would.
118
- if (typeof body === 'function') return (body as () => unknown)();
124
+ // A route given as a function answers or throws like fetch would, and sees the request.
125
+ if (typeof body === 'function') return (body as (url: string, init: RequestInit) => unknown)(url, init);
119
126
  // A refusal the API would send, with its status: `refusal(409, {...})`.
120
127
  if (body instanceof Refusal) {
121
128
  return {
@@ -178,7 +185,8 @@ async function bootMcpPair(opts: { capabilities: string[]; routes?: Record<strin
178
185
  await taigaClient.introspect();
179
186
 
180
187
  const server = new McpServer({ name: 'taiga-test', version: '0.0.1' });
181
- registerAllTools(server, taigaClient);
188
+ // Waits poll every few milliseconds here, not every few seconds.
189
+ registerAllTools(server, taigaClient, { wait: { pollIntervalMs: 5 } });
182
190
 
183
191
  const client = new Client({ name: 'test-client', version: '0.0.1' });
184
192
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -355,8 +363,15 @@ describe('MCP server integration', () => {
355
363
  'taiga_get_product_document',
356
364
  'taiga_list_builds',
357
365
  'taiga_get_build',
366
+ 'taiga_get_queue',
367
+ 'taiga_wait_for_build',
368
+ ],
369
+ 'backlog:read': [
370
+ 'taiga_list_backlog_items',
371
+ 'taiga_get_backlog_item',
372
+ 'taiga_get_plan',
373
+ 'taiga_wait_for_backlog_item',
358
374
  ],
359
- 'backlog:read': ['taiga_list_backlog_items', 'taiga_get_backlog_item', 'taiga_get_plan'],
360
375
  'factories:read': ['taiga_list_factories', 'taiga_list_factory_documents', 'taiga_get_factory_document'],
361
376
  'tenant:documents:read': ['taiga_list_documents', 'taiga_get_document'],
362
377
  };
@@ -365,7 +380,7 @@ describe('MCP server integration', () => {
365
380
  'factories:read factories:documents:write': ['taiga_update_factory_document', 'taiga_publish_factory_document'],
366
381
  'backlog:write': ['taiga_create_backlog_item', 'taiga_move_backlog_item'],
367
382
  'backlog:write products:write': ['taiga_queue_backlog_item'],
368
- 'products:write': ['taiga_resume_backlog_item'],
383
+ 'products:write': ['taiga_resume_backlog_item', 'taiga_set_auto_build'],
369
384
  'backlog:read products:write': ['taiga_approve_plan', 'taiga_reject_plan'],
370
385
  'products:read products:write': ['taiga_stop_build', 'taiga_resume_build'],
371
386
  };
@@ -1058,7 +1073,7 @@ describe('MCP server integration', () => {
1058
1073
  expect(sentUrl(fetchSpy, '/builds').searchParams.get('backlogItemId')).toBe(ITEM_ID);
1059
1074
  });
1060
1075
 
1061
- it('should return a run with its steps from two routes', async () => {
1076
+ it('should return a run with its steps in full from two routes when asked', async () => {
1062
1077
  const { client } = await bootMcpPair({
1063
1078
  capabilities: ['products:read'],
1064
1079
  routes: {
@@ -1067,7 +1082,7 @@ describe('MCP server integration', () => {
1067
1082
  },
1068
1083
  });
1069
1084
 
1070
- const result = await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } });
1085
+ const result = await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID, full: true } });
1071
1086
 
1072
1087
  expect(result.isError).toBeFalsy();
1073
1088
  expect(parseToolResult(result)).toEqual({ ...run, steps: [step] });
@@ -1086,7 +1101,7 @@ describe('MCP server integration', () => {
1086
1101
  });
1087
1102
 
1088
1103
  const build = parseToolResult(
1089
- await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
1104
+ await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID, full: true } })
1090
1105
  );
1091
1106
  expect(build).toEqual({ ...run, steps: [{ ...step, verification: { ok: true } }] });
1092
1107
  expect(JSON.stringify(build)).not.toContain('tenantId');
@@ -1975,6 +1990,38 @@ describe('MCP server integration', () => {
1975
1990
  // Ten pages per open status, two statuses.
1976
1991
  expect(bundle.backlog.items).toHaveLength(20);
1977
1992
  expect(bundle.backlog.truncated).toBe(true);
1993
+ // The list pages oldest first: past the cap, the newest were never read, and the bundle says so.
1994
+ expect(bundle.backlog.note).toContain('only the oldest of them were read');
1995
+ });
1996
+
1997
+ it('should carry the newest fifty open items across both statuses and say the backlog is truncated', async () => {
1998
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES, routes: allRoutes });
1999
+ const router = fetchSpy.getMockImplementation() as (url: string, init: RequestInit) => Promise<unknown>;
2000
+ fetchSpy.mockImplementation(async (url: string, init: RequestInit) => {
2001
+ const parsed = new URL(url);
2002
+ if (!parsed.pathname.endsWith('/backlog')) return router(url, init);
2003
+ // The newest initiative is still in the backlog; sixty older ones are in progress. The
2004
+ // statuses are read one after the other, so arrival order is not age.
2005
+ const rows =
2006
+ parsed.searchParams.get('status') === 'in_progress'
2007
+ ? Array.from({ length: 60 }, (_, n) => ({
2008
+ ...item,
2009
+ id: `0199a1b2-c3d4-7e5f-8a9b-${String(n).padStart(12, '0')}`,
2010
+ title: `Item ${n}`,
2011
+ }))
2012
+ : [{ ...item, id: '0199a1b2-c3d4-7e5f-8a9c-000000000000', title: 'Newest', status: 'backlog' }];
2013
+ const body = { data: rows, nextCursor: null };
2014
+ return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
2015
+ });
2016
+
2017
+ const bundle = parseToolResult(
2018
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
2019
+ );
2020
+
2021
+ expect(bundle.backlog.items).toHaveLength(50);
2022
+ expect(bundle.backlog.items[0].title).toBe('Item 11');
2023
+ expect(bundle.backlog.items.at(-1).title).toBe('Newest');
2024
+ expect(bundle.backlog.truncated).toBe(true);
1978
2025
  });
1979
2026
 
1980
2027
  it('should carry the two documents as section ids only when asked for a small bundle', async () => {
@@ -2712,6 +2759,7 @@ describe('MCP server integration', () => {
2712
2759
  'taiga_reject_plan',
2713
2760
  'taiga_stop_build',
2714
2761
  'taiga_resume_build',
2762
+ 'taiga_set_auto_build',
2715
2763
  ]) {
2716
2764
  expect(byName[name], name).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: false });
2717
2765
  }
@@ -2774,7 +2822,8 @@ describe('MCP server integration', () => {
2774
2822
  const result = await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID } });
2775
2823
 
2776
2824
  expect(result.isError).toBeFalsy();
2777
- // One write, no read around it: the route answers with the item.
2825
+ // One write to the item, no item read around it: the route answers with the item (the reads
2826
+ // after it are the product's gate and its queue).
2778
2827
  expect(fetchSpy.mock.calls.filter((c: unknown[]) => (c[0] as string).includes('/backlog/'))).toHaveLength(1);
2779
2828
  expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'queue' });
2780
2829
  expect(outcomeOf(result)).toMatchObject({ id: ITEM_ID, state: 'queue', status: 'in_progress' });
@@ -2827,9 +2876,12 @@ describe('MCP server integration', () => {
2827
2876
  'taiga_create_backlog_item',
2828
2877
  'taiga_move_backlog_item',
2829
2878
  ]);
2830
- // The resume takes an item ID a create or a queue hands back, so it needs no backlog read;
2831
- // the verdicts take a plan ID only the plan read gives.
2832
- expect(toolNames((await operatorOnly.client.listTools()).tools)).toEqual(['taiga_resume_backlog_item']);
2879
+ // The resume and the review gate take an item ID a create or a queue hands back, so they need no
2880
+ // backlog read; the verdicts take a plan ID only the plan read gives.
2881
+ expect(toolNames((await operatorOnly.client.listTools()).tools)).toEqual([
2882
+ 'taiga_resume_backlog_item',
2883
+ 'taiga_set_auto_build',
2884
+ ]);
2833
2885
  });
2834
2886
 
2835
2887
  it('should move an initiative into plan with a full key, and refuse queue or plan for a key without products:write before any request', async () => {
@@ -3338,6 +3390,1072 @@ describe('MCP server integration', () => {
3338
3390
  });
3339
3391
  });
3340
3392
 
3393
+ describe('observing and waiting on the line', () => {
3394
+ const QUEUED_ITEM = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a61';
3395
+ const HEAD_ITEM = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a62';
3396
+ const criteria = [
3397
+ { criterion: 'AC-1 logs in', met: true },
3398
+ { criterion: 'AC-2 logs out', met: false },
3399
+ ];
3400
+ const item = {
3401
+ id: ITEM_ID,
3402
+ productId: PRODUCT_ID,
3403
+ title: 'Add login',
3404
+ description: 'A long brief',
3405
+ status: 'in_progress',
3406
+ state: 'queue',
3407
+ planningStatus: 'unplanned',
3408
+ needsHumanCause: null,
3409
+ needsHumanDetail: null,
3410
+ autoBuild: null,
3411
+ acceptanceCriteria: criteria,
3412
+ lifecycle: { turn: 'idle', stage: 'backlog', cta: 'start_plan', latestBuild: null },
3413
+ };
3414
+ const head = {
3415
+ ...item,
3416
+ id: HEAD_ITEM,
3417
+ title: 'ARC-3.3 OpenAPI',
3418
+ state: 'build',
3419
+ planningStatus: 'planned',
3420
+ lifecycle: {
3421
+ turn: 'you',
3422
+ stage: 'in_review',
3423
+ cta: 'review_pr',
3424
+ latestBuild: { id: BUILD_ID, status: 'needs_review', prUrl: 'https://github.com/acme/web/pull/22' },
3425
+ },
3426
+ };
3427
+ const entry = (backlogItemId: string, state: string, extra: Record<string, unknown> = {}) => ({
3428
+ id: `e-${backlogItemId.slice(-2)}`,
3429
+ productId: PRODUCT_ID,
3430
+ backlogItemId,
3431
+ position: 'a0',
3432
+ state,
3433
+ queuedBy: null,
3434
+ haltReason: null,
3435
+ haltCause: null,
3436
+ haltParams: null,
3437
+ queuedAt: '2026-10-08T00:00:00.000Z',
3438
+ startedAt: null,
3439
+ settledAt: null,
3440
+ ...extra,
3441
+ });
3442
+ /** What fetch answers, for a route given as a function. */
3443
+ const answer = (body: unknown) => ({
3444
+ ok: true,
3445
+ status: 200,
3446
+ json: () => Promise.resolve(body),
3447
+ text: () => Promise.resolve(JSON.stringify(body)),
3448
+ });
3449
+ /** A route that answers each row in turn, then keeps answering the last. */
3450
+ const sequence = (...rows: unknown[]) => {
3451
+ let index = 0;
3452
+ return () => answer({ data: rows[Math.min(index++, rows.length - 1)] });
3453
+ };
3454
+
3455
+ it('should count met acceptance criteria on every list row, and say null before an item has any', async () => {
3456
+ const unplanned = { ...item, id: QUEUED_ITEM, acceptanceCriteria: null };
3457
+ const { client } = await bootMcpPair({
3458
+ capabilities: ['backlog:read'],
3459
+ routes: { [`/v1/products/${PRODUCT_ID}/backlog`]: { data: [item, unplanned], nextCursor: null } },
3460
+ });
3461
+
3462
+ const rows = parseToolResult(
3463
+ await client.callTool({ name: 'taiga_list_backlog_items', arguments: { productId: PRODUCT_ID } })
3464
+ ).data;
3465
+
3466
+ expect(rows[0].criteria).toEqual({ met: 1, total: 2 });
3467
+ expect(rows[1].criteria).toBeNull();
3468
+ expect(rows[0]).not.toHaveProperty('acceptanceCriteria');
3469
+ });
3470
+
3471
+ it('should summarize a run by default: each step without its instructions, with the headline of its report', async () => {
3472
+ const longHeadline = 'x'.repeat(700);
3473
+ const steps = [
3474
+ {
3475
+ id: 's1',
3476
+ stepOrder: 1,
3477
+ title: 'Add the route',
3478
+ status: 'completed',
3479
+ description: 'Planner instructions the summary must not carry',
3480
+ summary: 'Added the route; tests pass.\n\nDetail the summary must not carry.',
3481
+ commitSha: 'abc',
3482
+ filesChanged: ['src/a.ts'],
3483
+ verification: [{ check: 'tests', status: 'passed' }],
3484
+ executionMetrics: { build_ms: 1 },
3485
+ writes: ['src/a.ts'],
3486
+ },
3487
+ { id: 's2', stepOrder: 2, title: 'Verify', status: 'in_progress', summary: longHeadline },
3488
+ { id: 's3', stepOrder: 3, title: 'Not yet', status: 'planned', summary: null },
3489
+ ];
3490
+ const run = { id: BUILD_ID, status: 'implementing', prUrl: null, completedSteps: 1, totalSteps: 3 };
3491
+ const { client } = await bootMcpPair({
3492
+ capabilities: ['products:read'],
3493
+ routes: { [`/v1/builds/${BUILD_ID}`]: { data: run }, [`/v1/builds/${BUILD_ID}/steps`]: { data: steps } },
3494
+ });
3495
+
3496
+ const result = parseToolResult(
3497
+ await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
3498
+ );
3499
+
3500
+ expect(result).toMatchObject(run);
3501
+ expect(result.steps[0]).toEqual({
3502
+ stepOrder: 1,
3503
+ title: 'Add the route',
3504
+ status: 'completed',
3505
+ commitSha: 'abc',
3506
+ filesChanged: ['src/a.ts'],
3507
+ verification: [{ check: 'tests', status: 'passed' }],
3508
+ headline: 'Added the route; tests pass.',
3509
+ });
3510
+ expect(result.steps[1].headline).toBe(`${'x'.repeat(600)}...`);
3511
+ expect(result.steps[2].headline).toBeNull();
3512
+ });
3513
+
3514
+ it('should read the queue in order, name each entry and say what a halted head is waiting for', async () => {
3515
+ const { client } = await bootMcpPair({
3516
+ capabilities: ['products:read', 'backlog:read'],
3517
+ routes: {
3518
+ [`/v1/products/${PRODUCT_ID}/queue`]: {
3519
+ data: [
3520
+ entry(ITEM_ID, 'settled'),
3521
+ entry(HEAD_ITEM, 'halted', { haltCause: 'awaiting_plan_review', haltParams: {} }),
3522
+ entry(QUEUED_ITEM, 'queued'),
3523
+ ],
3524
+ },
3525
+ [`/v1/backlog/${HEAD_ITEM}`]: {
3526
+ data: { ...head, state: 'plan', lifecycle: { ...head.lifecycle, latestBuild: null } },
3527
+ },
3528
+ [`/v1/backlog/${QUEUED_ITEM}`]: { data: { ...item, id: QUEUED_ITEM, title: 'README' } },
3529
+ },
3530
+ });
3531
+
3532
+ const queue = parseToolResult(
3533
+ await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3534
+ );
3535
+
3536
+ expect(queue.entries.map((row: { title: string }) => row.title)).toEqual(['ARC-3.3 OpenAPI', 'README']);
3537
+ expect(queue.entries[0]).toMatchObject({
3538
+ position: 1,
3539
+ entryState: 'halted',
3540
+ criteria: { met: 1, total: 2 },
3541
+ halt: {
3542
+ cause: 'awaiting_plan_review',
3543
+ reason: 'The plan is ready and waiting for you to read it, so the queue stopped here.',
3544
+ nextStep: expect.stringContaining('taiga_approve_plan'),
3545
+ },
3546
+ });
3547
+ expect(queue.entries[1]).toMatchObject({ position: 2, entryState: 'queued' });
3548
+ expect(queue.waitingOn).toMatch(
3549
+ /^"ARC-3.3 OpenAPI" halted the queue, holding the entries behind it: The plan is ready/
3550
+ );
3551
+ expect(queue).not.toHaveProperty('note');
3552
+ });
3553
+
3554
+ it('should say an active head waits on a person to review its pull request, which holds the line', async () => {
3555
+ const { client } = await bootMcpPair({
3556
+ capabilities: ['products:read', 'backlog:read'],
3557
+ routes: {
3558
+ [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active'), entry(QUEUED_ITEM, 'queued')] },
3559
+ [`/v1/backlog/${HEAD_ITEM}`]: { data: head },
3560
+ [`/v1/backlog/${QUEUED_ITEM}`]: { data: { ...item, id: QUEUED_ITEM } },
3561
+ },
3562
+ });
3563
+
3564
+ const queue = parseToolResult(
3565
+ await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3566
+ );
3567
+
3568
+ expect(queue.entries[0]).toMatchObject({ buildId: BUILD_ID, prUrl: 'https://github.com/acme/web/pull/22' });
3569
+ expect(queue.waitingOn).toBe(
3570
+ '"ARC-3.3 OpenAPI" is waiting on a person (review_pr) (https://github.com/acme/web/pull/22); every other queued initiative waits until its pull request merges.'
3571
+ );
3572
+ });
3573
+
3574
+ it('should answer what the line waits on by the active entry wherever it sits, before a halted one ahead of it', async () => {
3575
+ const { client, fetchSpy } = await bootMcpPair({
3576
+ capabilities: ALL_CAPABILITIES,
3577
+ routes: {
3578
+ [`/v1/products/${PRODUCT_ID}/queue`]: {
3579
+ data: [
3580
+ entry(QUEUED_ITEM, 'halted', { haltCause: 'run_failed', haltParams: {} }),
3581
+ entry(HEAD_ITEM, 'active'),
3582
+ ],
3583
+ },
3584
+ [`/v1/backlog/${QUEUED_ITEM}`]: { data: { ...item, id: QUEUED_ITEM, title: 'Halted one' } },
3585
+ [`/v1/backlog/${HEAD_ITEM}`]: { data: head },
3586
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: item },
3587
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: { id: PRODUCT_ID } },
3588
+ },
3589
+ });
3590
+
3591
+ const queue = parseToolResult(
3592
+ await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3593
+ );
3594
+ expect(queue.waitingOn).toMatch(/^"ARC-3.3 OpenAPI" is waiting on a person/);
3595
+
3596
+ fetchSpy.mockClear();
3597
+ const queued = await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID } });
3598
+ expect(queued.isError).toBeFalsy();
3599
+ // Placing an item names only the entry the line waits on: the active one.
3600
+ const itemReads = fetchSpy.mock.calls
3601
+ .filter((c: unknown[]) => (c[1] as RequestInit)?.method === 'GET' && (c[0] as string).includes('/backlog/'))
3602
+ .map((c: unknown[]) => new URL(c[0] as string).pathname);
3603
+ expect(itemReads).toEqual([`/v1/backlog/${HEAD_ITEM}`]);
3604
+ });
3605
+
3606
+ it('should say a halted entry behind others does not hold the ones ahead of it', async () => {
3607
+ const { client } = await bootMcpPair({
3608
+ capabilities: ['products:read'],
3609
+ routes: {
3610
+ [`/v1/products/${PRODUCT_ID}/queue`]: {
3611
+ data: [
3612
+ entry(QUEUED_ITEM, 'queued'),
3613
+ entry(HEAD_ITEM, 'halted', { haltCause: 'run_failed', haltParams: {} }),
3614
+ ],
3615
+ },
3616
+ },
3617
+ });
3618
+
3619
+ const queue = parseToolResult(
3620
+ await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3621
+ );
3622
+
3623
+ expect(queue.waitingOn).toContain('holding the entries behind it');
3624
+ expect(queue.waitingOn).toContain('Entries ahead of it are not held by it.');
3625
+ });
3626
+
3627
+ it('should say an active entry with a landed plan waits for the Build slot, not for a person', async () => {
3628
+ const landed = {
3629
+ ...head,
3630
+ state: 'plan',
3631
+ lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
3632
+ };
3633
+ const { client } = await bootMcpPair({
3634
+ capabilities: ['products:read', 'backlog:read'],
3635
+ routes: {
3636
+ [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active')] },
3637
+ [`/v1/backlog/${HEAD_ITEM}`]: { data: landed },
3638
+ },
3639
+ });
3640
+
3641
+ const queue = parseToolResult(
3642
+ await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3643
+ );
3644
+
3645
+ expect(queue.waitingOn).toBe(
3646
+ '"ARC-3.3 OpenAPI" has its plan and waits for the Build slot; every other queued initiative waits until its pull request merges.'
3647
+ );
3648
+ });
3649
+
3650
+ it('should keep the queue when an entry cannot be named, and say why entries carry ids only', async () => {
3651
+ const queueRoute = { [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active')] } };
3652
+ // One pair at a time: each boot stubs the global fetch the previous client reads through.
3653
+ const noBacklog = await bootMcpPair({ capabilities: ['products:read'], routes: queueRoute });
3654
+ const without = parseToolResult(
3655
+ await noBacklog.client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3656
+ );
3657
+ expect(noBacklog.fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes('/backlog/'))).toBe(false);
3658
+ const failing = await bootMcpPair({
3659
+ capabilities: ['products:read', 'backlog:read'],
3660
+ routes: { ...queueRoute, [`/v1/backlog/${HEAD_ITEM}`]: refusal(500, 'INTERNAL', 'boom') },
3661
+ });
3662
+ const failed = parseToolResult(
3663
+ await failing.client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
3664
+ );
3665
+
3666
+ expect(without.entries[0]).toEqual({ position: 1, backlogItemId: HEAD_ITEM, entryState: 'active' });
3667
+ expect(without.note).toContain('backlog:read');
3668
+ expect(failed.entries[0]).not.toHaveProperty('title');
3669
+ expect(failed.note).toContain('could not be read');
3670
+ expect(failed.waitingOn).toMatch(/^Initiative .* is the active entry/);
3671
+ });
3672
+
3673
+ it('should set the review gate before the move when queueing, then report the gate and the place in line', async () => {
3674
+ const { client, fetchSpy } = await bootMcpPair({
3675
+ capabilities: ALL_CAPABILITIES,
3676
+ routes: {
3677
+ [`PUT /v1/backlog/${ITEM_ID}/auto-build`]: { data: { ...item, autoBuild: false } },
3678
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: { ...item, autoBuild: false } },
3679
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: { id: PRODUCT_ID, autoBuild: true } },
3680
+ [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active'), entry(ITEM_ID, 'queued')] },
3681
+ [`/v1/backlog/${HEAD_ITEM}`]: { data: head },
3682
+ },
3683
+ });
3684
+
3685
+ const result = parseToolResult(
3686
+ await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID, autoBuild: false } })
3687
+ );
3688
+
3689
+ // To place one item and say what the line waits on, only the blocking entry is read.
3690
+ const itemReads = fetchSpy.mock.calls.filter(
3691
+ (c: unknown[]) => (c[1] as RequestInit)?.method === 'GET' && (c[0] as string).includes('/backlog/')
3692
+ );
3693
+ expect(itemReads.map((c: unknown[]) => new URL(c[0] as string).pathname)).toEqual([`/v1/backlog/${HEAD_ITEM}`]);
3694
+
3695
+ const writes = fetchSpy.mock.calls
3696
+ .filter((c: unknown[]) => (c[1] as RequestInit)?.method === 'PUT')
3697
+ .map((c: unknown[]) => new URL(c[0] as string).pathname);
3698
+ expect(writes).toEqual([`/v1/backlog/${ITEM_ID}/auto-build`, `/v1/backlog/${ITEM_ID}/state`]);
3699
+ expect(sentBody(fetchSpy, 'PUT', '/auto-build')).toEqual({ autoBuild: false });
3700
+ expect(sentBody(fetchSpy, 'PUT', '/state')).toEqual({ state: 'queue' });
3701
+ expect(result).toMatchObject({
3702
+ id: ITEM_ID,
3703
+ autoBuild: false,
3704
+ criteria: { met: 1, total: 2 },
3705
+ reviewGate: { buildsWithoutReview: false, decidedBy: 'initiative' },
3706
+ queue: { position: 2, of: 2, waitingOn: expect.stringContaining('ARC-3.3 OpenAPI') },
3707
+ });
3708
+ expect(result).not.toHaveProperty('note');
3709
+ });
3710
+
3711
+ it("should leave the gate alone when queueing without autoBuild, and report the product's default as deciding", async () => {
3712
+ const { client, fetchSpy } = await bootMcpPair({
3713
+ capabilities: ALL_CAPABILITIES,
3714
+ routes: {
3715
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: item },
3716
+ // A product row written before the field shipped: absent reads as true.
3717
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: { id: PRODUCT_ID } },
3718
+ },
3719
+ });
3720
+
3721
+ const result = parseToolResult(
3722
+ await client.callTool({ name: 'taiga_queue_backlog_item', arguments: { itemId: ITEM_ID } })
3723
+ );
3724
+
3725
+ expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes('/auto-build'))).toBe(false);
3726
+ expect(result.reviewGate).toMatchObject({ buildsWithoutReview: true, decidedBy: 'product' });
3727
+ // The queue read missed (no route); the outcome says so rather than failing the write it reports.
3728
+ expect(result.queue).toBeNull();
3729
+ expect(result.note).toContain('taiga_get_queue');
3730
+ });
3731
+
3732
+ it('should set an initiative back to the product default with null and report the gate that now applies', async () => {
3733
+ const { client, fetchSpy } = await bootMcpPair({
3734
+ capabilities: ['products:read', 'products:write'],
3735
+ routes: {
3736
+ [`PUT /v1/backlog/${ITEM_ID}/auto-build`]: { data: item },
3737
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: { id: PRODUCT_ID, autoBuild: false } },
3738
+ },
3739
+ });
3740
+
3741
+ const result = parseToolResult(
3742
+ await client.callTool({ name: 'taiga_set_auto_build', arguments: { itemId: ITEM_ID, autoBuild: null } })
3743
+ );
3744
+
3745
+ expect(sentBody(fetchSpy, 'PUT', '/auto-build')).toEqual({ autoBuild: null });
3746
+ expect(result.reviewGate).toMatchObject({ buildsWithoutReview: false, decidedBy: 'product' });
3747
+ });
3748
+
3749
+ it('should wait until the plan lands and report the item as it is then', async () => {
3750
+ const planning = { ...item, state: 'plan', planningStatus: 'planning' };
3751
+ const planned = {
3752
+ ...item,
3753
+ state: 'plan',
3754
+ planningStatus: 'planned',
3755
+ lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
3756
+ };
3757
+ const { client, fetchSpy } = await bootMcpPair({
3758
+ capabilities: ['backlog:read'],
3759
+ routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(planning, planning, planned) },
3760
+ });
3761
+
3762
+ const result = parseToolResult(
3763
+ await client.callTool({
3764
+ name: 'taiga_wait_for_backlog_item',
3765
+ arguments: { itemId: ITEM_ID, until: 'planned', timeoutSeconds: 30 },
3766
+ })
3767
+ );
3768
+
3769
+ expect(result).toMatchObject({
3770
+ until: 'planned',
3771
+ reached: true,
3772
+ item: {
3773
+ planningStatus: 'planned',
3774
+ lifecycle: { turn: 'you', stage: 'planned' },
3775
+ criteria: { met: 1, total: 2 },
3776
+ },
3777
+ });
3778
+ expect(fetchSpy.mock.calls.filter((c: unknown[]) => (c[0] as string).includes('/backlog/'))).toHaveLength(3);
3779
+ });
3780
+
3781
+ it('should stop waiting as soon as the initiative needs a person, since the condition cannot come true on its own', async () => {
3782
+ const flagged = { ...item, needsHumanCause: 'planning_failed', planningStatus: 'failed' };
3783
+ const { client } = await bootMcpPair({
3784
+ capabilities: ['backlog:read'],
3785
+ routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(item, flagged) },
3786
+ });
3787
+
3788
+ const result = parseToolResult(
3789
+ await client.callTool({ name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'pr_open' } })
3790
+ );
3791
+
3792
+ expect(result).toMatchObject({ reached: false, stoppedBecause: 'needs a person: planning_failed' });
3793
+ });
3794
+
3795
+ it('should stop waiting at an ordinary gate that sets no flag: a plan waiting for its verdict, a pull request for its review', async () => {
3796
+ const awaitingVerdict = {
3797
+ ...item,
3798
+ state: 'plan',
3799
+ planningStatus: 'planned',
3800
+ lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
3801
+ };
3802
+ // The initiative's own closed gate: no product read needed.
3803
+ const verdict = await bootMcpPair({
3804
+ capabilities: ['backlog:read'],
3805
+ routes: { [`/v1/backlog/${ITEM_ID}`]: { data: { ...awaitingVerdict, autoBuild: false } } },
3806
+ });
3807
+ const plan = parseToolResult(
3808
+ await verdict.client.callTool({
3809
+ name: 'taiga_wait_for_backlog_item',
3810
+ arguments: { itemId: ITEM_ID, until: 'pr_open' },
3811
+ })
3812
+ );
3813
+ expect(plan).toMatchObject({ reached: false, stoppedBecause: 'waits on a person: start_build' });
3814
+
3815
+ // No gate of its own and a key that cannot read the product's default: unknown, said as such.
3816
+ const unknown = await bootMcpPair({
3817
+ capabilities: ['backlog:read'],
3818
+ routes: { [`/v1/backlog/${ITEM_ID}`]: { data: awaitingVerdict } },
3819
+ });
3820
+ const unsure = parseToolResult(
3821
+ await unknown.client.callTool({
3822
+ name: 'taiga_wait_for_backlog_item',
3823
+ arguments: { itemId: ITEM_ID, until: 'your_turn' },
3824
+ })
3825
+ );
3826
+ expect(unsure.reached).toBe(false);
3827
+ expect(unsure.stoppedBecause).toMatch(/whether it waits for a review is unknown/);
3828
+
3829
+ const review = await bootMcpPair({
3830
+ capabilities: ['backlog:read'],
3831
+ routes: { [`/v1/backlog/${ITEM_ID}`]: { data: head } },
3832
+ });
3833
+ const pr = parseToolResult(
3834
+ await review.client.callTool({
3835
+ name: 'taiga_wait_for_backlog_item',
3836
+ arguments: { itemId: ITEM_ID, until: 'done' },
3837
+ })
3838
+ );
3839
+ expect(pr).toMatchObject({ reached: false, stoppedBecause: 'waits on a person: review_pr' });
3840
+ });
3841
+
3842
+ it('should not let a slow read outlast the timeout: the read is cut at the deadline', async () => {
3843
+ let call = 0;
3844
+ let hung: AbortSignal | undefined;
3845
+ const { client } = await bootMcpPair({
3846
+ capabilities: ['backlog:read'],
3847
+ routes: {
3848
+ [`/v1/backlog/${ITEM_ID}`]: (_url: string, init: RequestInit) => {
3849
+ if (call++ === 0) return answer({ data: item });
3850
+ hung = init.signal!;
3851
+ // An API that never answers: only the abort ends this read.
3852
+ return new Promise((_resolve, reject) =>
3853
+ init.signal!.addEventListener('abort', () => reject(init.signal!.reason))
3854
+ );
3855
+ },
3856
+ },
3857
+ });
3858
+
3859
+ const started = Date.now();
3860
+ const result = parseToolResult(
3861
+ await client.callTool({
3862
+ name: 'taiga_wait_for_backlog_item',
3863
+ arguments: { itemId: ITEM_ID, until: 'planned', timeoutSeconds: 1 },
3864
+ })
3865
+ );
3866
+
3867
+ expect(Date.now() - started).toBeLessThan(1_500);
3868
+ expect(result).toMatchObject({ reached: false, stoppedBecause: 'timeout', item: { id: ITEM_ID } });
3869
+ expect(hung?.aborted).toBe(true);
3870
+ });
3871
+
3872
+ it('should answer a timeout, with no item, when not even the first read comes back in time', async () => {
3873
+ const { client } = await bootMcpPair({
3874
+ capabilities: ['backlog:read'],
3875
+ routes: {
3876
+ [`/v1/backlog/${ITEM_ID}`]: (_url: string, init: RequestInit) =>
3877
+ new Promise((_resolve, reject) =>
3878
+ init.signal!.addEventListener('abort', () => reject(init.signal!.reason))
3879
+ ),
3880
+ },
3881
+ });
3882
+
3883
+ const result = await client.callTool({
3884
+ name: 'taiga_wait_for_backlog_item',
3885
+ arguments: { itemId: ITEM_ID, until: 'planned', timeoutSeconds: 1 },
3886
+ });
3887
+
3888
+ expect(result.isError).toBeFalsy();
3889
+ expect(parseToolResult(result)).toMatchObject({ reached: false, stoppedBecause: 'timeout', item: null });
3890
+ });
3891
+
3892
+ it('should wake a wait for any change when the pull request closes and nothing else moves', async () => {
3893
+ const run = {
3894
+ id: BUILD_ID,
3895
+ status: 'implementing',
3896
+ completedSteps: 2,
3897
+ prUrl: 'https://github.com/acme/web/pull/9',
3898
+ prMerged: false,
3899
+ };
3900
+ const withRun = (prState: string) => ({
3901
+ ...item,
3902
+ lifecycle: { turn: 'agent', stage: 'building', cta: null, latestBuild: { ...run, prState } },
3903
+ });
3904
+ const { client } = await bootMcpPair({
3905
+ capabilities: ['backlog:read'],
3906
+ routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(withRun('open'), withRun('open'), withRun('closed')) },
3907
+ });
3908
+
3909
+ const result = parseToolResult(
3910
+ await client.callTool({ name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'changed' } })
3911
+ );
3912
+
3913
+ expect(result).toMatchObject({ reached: true, item: { latestBuild: { prState: 'closed' } } });
3914
+ });
3915
+
3916
+ it('should end the read in flight when the call is cancelled', async () => {
3917
+ let call = 0;
3918
+ let hung: AbortSignal | undefined;
3919
+ const { client } = await bootMcpPair({
3920
+ capabilities: ['backlog:read'],
3921
+ routes: {
3922
+ [`/v1/backlog/${ITEM_ID}`]: (_url: string, init: RequestInit) => {
3923
+ if (call++ === 0) return answer({ data: item });
3924
+ hung = init.signal!;
3925
+ return new Promise((_resolve, reject) =>
3926
+ init.signal!.addEventListener('abort', () => reject(init.signal!.reason))
3927
+ );
3928
+ },
3929
+ },
3930
+ });
3931
+ const controller = new AbortController();
3932
+
3933
+ const pending = client.callTool(
3934
+ { name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'planned', timeoutSeconds: 60 } },
3935
+ undefined,
3936
+ { signal: controller.signal }
3937
+ );
3938
+ await vi.waitFor(() => expect(hung).toBeDefined());
3939
+ controller.abort();
3940
+
3941
+ await expect(pending).rejects.toThrow();
3942
+ await vi.waitFor(() => expect(hung!.aborted).toBe(true));
3943
+ });
3944
+
3945
+ it('should not count a merged pull request as open, nor a canceled initiative as done', async () => {
3946
+ const merged = {
3947
+ ...item,
3948
+ state: 'done',
3949
+ lifecycle: {
3950
+ turn: 'idle',
3951
+ stage: 'done',
3952
+ cta: null,
3953
+ latestBuild: {
3954
+ id: BUILD_ID,
3955
+ status: 'completed',
3956
+ prUrl: 'https://github.com/acme/web/pull/24',
3957
+ prState: 'closed',
3958
+ prMerged: true,
3959
+ },
3960
+ },
3961
+ };
3962
+ const shipped = await bootMcpPair({
3963
+ capabilities: ['backlog:read'],
3964
+ routes: { [`/v1/backlog/${ITEM_ID}`]: { data: merged } },
3965
+ });
3966
+ const pr = parseToolResult(
3967
+ await shipped.client.callTool({
3968
+ name: 'taiga_wait_for_backlog_item',
3969
+ arguments: { itemId: ITEM_ID, until: 'pr_open' },
3970
+ })
3971
+ );
3972
+ expect(pr).toMatchObject({ reached: false, stoppedBecause: 'the initiative is done' });
3973
+
3974
+ const canceled = {
3975
+ ...item,
3976
+ state: 'canceled',
3977
+ lifecycle: { turn: 'idle', stage: 'canceled', cta: null, latestBuild: null },
3978
+ };
3979
+ const dropped = await bootMcpPair({
3980
+ capabilities: ['backlog:read'],
3981
+ routes: { [`/v1/backlog/${ITEM_ID}`]: { data: canceled } },
3982
+ });
3983
+ const done = parseToolResult(
3984
+ await dropped.client.callTool({
3985
+ name: 'taiga_wait_for_backlog_item',
3986
+ arguments: { itemId: ITEM_ID, until: 'done' },
3987
+ })
3988
+ );
3989
+ expect(done).toMatchObject({ reached: false, stoppedBecause: 'the initiative is canceled' });
3990
+
3991
+ const run = {
3992
+ id: BUILD_ID,
3993
+ status: 'completed',
3994
+ completedSteps: 3,
3995
+ prUrl: 'https://github.com/acme/web/pull/24',
3996
+ prState: 'closed',
3997
+ prMerged: true,
3998
+ };
3999
+ const finished = await bootMcpPair({
4000
+ capabilities: ['products:read'],
4001
+ routes: { [`/v1/builds/${BUILD_ID}`]: { data: run } },
4002
+ });
4003
+ const runPr = parseToolResult(
4004
+ await finished.client.callTool({
4005
+ name: 'taiga_wait_for_build',
4006
+ arguments: { buildId: BUILD_ID, until: 'pr_open' },
4007
+ })
4008
+ );
4009
+ expect(runPr).toMatchObject({ reached: false, stoppedBecause: 'the run is completed' });
4010
+ });
4011
+
4012
+ it("should not take a landed plan behind an open review gate for a person's turn: it waits for the Build slot", async () => {
4013
+ const landed = {
4014
+ ...item,
4015
+ state: 'plan',
4016
+ planningStatus: 'planned',
4017
+ autoBuild: true,
4018
+ lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
4019
+ };
4020
+ const building = {
4021
+ ...landed,
4022
+ state: 'build',
4023
+ lifecycle: {
4024
+ turn: 'agent',
4025
+ stage: 'building',
4026
+ cta: null,
4027
+ latestBuild: {
4028
+ id: BUILD_ID,
4029
+ status: 'implementing',
4030
+ prUrl: 'https://github.com/acme/web/pull/9',
4031
+ prState: 'open',
4032
+ },
4033
+ },
4034
+ };
4035
+ const { client } = await bootMcpPair({
4036
+ capabilities: ['backlog:read'],
4037
+ routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(landed, landed, building) },
4038
+ });
4039
+
4040
+ const result = parseToolResult(
4041
+ await client.callTool({ name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'pr_open' } })
4042
+ );
4043
+
4044
+ // Not stopped at the landed plan: the open gate builds it without anyone.
4045
+ expect(result).toMatchObject({ reached: true, item: { latestBuild: { prState: 'open' } } });
4046
+ });
4047
+
4048
+ it("should take a landed plan for a person's turn when the product's closed gate holds it, reading the product once", async () => {
4049
+ const landed = {
4050
+ ...item,
4051
+ state: 'plan',
4052
+ planningStatus: 'planned',
4053
+ autoBuild: null,
4054
+ lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
4055
+ };
4056
+ const { client, fetchSpy } = await bootMcpPair({
4057
+ capabilities: ['backlog:read', 'products:read'],
4058
+ routes: {
4059
+ [`/v1/backlog/${ITEM_ID}`]: sequence(
4060
+ { ...landed, planningStatus: 'planning', lifecycle: item.lifecycle },
4061
+ landed
4062
+ ),
4063
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: { id: PRODUCT_ID, autoBuild: false } },
4064
+ },
4065
+ });
4066
+
4067
+ const result = parseToolResult(
4068
+ await client.callTool({
4069
+ name: 'taiga_wait_for_backlog_item',
4070
+ arguments: { itemId: ITEM_ID, until: 'your_turn' },
4071
+ })
4072
+ );
4073
+
4074
+ expect(result).toMatchObject({ reached: true, item: { lifecycle: { cta: 'start_build' } } });
4075
+ const productReads = fetchSpy.mock.calls.filter(
4076
+ (c: unknown[]) => new URL(c[0] as string).pathname === `/v1/products/${PRODUCT_ID}`
4077
+ );
4078
+ expect(productReads).toHaveLength(1);
4079
+ });
4080
+
4081
+ it('should ride out a transient failure of the first read, and of the product read behind the gate', async () => {
4082
+ const failing = { ok: false, status: 503, json: async () => ({}), text: async () => '{"message":"Unavailable"}' };
4083
+ const landed = {
4084
+ ...item,
4085
+ state: 'plan',
4086
+ planningStatus: 'planned',
4087
+ autoBuild: null,
4088
+ lifecycle: { turn: 'you', stage: 'planned', cta: 'start_build', latestBuild: null },
4089
+ };
4090
+ let itemCalls = 0;
4091
+ let productCalls = 0;
4092
+ const { client } = await bootMcpPair({
4093
+ capabilities: ['backlog:read', 'products:read'],
4094
+ routes: {
4095
+ [`/v1/backlog/${ITEM_ID}`]: () => (itemCalls++ === 0 ? failing : answer({ data: landed })),
4096
+ [`GET /v1/products/${PRODUCT_ID}`]: () =>
4097
+ productCalls++ === 0 ? failing : answer({ data: { id: PRODUCT_ID, autoBuild: false } }),
4098
+ },
4099
+ });
4100
+
4101
+ const result = parseToolResult(
4102
+ await client.callTool({
4103
+ name: 'taiga_wait_for_backlog_item',
4104
+ arguments: { itemId: ITEM_ID, until: 'your_turn' },
4105
+ })
4106
+ );
4107
+
4108
+ // A 503 on the first item read, then a 503 on the product read: both retried, then the closed gate answers.
4109
+ expect(result).toMatchObject({ reached: true, item: { lifecycle: { cta: 'start_build' } } });
4110
+ expect(productCalls).toBe(2);
4111
+ });
4112
+
4113
+ it('should answer reached: false at the timeout', async () => {
4114
+ const { client } = await bootMcpPair({
4115
+ capabilities: ['backlog:read'],
4116
+ routes: { [`/v1/backlog/${ITEM_ID}`]: { data: item } },
4117
+ });
4118
+
4119
+ const result = parseToolResult(
4120
+ await client.callTool({
4121
+ name: 'taiga_wait_for_backlog_item',
4122
+ arguments: { itemId: ITEM_ID, until: 'changed', timeoutSeconds: 1 },
4123
+ })
4124
+ );
4125
+
4126
+ expect(result).toMatchObject({ until: 'changed', reached: false, stoppedBecause: 'timeout', waitedSeconds: 1 });
4127
+ });
4128
+
4129
+ it('should report progress while waiting when the client asks for it', async () => {
4130
+ const { client } = await bootMcpPair({
4131
+ capabilities: ['backlog:read'],
4132
+ routes: {
4133
+ [`/v1/backlog/${ITEM_ID}`]: sequence(item, item, { ...item, state: 'plan', planningStatus: 'planning' }),
4134
+ },
4135
+ });
4136
+ const onprogress = vi.fn();
4137
+
4138
+ const result = await client.callTool(
4139
+ { name: 'taiga_wait_for_backlog_item', arguments: { itemId: ITEM_ID, until: 'changed' } },
4140
+ undefined,
4141
+ { onprogress }
4142
+ );
4143
+
4144
+ expect(parseToolResult(result).reached).toBe(true);
4145
+ expect(onprogress).toHaveBeenCalledWith(expect.objectContaining({ message: 'Still waiting' }));
4146
+ });
4147
+
4148
+ it('should wait for a run to finish a step, and stop early when it settles without a pull request', async () => {
4149
+ const running = { id: BUILD_ID, status: 'implementing', completedSteps: 1, prUrl: null, error: 'noise' };
4150
+ // One pair at a time: each boot stubs the global fetch the previous client reads through.
4151
+ const stepped = await bootMcpPair({
4152
+ capabilities: ['products:read'],
4153
+ routes: { [`/v1/builds/${BUILD_ID}`]: sequence(running, { ...running, completedSteps: 2 }) },
4154
+ });
4155
+ const step = parseToolResult(
4156
+ await stepped.client.callTool({
4157
+ name: 'taiga_wait_for_build',
4158
+ arguments: { buildId: BUILD_ID, until: 'step_done' },
4159
+ })
4160
+ );
4161
+ const failed = await bootMcpPair({
4162
+ capabilities: ['products:read'],
4163
+ routes: { [`/v1/builds/${BUILD_ID}`]: sequence(running, { ...running, status: 'failed' }) },
4164
+ });
4165
+ const settled = parseToolResult(
4166
+ await failed.client.callTool({
4167
+ name: 'taiga_wait_for_build',
4168
+ arguments: { buildId: BUILD_ID, until: 'pr_open' },
4169
+ })
4170
+ );
4171
+
4172
+ expect(step).toMatchObject({ reached: true, run: { completedSteps: 2 } });
4173
+ expect(step.run).not.toHaveProperty('error');
4174
+ expect(settled).toMatchObject({ reached: false, stoppedBecause: 'the run is failed', run: { status: 'failed' } });
4175
+ });
4176
+
4177
+ it('should ride out a transient read failure while waiting, and give up after three in a row', async () => {
4178
+ const failing = { ok: false, status: 502, json: async () => ({}), text: async () => '{"message":"Bad gateway"}' };
4179
+ const reads = [answer({ data: item }), failing, answer({ data: { ...item, planningStatus: 'planned' } })];
4180
+ let call = 0;
4181
+ const recovered = await bootMcpPair({
4182
+ capabilities: ['backlog:read'],
4183
+ routes: { [`/v1/backlog/${ITEM_ID}`]: () => reads[Math.min(call++, reads.length - 1)] },
4184
+ });
4185
+ const result = parseToolResult(
4186
+ await recovered.client.callTool({
4187
+ name: 'taiga_wait_for_backlog_item',
4188
+ arguments: { itemId: ITEM_ID, until: 'planned' },
4189
+ })
4190
+ );
4191
+ expect(result.reached).toBe(true);
4192
+
4193
+ let calls = 0;
4194
+ const broken = await bootMcpPair({
4195
+ capabilities: ['backlog:read'],
4196
+ routes: { [`/v1/backlog/${ITEM_ID}`]: () => (calls++ === 0 ? answer({ data: item }) : failing) },
4197
+ });
4198
+ const failed = await broken.client.callTool({
4199
+ name: 'taiga_wait_for_backlog_item',
4200
+ arguments: { itemId: ITEM_ID, until: 'planned' },
4201
+ });
4202
+ expect(failed.isError).toBe(true);
4203
+ expect(calls).toBe(4);
4204
+ });
4205
+
4206
+ it('should stop a wait for the plan when planning fails, and keep waiting on a flagged item when asked for any change', async () => {
4207
+ const failedPlan = { ...item, planningStatus: 'failed' };
4208
+ const planning = await bootMcpPair({
4209
+ capabilities: ['backlog:read'],
4210
+ routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(item, failedPlan) },
4211
+ });
4212
+ const stopped = parseToolResult(
4213
+ await planning.client.callTool({
4214
+ name: 'taiga_wait_for_backlog_item',
4215
+ arguments: { itemId: ITEM_ID, until: 'planned' },
4216
+ })
4217
+ );
4218
+ expect(stopped).toMatchObject({ reached: false, stoppedBecause: 'planning failed' });
4219
+
4220
+ const flagged = { ...item, needsHumanCause: 'planning_failed' };
4221
+ const waiting = await bootMcpPair({
4222
+ capabilities: ['backlog:read'],
4223
+ routes: { [`/v1/backlog/${ITEM_ID}`]: sequence(flagged, flagged, { ...item, state: 'plan' }) },
4224
+ });
4225
+ const changed = parseToolResult(
4226
+ await waiting.client.callTool({
4227
+ name: 'taiga_wait_for_backlog_item',
4228
+ arguments: { itemId: ITEM_ID, until: 'changed' },
4229
+ })
4230
+ );
4231
+ expect(changed).toMatchObject({ reached: true, item: { state: 'plan', needsHumanCause: null } });
4232
+ });
4233
+
4234
+ it('should not move the initiative when setting its gate is refused', async () => {
4235
+ const { client, fetchSpy } = await bootMcpPair({
4236
+ capabilities: ALL_CAPABILITIES,
4237
+ routes: {
4238
+ [`PUT /v1/backlog/${ITEM_ID}/auto-build`]: refusal(409, 'CONFLICT', 'This initiative is finished.'),
4239
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: { data: item },
4240
+ },
4241
+ });
4242
+
4243
+ const result = await client.callTool({
4244
+ name: 'taiga_queue_backlog_item',
4245
+ arguments: { itemId: ITEM_ID, autoBuild: false },
4246
+ });
4247
+
4248
+ expect(result.isError).toBe(true);
4249
+ expect(parseToolResult(result)).toEqual({ error: 'CONFLICT', message: 'This initiative is finished.' });
4250
+ expect(fetchSpy.mock.calls.some((c: unknown[]) => (c[0] as string).includes('/state'))).toBe(false);
4251
+ });
4252
+
4253
+ it('should say the gate could not be read when the key cannot read the product', async () => {
4254
+ const { client } = await bootMcpPair({
4255
+ capabilities: ['products:write'],
4256
+ routes: {
4257
+ [`PUT /v1/backlog/${ITEM_ID}/auto-build`]: { data: item },
4258
+ [`GET /v1/products/${PRODUCT_ID}`]: refusal(403, 'FORBIDDEN', 'No'),
4259
+ },
4260
+ });
4261
+
4262
+ const result = parseToolResult(
4263
+ await client.callTool({ name: 'taiga_set_auto_build', arguments: { itemId: ITEM_ID, autoBuild: null } })
4264
+ );
4265
+
4266
+ expect(result.reviewGate).toBeNull();
4267
+ expect(result.note).toContain('taiga_get_product');
4268
+ });
4269
+
4270
+ it("should report an initiative's own gate without reading the product, which it does not need", async () => {
4271
+ const { client, fetchSpy } = await bootMcpPair({
4272
+ capabilities: ['products:write'],
4273
+ routes: { [`PUT /v1/backlog/${ITEM_ID}/auto-build`]: { data: { ...item, autoBuild: false } } },
4274
+ });
4275
+
4276
+ const result = parseToolResult(
4277
+ await client.callTool({ name: 'taiga_set_auto_build', arguments: { itemId: ITEM_ID, autoBuild: false } })
4278
+ );
4279
+
4280
+ expect(result.reviewGate).toMatchObject({ buildsWithoutReview: false, decidedBy: 'initiative' });
4281
+ expect(result).not.toHaveProperty('note');
4282
+ expect(
4283
+ fetchSpy.mock.calls.some((c: unknown[]) => new URL(c[0] as string).pathname === `/v1/products/${PRODUCT_ID}`)
4284
+ ).toBe(false);
4285
+ });
4286
+
4287
+ it('should advise each halt from the registry: resume where a person may, wait where it clears itself', async () => {
4288
+ const { client } = await bootMcpPair({
4289
+ capabilities: ['products:read'],
4290
+ routes: {
4291
+ [`/v1/products/${PRODUCT_ID}/queue`]: {
4292
+ data: [
4293
+ entry(HEAD_ITEM, 'halted', { haltCause: 'run_failed', haltParams: {} }),
4294
+ entry(QUEUED_ITEM, 'halted', { haltCause: 'activation_refused', haltParams: { message: 'No seats' } }),
4295
+ entry(ITEM_ID, 'halted', { haltCause: 'not_a_cause_yet', haltReason: 'Stored prose' }),
4296
+ ],
4297
+ },
4298
+ },
4299
+ });
4300
+
4301
+ const [failed, refused, unknown] = parseToolResult(
4302
+ await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } })
4303
+ ).entries;
4304
+
4305
+ expect(failed.halt.nextStep).toMatch(/^It clears when the run is retried/);
4306
+ expect(refused.halt).toMatchObject({
4307
+ reason: 'No seats',
4308
+ nextStep: expect.stringMatching(/^taiga_resume_backlog_item lifts it/),
4309
+ });
4310
+ expect(unknown.halt).toMatchObject({
4311
+ reason: 'Stored prose',
4312
+ nextStep: expect.stringContaining('taiga_get_backlog_item'),
4313
+ });
4314
+ });
4315
+
4316
+ it('should fail the queue read on a fault that is not the API, rather than hide it as an unnamed entry', async () => {
4317
+ const { client } = await bootMcpPair({
4318
+ capabilities: ['products:read', 'backlog:read'],
4319
+ routes: {
4320
+ [`/v1/products/${PRODUCT_ID}/queue`]: { data: [entry(HEAD_ITEM, 'active')] },
4321
+ [`/v1/backlog/${HEAD_ITEM}`]: () => {
4322
+ throw new Error('a bug, not the API');
4323
+ },
4324
+ },
4325
+ });
4326
+
4327
+ const result = await client.callTool({ name: 'taiga_get_queue', arguments: { productId: PRODUCT_ID } });
4328
+
4329
+ expect(result.isError).toBe(true);
4330
+ expect((result.content as { text: string }[])[0]!.text).toContain('a bug, not the API');
4331
+ });
4332
+
4333
+ it('should refuse an argument a tool does not take, before any request', async () => {
4334
+ const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
4335
+ const before = fetchSpy.mock.calls.length;
4336
+
4337
+ const result = await client.callTool({
4338
+ name: 'taiga_approve_plan',
4339
+ arguments: { planId: '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60', comment: 'Looks right' },
4340
+ });
4341
+
4342
+ expect(result.isError).toBe(true);
4343
+ expect((result.content as { text: string }[])[0]!.text).toMatch(/Unrecognized key.*comment/s);
4344
+ expect(fetchSpy.mock.calls.length).toBe(before);
4345
+ });
4346
+ });
4347
+
4348
+ describe('document budgets', () => {
4349
+ const product = { id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_ID, settings: {} };
4350
+ const section = (content: string) => ({ source: 'derived', content });
4351
+ const bigSpec = {
4352
+ id: 'doc-spec',
4353
+ type: 'specification',
4354
+ version: 4,
4355
+ isDraft: false,
4356
+ origin: 'plan',
4357
+ data: {
4358
+ sections: {
4359
+ overview: section('o'.repeat(5_000)),
4360
+ features: section('f'.repeat(5_000)),
4361
+ flows: section('l'.repeat(5_000)),
4362
+ risks: section('r'.repeat(1_000)),
4363
+ },
4364
+ },
4365
+ };
4366
+ const routes = (spec: unknown) => ({
4367
+ [`GET /v1/products/${PRODUCT_ID}`]: { data: product },
4368
+ [`GET /v1/products/${PRODUCT_ID}/documents/specification?origin=plan`]: { data: spec },
4369
+ });
4370
+
4371
+ it('should carry the whole sections that fit the budget, skipping one that does not, name the rest and say how large it is', async () => {
4372
+ const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(bigSpec) });
4373
+
4374
+ const raw = await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } });
4375
+ const bundle = parseToolResult(raw);
4376
+
4377
+ // The size it reports is the size of the text it hands over, the field included.
4378
+ expect(bundle.characters).toBe((raw.content as { text: string }[])[0]!.text.length);
4379
+ const spec = bundle.documents.specification;
4380
+ // flows would pass the budget; risks, after it, still fits.
4381
+ expect(Object.keys(spec.data.sections)).toEqual(['overview', 'features', 'risks']);
4382
+ expect(spec.data.sections.overview).toEqual(section('o'.repeat(5_000)));
4383
+ expect(spec.omittedSections).toEqual([{ id: 'flows', characters: 5_000 }]);
4384
+ expect(bundle.documents.notes).toEqual(expect.arrayContaining([expect.stringContaining('omittedSections')]));
4385
+ expect(bundle.characters).toBeGreaterThan(10_000);
4386
+ expect(bundle.characters).toBeLessThan(20_000);
4387
+ });
4388
+
4389
+ it('should never cut a section: one past the budget alone is named, and fullDocuments carries everything', async () => {
4390
+ // The order a jsonb column hands back: shorter ids first, whatever order they were written in.
4391
+ const huge = {
4392
+ ...bigSpec,
4393
+ data: { sections: { tail: section('t'), overview: section('o'.repeat(30_000)) } },
4394
+ };
4395
+ const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(huge) });
4396
+
4397
+ const cut = parseToolResult(
4398
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
4399
+ ).documents.specification;
4400
+ const full = parseToolResult(
4401
+ await client.callTool({
4402
+ name: 'taiga_get_product_context',
4403
+ arguments: { productId: PRODUCT_ID, fullDocuments: true },
4404
+ })
4405
+ ).documents.specification;
4406
+
4407
+ expect(cut.data.sections).toEqual({ tail: section('t') });
4408
+ expect(cut.omittedSections).toEqual([{ id: 'overview', characters: 30_000 }]);
4409
+ expect(full.data.sections.overview.content).toHaveLength(30_000);
4410
+ expect(full).not.toHaveProperty('omittedSections');
4411
+ });
4412
+
4413
+ it("should leave each section's citations out of the bundle and say so, while fullDocuments keeps them", async () => {
4414
+ const cited = {
4415
+ ...bigSpec,
4416
+ data: { sections: { overview: { ...section('text'), citations: [{ path: 'src/a.rs', lines: '1-9' }] } } },
4417
+ };
4418
+ const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(cited) });
4419
+
4420
+ const lean = parseToolResult(
4421
+ await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
4422
+ ).documents;
4423
+ const full = parseToolResult(
4424
+ await client.callTool({
4425
+ name: 'taiga_get_product_context',
4426
+ arguments: { productId: PRODUCT_ID, fullDocuments: true },
4427
+ })
4428
+ ).documents;
4429
+
4430
+ expect(lean.specification.data.sections.overview).toEqual(section('text'));
4431
+ expect(lean.notes).toEqual(expect.arrayContaining([expect.stringContaining('citations')]));
4432
+ expect(full.specification.data.sections.overview.citations).toHaveLength(1);
4433
+ });
4434
+
4435
+ it('should read only the named sections of a product document and list the rest', async () => {
4436
+ const { client } = await bootMcpPair({ capabilities: ['products:read'], routes: routes(bigSpec) });
4437
+
4438
+ const doc = parseToolResult(
4439
+ await client.callTool({
4440
+ name: 'taiga_get_product_document',
4441
+ arguments: { productId: PRODUCT_ID, type: 'specification', origin: 'plan', sections: ['risks', 'flows'] },
4442
+ })
4443
+ );
4444
+ const miss = await client.callTool({
4445
+ name: 'taiga_get_product_document',
4446
+ arguments: { productId: PRODUCT_ID, type: 'specification', origin: 'plan', sections: ['nope'] },
4447
+ });
4448
+
4449
+ // The document's own order, whatever order they were asked in.
4450
+ expect(Object.keys(doc.data.sections)).toEqual(['flows', 'risks']);
4451
+ expect(doc.omittedSections.map((s: { id: string }) => s.id)).toEqual(['overview', 'features']);
4452
+ expect(miss.isError).toBe(true);
4453
+ expect(parseToolResult(miss).message).toBe(
4454
+ 'The plan specification has no section nope; its sections are overview, features, flows, risks.'
4455
+ );
4456
+ });
4457
+ });
4458
+
3341
4459
  describe('error handling', () => {
3342
4460
  it('should surface API errors as structured MCP error content', async () => {
3343
4461
  // Introspect succeeds, but all other endpoints fail