@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 +1 -1
- package/src/__tests__/anthropic-provider.test.ts +136 -19
- package/src/__tests__/history-repair.test.ts +112 -12
- package/src/__tests__/oauth-provider-profiles.test.ts +4 -2
- package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -0
- package/src/__tests__/web-search-history.test.ts +96 -0
- package/src/agent/history-repair/history-repair.ts +27 -26
- package/src/api/surfaces-oauth-connect.test.ts +96 -0
- package/src/api/surfaces.ts +36 -8
- package/src/cli/commands/oauth/connect-surface-guidance.test.ts +16 -0
- package/src/cli/commands/oauth/connect-surface-guidance.ts +25 -7
- package/src/cli/commands/oauth/connect.test.ts +26 -1
- package/src/cli/commands/oauth/connect.ts +4 -1
- package/src/cli/commands/oauth/index.help.ts +3 -1
- package/src/daemon/web-search-history.ts +47 -33
- package/src/oauth/seed-providers.ts +12 -0
- package/src/providers/anthropic/client.ts +69 -54
- package/src/providers/server-tool-pairing.ts +134 -0
- package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
package/package.json
CHANGED
|
@@ -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("
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1601
|
-
|
|
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
|
|
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
|
-
//
|
|
1616
|
-
const
|
|
1617
|
-
expect(
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
|
576
|
-
//
|
|
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(
|
|
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(
|
|
599
|
-
expect(repaired[1].content[
|
|
600
|
-
type: "
|
|
601
|
-
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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
|
|
110
|
-
//
|
|
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
|
|
131
|
-
if (
|
|
132
|
-
|
|
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
|
+
});
|