@vellumai/assistant 0.11.2-dev.202608050137.0c8f50a → 0.11.2-dev.202608050507.7e832db

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608050137.0c8f50a",
3
+ "version": "0.11.2-dev.202608050507.7e832db",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1410,9 +1410,11 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1410
1410
  expect(sent[2].content[0].type).toBe("text");
1411
1411
  });
1412
1412
 
1413
- test("orphaned server_tool_use at end of messages gets synthetic result (no synthetic user append)", async () => {
1414
- // Orphaned server_tool_use at the end should get a synthetic
1415
- // web_search_tool_result but no synthetic user message.
1413
+ test("unanswered server_tool_use in the final assistant message is left intact for deferred execution", async () => {
1414
+ // When the final assistant message ends with an unanswered
1415
+ // server_tool_use, the API executes the search on this request (the
1416
+ // pause_turn / deferred-execution contract). The tail must go back
1417
+ // verbatim with no synthetic result, or the search is cancelled.
1416
1418
  const messages: Message[] = [
1417
1419
  userMsg("Search something"),
1418
1420
  {
@@ -1434,12 +1436,11 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1434
1436
  content: Array<{ type: string; tool_use_id?: string }>;
1435
1437
  }>;
1436
1438
 
1437
- // Original 2 messages, with synthetic result injected in assistant message
1439
+ // Original 2 messages, assistant tail verbatim: no synthetic result
1438
1440
  expect(sent).toHaveLength(2);
1439
1441
  expect(sent[1].role).toBe("assistant");
1442
+ expect(sent[1].content).toHaveLength(1);
1440
1443
  expect(sent[1].content[0].type).toBe("server_tool_use");
1441
- expect(sent[1].content[1].type).toBe("web_search_tool_result");
1442
- expect(sent[1].content[1].tool_use_id).toBe("srvtoolu_end");
1443
1444
  });
1444
1445
 
1445
1446
  test("server_tool_use with matching web_search_tool_result passes through unchanged", async () => {
@@ -1568,7 +1569,11 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1568
1569
  expect(webSearchResults[0].tool_use_id).toBe("srvtoolu_search");
1569
1570
  });
1570
1571
 
1571
- test("mixed tool_use and server_tool_use — only client-side tool_use gets pairing, server tools pass through", async () => {
1572
+ test("mixed tool_use and deferred server_tool_use tail goes out verbatim", async () => {
1573
+ // The mixed parallel group is the deferred-execution trigger: the API
1574
+ // returns stop_reason tool_use without running the search, and runs it on
1575
+ // the next request when the client tool results come back with the
1576
+ // assistant message unchanged. No split, no synthetic result.
1572
1577
  const messages: Message[] = [
1573
1578
  userMsg("Do things"),
1574
1579
  {
@@ -1583,7 +1588,7 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1583
1588
  },
1584
1589
  ],
1585
1590
  },
1586
- // Only tu_a has a result — server_tool_use doesn't need one in the user message
1591
+ // Only tu_a has a result; the deferred search has none yet
1587
1592
  toolResultMsg("tu_a", "result A"),
1588
1593
  ];
1589
1594
  await provider.sendMessage(messages);
@@ -1597,27 +1602,139 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1597
1602
  }>;
1598
1603
  }>;
1599
1604
 
1600
- // Assistant message should have tool_use in paired portion, server_tool_use in carryover
1601
- // ensureToolPairing splits: paired = [tool_use(tu_a)], carryover = [server_tool_use(srvtoolu_b)]
1602
- // Result: assistant(tool_use) → user(tool_result) → assistant(server_tool_use) → user(synthetic_continuation)
1605
+ // assistant(tool_use, server_tool_use) → user(tool_result), verbatim
1606
+ expect(sent).toHaveLength(3);
1603
1607
  const assistantMsg = sent[1];
1604
1608
  expect(assistantMsg.role).toBe("assistant");
1605
- expect(assistantMsg.content[0].type).toBe("tool_use");
1609
+ expect(assistantMsg.content.map((b) => b.type)).toEqual([
1610
+ "tool_use",
1611
+ "server_tool_use",
1612
+ ]);
1606
1613
 
1607
1614
  const userAfterAssistant = sent[2];
1608
1615
  expect(userAfterAssistant.role).toBe("user");
1609
- // Only tool_result for tu_a — no synthetic web_search_tool_result
1610
1616
  expect(userAfterAssistant.content[0]).toMatchObject({
1611
1617
  type: "tool_result",
1612
1618
  tool_use_id: "tu_a",
1613
1619
  });
1614
1620
 
1615
- // server_tool_use preserved in a carryover assistant message with synthetic result
1616
- const carryoverAssistant = sent[3];
1617
- expect(carryoverAssistant.role).toBe("assistant");
1618
- expect(carryoverAssistant.content[0].type).toBe("server_tool_use");
1619
- expect(carryoverAssistant.content[1].type).toBe("web_search_tool_result");
1620
- expect(carryoverAssistant.content[1].tool_use_id).toBe("srvtoolu_b");
1621
+ // No synthetic web_search_tool_result anywhere; the API runs the search
1622
+ const allBlocks = sent.flatMap((m) => m.content);
1623
+ expect(
1624
+ allBlocks.filter((b) => b.type === "web_search_tool_result"),
1625
+ ).toHaveLength(0);
1626
+ });
1627
+
1628
+ test("deferred mixed heartbeat shape with text and multiple searches goes out verbatim", async () => {
1629
+ const messages: Message[] = [
1630
+ userMsg("Heartbeat: check the file and the news"),
1631
+ {
1632
+ role: "assistant",
1633
+ content: [
1634
+ { type: "text", text: "Checking both." },
1635
+ { type: "tool_use", id: "tu_read", name: "file_read", input: {} },
1636
+ {
1637
+ type: "server_tool_use",
1638
+ id: "srvtoolu_s1",
1639
+ name: "web_search",
1640
+ input: { query: "news one" },
1641
+ },
1642
+ {
1643
+ type: "server_tool_use",
1644
+ id: "srvtoolu_s2",
1645
+ name: "web_search",
1646
+ input: { query: "news two" },
1647
+ },
1648
+ ],
1649
+ },
1650
+ toolResultMsg("tu_read", "file contents"),
1651
+ ];
1652
+ await provider.sendMessage(messages);
1653
+
1654
+ const sent = lastStreamParams!.messages as Array<{
1655
+ role: string;
1656
+ content: Array<{ type: string; id?: string; tool_use_id?: string }>;
1657
+ }>;
1658
+
1659
+ expect(sent).toHaveLength(3);
1660
+ expect(sent[1].content.map((b) => b.type)).toEqual([
1661
+ "text",
1662
+ "tool_use",
1663
+ "server_tool_use",
1664
+ "server_tool_use",
1665
+ ]);
1666
+ const allBlocks = sent.flatMap((m) => m.content);
1667
+ expect(
1668
+ allBlocks.filter((b) => b.type === "web_search_tool_result"),
1669
+ ).toHaveLength(0);
1670
+ });
1671
+
1672
+ test("cross-message server tool pair from a completed deferred execution is preserved verbatim", async () => {
1673
+ // After a deferred search executes, history carries the split pair: the
1674
+ // server_tool_use in one assistant message and its web_search_tool_result
1675
+ // heading the next assistant message. Both sides must pass through
1676
+ // untouched: no synthetic result on the use, no text downgrade on the
1677
+ // result.
1678
+ const messages: Message[] = [
1679
+ userMsg("Check the file and the news"),
1680
+ {
1681
+ role: "assistant",
1682
+ content: [
1683
+ { type: "text", text: "On it." },
1684
+ { type: "tool_use", id: "tu_a", name: "file_read", input: {} },
1685
+ {
1686
+ type: "server_tool_use",
1687
+ id: "srvtoolu_deferred",
1688
+ name: "web_search",
1689
+ input: { query: "news" },
1690
+ },
1691
+ ],
1692
+ },
1693
+ toolResultMsg("tu_a", "file contents"),
1694
+ {
1695
+ role: "assistant",
1696
+ content: [
1697
+ {
1698
+ type: "web_search_tool_result",
1699
+ tool_use_id: "srvtoolu_deferred",
1700
+ content: [
1701
+ {
1702
+ type: "web_search_result",
1703
+ url: "https://example.com",
1704
+ title: "Example",
1705
+ encrypted_content: "enc_deferred",
1706
+ },
1707
+ ],
1708
+ },
1709
+ { type: "text", text: "Here is the news." },
1710
+ ],
1711
+ },
1712
+ userMsg("thanks, anything else?"),
1713
+ ];
1714
+ await provider.sendMessage(messages);
1715
+
1716
+ const sent = lastStreamParams!.messages as Array<{
1717
+ role: string;
1718
+ content: Array<{ type: string; id?: string; tool_use_id?: string }>;
1719
+ }>;
1720
+
1721
+ expect(sent).toHaveLength(5);
1722
+ expect(sent[1].content.map((b) => b.type)).toEqual([
1723
+ "text",
1724
+ "tool_use",
1725
+ "server_tool_use",
1726
+ ]);
1727
+ expect(sent[3].content.map((b) => b.type)).toEqual([
1728
+ "web_search_tool_result",
1729
+ "text",
1730
+ ]);
1731
+ // Exactly the original result block, still a result block
1732
+ const allBlocks = sent.flatMap((m) => m.content);
1733
+ const results = allBlocks.filter(
1734
+ (b) => b.type === "web_search_tool_result",
1735
+ );
1736
+ expect(results).toHaveLength(1);
1737
+ expect(results[0].tool_use_id).toBe("srvtoolu_deferred");
1621
1738
  });
1622
1739
 
1623
1740
  test("orphaned server_tool_use from interrupted stream gets repaired in multi-turn conversation", async () => {
@@ -572,8 +572,10 @@ describe("repairHistory", () => {
572
572
  ).toBe(true);
573
573
  });
574
574
 
575
- test("trailing server_tool_use gets synthetic result in same assistant message", () => {
576
- // No trailing user message needed — result goes in the assistant message
575
+ test("trailing server_tool_use is preserved as a deferred execution", () => {
576
+ // An unanswered server_tool_use at the tail is a deferred search the
577
+ // provider executes on the next request; repairing it would cancel the
578
+ // search.
577
579
  const messages: Message[] = [
578
580
  { role: "user", content: [{ type: "text", text: "Go" }] },
579
581
  {
@@ -591,21 +593,107 @@ describe("repairHistory", () => {
591
593
 
592
594
  const { messages: repaired, stats } = repairHistory(messages);
593
595
 
594
- expect(stats.missingToolResultsInserted).toBe(1);
595
- // Result is in the assistant message
596
+ expect(stats.missingToolResultsInserted).toBe(0);
596
597
  expect(repaired).toHaveLength(2);
597
598
  expect(repaired[1].role).toBe("assistant");
598
- expect(repaired[1].content).toHaveLength(2);
599
- expect(repaired[1].content[1]).toMatchObject({
600
- type: "web_search_tool_result",
601
- tool_use_id: "stu_1",
602
- content: {
603
- type: "web_search_tool_result_error",
604
- error_code: "unavailable",
605
- },
599
+ expect(repaired[1].content).toHaveLength(1);
600
+ expect(repaired[1].content[0]).toMatchObject({
601
+ type: "server_tool_use",
602
+ id: "stu_1",
606
603
  });
607
604
  });
608
605
 
606
+ test("deferred mixed tail (tool_use + server_tool_use, then tool_result) survives a reload untouched", () => {
607
+ // Persisted mid-deferral shape: the model called a client tool and a
608
+ // search in one parallel group, the client result came back, and the
609
+ // daemon reloaded before the next model call ran the search.
610
+ const messages: Message[] = [
611
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
612
+ {
613
+ role: "assistant",
614
+ content: [
615
+ { type: "tool_use", id: "tu_a", name: "file_read", input: {} },
616
+ {
617
+ type: "server_tool_use",
618
+ id: "stu_deferred",
619
+ name: "web_search",
620
+ input: { query: "news" },
621
+ },
622
+ ],
623
+ },
624
+ {
625
+ role: "user",
626
+ content: [
627
+ { type: "tool_result", tool_use_id: "tu_a", content: "file" },
628
+ ],
629
+ },
630
+ ];
631
+
632
+ const { messages: repaired, stats } = repairHistory(messages);
633
+
634
+ expect(stats.missingToolResultsInserted).toBe(0);
635
+ expect(stats.orphanToolResultsDowngraded).toBe(0);
636
+ expect(repaired).toHaveLength(3);
637
+ expect(repaired[1].content.map((b) => b.type)).toEqual([
638
+ "tool_use",
639
+ "server_tool_use",
640
+ ]);
641
+ });
642
+
643
+ test("cross-message server tool pair from a completed deferred execution survives a reload untouched", () => {
644
+ const messages: Message[] = [
645
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
646
+ {
647
+ role: "assistant",
648
+ content: [
649
+ { type: "tool_use", id: "tu_a", name: "file_read", input: {} },
650
+ {
651
+ type: "server_tool_use",
652
+ id: "stu_split",
653
+ name: "web_search",
654
+ input: { query: "news" },
655
+ },
656
+ ],
657
+ },
658
+ {
659
+ role: "user",
660
+ content: [
661
+ { type: "tool_result", tool_use_id: "tu_a", content: "file" },
662
+ ],
663
+ },
664
+ {
665
+ role: "assistant",
666
+ content: [
667
+ {
668
+ type: "web_search_tool_result",
669
+ tool_use_id: "stu_split",
670
+ content: [
671
+ {
672
+ type: "web_search_result",
673
+ url: "https://example.com",
674
+ title: "Example",
675
+ },
676
+ ],
677
+ },
678
+ { type: "text", text: "Found it." },
679
+ ],
680
+ },
681
+ ];
682
+
683
+ const { messages: repaired, stats } = repairHistory(messages);
684
+
685
+ expect(stats.missingToolResultsInserted).toBe(0);
686
+ expect(stats.orphanToolResultsDowngraded).toBe(0);
687
+ expect(repaired[1].content.map((b) => b.type)).toEqual([
688
+ "tool_use",
689
+ "server_tool_use",
690
+ ]);
691
+ expect(repaired[3].content.map((b) => b.type)).toEqual([
692
+ "web_search_tool_result",
693
+ "text",
694
+ ]);
695
+ });
696
+
609
697
  test("synthetic web_search_tool_result is placed immediately after its server_tool_use, not at end", () => {
610
698
  // Regression: synthetic results appended to the end of the content array
611
699
  // get separated from their server_tool_use by ensureToolPairing's split
@@ -648,6 +736,12 @@ describe("repairHistory", () => {
648
736
  },
649
737
  ],
650
738
  },
739
+ // A later assistant message closes the deferral window: the searches
740
+ // never executed, so they are genuine orphans.
741
+ {
742
+ role: "assistant",
743
+ content: [{ type: "text", text: "Skill ready." }],
744
+ },
651
745
  ];
652
746
 
653
747
  const { messages: repaired, stats } = repairHistory(messages);
@@ -866,6 +960,12 @@ describe("repairHistory", () => {
866
960
  },
867
961
  ],
868
962
  },
963
+ // A later exchange closes the deferral window for stu_missing_result.
964
+ { role: "user", content: [{ type: "text", text: "and?" }] },
965
+ {
966
+ role: "assistant",
967
+ content: [{ type: "text", text: "done" }],
968
+ },
869
969
  ];
870
970
 
871
971
  const { messages: repaired, stats } = repairHistory(messages);
@@ -23,7 +23,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
23
23
  );
24
24
  });
25
25
 
26
- test("google provider row contains bearer injection templates for 4 Google API hosts", () => {
26
+ test("google provider row contains bearer injection templates for 6 Google API hosts", () => {
27
27
  const provider = getProvider("google");
28
28
 
29
29
  expect(provider).toBeDefined();
@@ -36,7 +36,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
36
36
  valuePrefix: string;
37
37
  }>;
38
38
 
39
- expect(templates).toHaveLength(4);
39
+ expect(templates).toHaveLength(6);
40
40
 
41
41
  const byHost = new Map(templates.map((t) => [t.hostPattern, t]));
42
42
 
@@ -45,6 +45,8 @@ describe("oauth provider profiles (DB-seeded)", () => {
45
45
  "www.googleapis.com",
46
46
  "people.googleapis.com",
47
47
  "docs.googleapis.com",
48
+ "tasks.googleapis.com",
49
+ "calendar.googleapis.com",
48
50
  ]) {
49
51
  const tpl = byHost.get(host);
50
52
  expect(tpl).toBeDefined();
@@ -204,6 +204,7 @@ describe("choice and copy_block surface proxying", () => {
204
204
  providerKey: "google",
205
205
  displayName: "Google",
206
206
  description: "Connect Gmail for this task.",
207
+ requestedScopes: ["gmail.readonly"],
207
208
  });
208
209
  expect(ctx.pendingSurfaceActions.get(showMessage.surfaceId)).toEqual({
209
210
  surfaceType: "oauth_connect",
@@ -328,4 +328,100 @@ describe("stripHistoricalWebSearchResults", () => {
328
328
  const afterTypes = messages[0].content.map((b) => b.type);
329
329
  expect(afterTypes).toEqual(beforeTypes);
330
330
  });
331
+
332
+ test("drops the server_tool_use of a cross-message pair from a deferred execution", () => {
333
+ // Deferred execution places the web_search_tool_result at the head of the
334
+ // assistant message after the client tool round-trip. Both sides of the
335
+ // pair must go together, or the leftover server_tool_use reads as an
336
+ // orphan and gets a synthetic error result downstream.
337
+ const messages: Message[] = [
338
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
339
+ {
340
+ role: "assistant",
341
+ content: [
342
+ { type: "text", text: "On it" },
343
+ { type: "tool_use", id: "tu_read", name: "file_read", input: {} },
344
+ {
345
+ type: "server_tool_use",
346
+ id: "stu_deferred",
347
+ name: "web_search",
348
+ input: { query: "deferred query" },
349
+ },
350
+ ],
351
+ },
352
+ {
353
+ role: "user",
354
+ content: [
355
+ { type: "tool_result", tool_use_id: "tu_read", content: "file" },
356
+ ],
357
+ },
358
+ {
359
+ role: "assistant",
360
+ content: [
361
+ {
362
+ type: "web_search_tool_result",
363
+ tool_use_id: "stu_deferred",
364
+ content: [
365
+ {
366
+ type: "web_search_result",
367
+ url: "https://example.com",
368
+ title: "Deferred",
369
+ encrypted_content: "tok",
370
+ },
371
+ ],
372
+ },
373
+ { type: "text", text: "Found it." },
374
+ ],
375
+ },
376
+ ];
377
+
378
+ const { messages: result, stats } =
379
+ stripHistoricalWebSearchResults(messages);
380
+
381
+ // The use in the earlier assistant message is dropped with its result
382
+ expect(result[1].content.map((b) => b.type)).toEqual(["text", "tool_use"]);
383
+ const summarized = result[3].content[0];
384
+ expect(summarized.type).toBe("text");
385
+ expect((summarized as { text: string }).text).toContain("deferred query");
386
+ expect((summarized as { text: string }).text).toContain(
387
+ "https://example.com",
388
+ );
389
+ expect(stats.serverToolUsesDropped).toBe(1);
390
+ expect(stats.blocksStripped).toBe(1);
391
+ expect(stats.messagesModified).toBe(2);
392
+ });
393
+
394
+ test("leaves a pending server_tool_use with no result anywhere untouched", () => {
395
+ // A resultless use at the tail is a deferred search the provider executes
396
+ // on this request; stripping it would cancel the search.
397
+ const messages: Message[] = [
398
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
399
+ {
400
+ role: "assistant",
401
+ content: [
402
+ { type: "tool_use", id: "tu_read", name: "file_read", input: {} },
403
+ {
404
+ type: "server_tool_use",
405
+ id: "stu_pending",
406
+ name: "web_search",
407
+ input: { query: "pending" },
408
+ },
409
+ ],
410
+ },
411
+ {
412
+ role: "user",
413
+ content: [
414
+ { type: "tool_result", tool_use_id: "tu_read", content: "file" },
415
+ ],
416
+ },
417
+ ];
418
+
419
+ const { messages: result, stats } =
420
+ stripHistoricalWebSearchResults(messages);
421
+
422
+ expect(result).toEqual(messages);
423
+ expect(stats.serverToolUsesDropped).toBe(0);
424
+ expect(stats.blocksStripped).toBe(0);
425
+ expect(stats.messagesModified).toBe(0);
426
+ });
331
427
  });
@@ -15,6 +15,7 @@
15
15
  * recover — so detection and repair of ordering drift live together as one unit.
16
16
  */
17
17
 
18
+ import { analyzeServerToolPairing } from "../../providers/server-tool-pairing.js";
18
19
  import type {
19
20
  ContentBlock,
20
21
  Message,
@@ -69,6 +70,10 @@ export function repairHistory(messages: Message[]): RepairResult {
69
70
  let pendingToolUseIds = new Set<string>();
70
71
  // tool_result blocks stripped from assistant messages, keyed by tool_use_id
71
72
  let recoveredResults = new Map<string, ToolResultContent>();
73
+ // Server-tool pairs can legitimately span messages (deferred execution) or
74
+ // sit unanswered at the tail (the provider runs the search on the next
75
+ // request), so orphanhood is judged against the whole list, not per message.
76
+ const serverToolPairing = analyzeServerToolPairing(messages);
72
77
 
73
78
  for (const [msgIndex, msg] of messages.entries()) {
74
79
  if (msg.role === "assistant") {
@@ -100,36 +105,32 @@ export function repairHistory(messages: Message[]): RepairResult {
100
105
  }
101
106
  }
102
107
 
103
- // Pair server-side tool blocks within the same assistant message.
104
- // Server tools (e.g. web_search) emit server_tool_use + matching
105
- // web_search_tool_result. Either side can go missing — the synthetic
106
- // result is inserted IMMEDIATELY AFTER the orphan server_tool_use (not
108
+ // Repair orphaned server-side tool blocks. Server tools (e.g.
109
+ // web_search) emit server_tool_use + matching web_search_tool_result;
110
+ // orphanhood comes from `serverToolPairing`, which resolves pairs
111
+ // across messages and exempts the deferred tail. The synthetic result
112
+ // is inserted IMMEDIATELY AFTER the orphan server_tool_use (not
107
113
  // appended to the end) so ensureToolPairing's split at tool_use
108
114
  // boundaries cannot separate the pair. An orphan
109
- // web_search_tool_result (no preceding server_tool_use) is downgraded
110
- // to text — Anthropic rejects the request otherwise.
111
- const serverToolIds = new Set(
112
- cleanedContent
113
- .filter(
114
- (b): b is ServerToolUseContent => b.type === "server_tool_use",
115
- )
116
- .map((b) => b.id),
117
- );
118
- const matchedServerIds = new Set(
119
- cleanedContent
120
- .filter((b) => b.type === "web_search_tool_result")
121
- .map((b) => (b as { tool_use_id: string }).tool_use_id),
122
- );
115
+ // web_search_tool_result with no use anywhere is downgraded to text.
116
+ // Anthropic rejects the request otherwise.
123
117
  const orphanedServerIds = new Set<string>();
124
- for (const id of serverToolIds) {
125
- if (!matchedServerIds.has(id)) {
126
- orphanedServerIds.add(id);
127
- }
128
- }
129
118
  const orphanedWebSearchResultIds = new Set<string>();
130
- for (const id of matchedServerIds) {
131
- if (!serverToolIds.has(id)) {
132
- orphanedWebSearchResultIds.add(id);
119
+ for (const b of cleanedContent) {
120
+ if (b.type === "server_tool_use") {
121
+ const id = (b as ServerToolUseContent).id;
122
+ if (
123
+ !serverToolPairing.resolvedPairIds.has(id) &&
124
+ !serverToolPairing.deferredUseIds.has(id)
125
+ ) {
126
+ orphanedServerIds.add(id);
127
+ }
128
+ }
129
+ if (b.type === "web_search_tool_result") {
130
+ const id = (b as { tool_use_id: string }).tool_use_id;
131
+ if (!serverToolPairing.resolvedPairIds.has(id)) {
132
+ orphanedWebSearchResultIds.add(id);
133
+ }
133
134
  }
134
135
  }
135
136
 
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ type OAuthConnectSurfaceData,
5
+ OAuthConnectSurfaceDataSchema,
6
+ } from "./surfaces.js";
7
+
8
+ describe("OAuthConnectSurfaceDataSchema requestedScopes", () => {
9
+ test("parses a valid scopes array and preserves order", () => {
10
+ const parsed = OAuthConnectSurfaceDataSchema.parse({
11
+ providerKey: "google",
12
+ requestedScopes: [
13
+ "https://www.googleapis.com/auth/tasks",
14
+ "https://www.googleapis.com/auth/calendar",
15
+ "openid",
16
+ ],
17
+ });
18
+ expect(parsed.requestedScopes).toEqual([
19
+ "https://www.googleapis.com/auth/tasks",
20
+ "https://www.googleapis.com/auth/calendar",
21
+ "openid",
22
+ ]);
23
+ });
24
+
25
+ test("absent requestedScopes parses to undefined", () => {
26
+ const parsed = OAuthConnectSurfaceDataSchema.parse({
27
+ providerKey: "google",
28
+ });
29
+ expect(parsed.requestedScopes).toBeUndefined();
30
+ });
31
+
32
+ test("an empty array collapses to undefined", () => {
33
+ const parsed = OAuthConnectSurfaceDataSchema.parse({
34
+ providerKey: "google",
35
+ requestedScopes: [],
36
+ });
37
+ expect(parsed.requestedScopes).toBeUndefined();
38
+ });
39
+
40
+ test("non-array values collapse to undefined instead of rejecting", () => {
41
+ for (const value of [
42
+ "https://www.googleapis.com/auth/tasks, openid",
43
+ 42,
44
+ true,
45
+ { scope: "openid" },
46
+ null,
47
+ ]) {
48
+ const parsed = OAuthConnectSurfaceDataSchema.parse({
49
+ providerKey: "google",
50
+ requestedScopes: value,
51
+ });
52
+ expect(parsed.requestedScopes).toBeUndefined();
53
+ }
54
+ });
55
+
56
+ test("an array of blanks collapses to undefined", () => {
57
+ const parsed = OAuthConnectSurfaceDataSchema.parse({
58
+ providerKey: "google",
59
+ requestedScopes: ["", " ", "\t"],
60
+ });
61
+ expect(parsed.requestedScopes).toBeUndefined();
62
+ });
63
+
64
+ test("entries are trimmed and blank or non-string junk is dropped", () => {
65
+ const parsed = OAuthConnectSurfaceDataSchema.parse({
66
+ providerKey: "google",
67
+ requestedScopes: [
68
+ " https://www.googleapis.com/auth/tasks ",
69
+ "",
70
+ null,
71
+ { nope: true },
72
+ "openid",
73
+ ],
74
+ });
75
+ expect(parsed.requestedScopes).toEqual([
76
+ "https://www.googleapis.com/auth/tasks",
77
+ "openid",
78
+ ]);
79
+ });
80
+
81
+ test("a bare string is not split on commas (scopes are opaque URIs)", () => {
82
+ const parsed = OAuthConnectSurfaceDataSchema.parse({
83
+ providerKey: "google",
84
+ requestedScopes: "a,b,c",
85
+ });
86
+ expect(parsed.requestedScopes).toBeUndefined();
87
+ });
88
+
89
+ test("legacy payload { providerKey: 'google' } still parses (skew guard)", () => {
90
+ const parsed: OAuthConnectSurfaceData = OAuthConnectSurfaceDataSchema.parse(
91
+ { providerKey: "google" },
92
+ );
93
+ expect(parsed.providerKey).toBe("google");
94
+ expect(parsed.requestedScopes).toBeUndefined();
95
+ });
96
+ });