@vellumai/assistant 0.12.2-dev.202609162214.308bda0 → 0.12.2-dev.202609170044.9cbb729

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/docs/desktop-browser-cli.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  6. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  7. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  8. package/openapi.yaml +15 -0
  9. package/package.json +1 -1
  10. package/src/__tests__/agent-loop.test.ts +135 -0
  11. package/src/__tests__/anthropic-provider.test.ts +55 -0
  12. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  13. package/src/__tests__/history-repair.test.ts +53 -0
  14. package/src/__tests__/secret-scanner.test.ts +20 -0
  15. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  16. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  17. package/src/agent/loop.ts +18 -12
  18. package/src/agent/tool-result-follow-up.ts +70 -0
  19. package/src/api/events/question-request.ts +1 -0
  20. package/src/cli/commands/browser.help.ts +5 -1
  21. package/src/daemon/chat-credential-redaction.ts +16 -1
  22. package/src/desktop/desktop-automation-lease.test.ts +87 -0
  23. package/src/desktop/desktop-automation-lease.ts +51 -2
  24. package/src/desktop/desktop-help.ts +16 -0
  25. package/src/desktop/desktop-wallpaper.test.ts +12 -1
  26. package/src/permissions/question-prompter.test.ts +31 -0
  27. package/src/permissions/question-prompter.ts +2 -0
  28. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  29. package/src/providers/server-tool-pairing.ts +16 -7
  30. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  31. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  32. package/src/tools/browser/browser-execution.ts +7 -1
  33. package/src/util/browser-human-verification.ts +14 -0
@@ -24,7 +24,7 @@ The desktop client bypasses personal-browser discovery, extension reconnect wait
24
24
 
25
25
  Tab IDs are ephemeral numeric aliases for this managed browser's CDP target IDs. They are never personal extension tab IDs. Initial attachment selects an existing HTTP(S) page or opens a blank tab. Use `tabs list` and `tabs select --tab-id <id>` to choose explicitly. Navigation, tab selection, document replacement and release clear the desktop snapshot map. Personal-browser snapshots use a separate namespace.
26
26
 
27
- CDP mouse events use page viewport CSS coordinates. Before dispatching them, the client animates a purple arrow overlay to the same point. The overlay is excluded from accessibility and hit testing. It appears in the existing desktop stream and is removed on release. It does not move the OS pointer, appear in browser toolbar UI or visualize every programmatic DOM operation. Native dialogs and other applications are outside the browser CLI; users can interact with them directly in the expanded desktop. The picture-in-picture preview is view-only. Direct user interaction does not pause automation.
27
+ CDP mouse events use page viewport CSS coordinates. Before dispatching them, the client animates a purple arrow overlay to the same point. The overlay is excluded from accessibility and hit testing. It appears in the existing desktop stream and is removed on release. It does not move the OS pointer, appear in browser toolbar UI or visualize every programmatic DOM operation. Native dialogs and other applications are outside the browser CLI; users can interact with them directly in the expanded desktop. The picture-in-picture preview is view-only. Direct user interaction does not pause automation. For logins, the assistant uses saved credentials first and securely collects missing credentials with `assistant credentials prompt`, then fills the login form itself. Every CAPTCHA and bot-detection challenge, including sliders and press-and-hold checks, is handed to the user before the assistant attempts it. When human interaction is needed, the assistant calls `ask_question` with `desktopHelp: { message, doneLabel, skipLabel }` in the user's language. The tool releases held browser input and reserves the desktop before publishing a question with `presentation: "virtual_desktop"`. The live viewer mounts only in the attended Electron window, with browser focus as a fallback on older shells and the web, so conversations release it when focus changes. A busy viewer offers Reconnect if the previous connection has not finished closing. The compact card lives in the scrolling message transcript, with expandable instructions and a preview capped at 384 pixels wide. It hosts the shared live viewer, with Step In opening its full desktop modal and Done or Skip resolving the existing question response. The assistant retains an exclusive human-help reservation while waiting. Done resumes the same conversation's automation lease for its fresh snapshot; Skip, closure, timeout and cancellation release it. The browser idle timer does not expire a pending human-help reservation. The live preview connects after Show live preview or Step In, and reuses an already-open picture-in-picture viewer. Other devices receiving the card do not automatically open a stream. When the request resolves, its interactive viewer closes or returns to the read-only PiP session that was open before the request. Skip does not imply the obstacle was resolved. Older clients show the same request as a normal Done/Skip question, with model-localized labels retained in history. Channels without dynamic UI return guidance to continue in the app rather than waiting on an unusable card.
28
28
 
29
29
  The client records key and mouse presses before dispatch. On release it opens a fresh bounded cleanup connection, attaches to the same live targets, releases uncertain held input and removes overlays. A failed cleanup preserves state and the automation slot for retry. Turn-triggered cancellation retries cleanup automatically until it succeeds or ownership changes. Dispatched actions are never automatically retried. Closed targets need no input cleanup. Browser-process loss disposes the client, and later requests discover the replacement process.
30
30
 
@@ -63,6 +63,7 @@ const FIXTURES: Record<string, string> = {
63
63
  "Perplexity API Key": `pplx-${filler(40)}`,
64
64
  "Tavily API Key": `tvly-${filler(20)}`,
65
65
  "Firecrawl API Key": `fc-${filler(20)}`,
66
+ "Resend API Key": `re_${filler(8)}_${filler(24)}`,
66
67
  };
67
68
 
68
69
  describe("subpath export", () => {
@@ -230,6 +230,9 @@ export const PREFIX_PATTERNS: SecretPrefixPattern[] = [
230
230
 
231
231
  // -- Firecrawl --
232
232
  { label: "Firecrawl API Key", regex: /fc-[A-Za-z0-9]{20,}/ },
233
+ // Resend documents only the `re_` prefix; the segment shape is the observed
234
+ // key format. Strict on purpose: bare `re_` prefixes ordinary identifiers.
235
+ { label: "Resend API Key", regex: /re_[A-Za-z0-9]{8}_[A-Za-z0-9]{24,}/ },
233
236
  ];
234
237
 
235
238
  /**
@@ -362,7 +365,7 @@ export function isPlaceholderValue(value: string): boolean {
362
365
  // Strip known prefixes to isolate the variable part
363
366
  const variablePart = value
364
367
  .replace(
365
- /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
368
+ /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|re_|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
366
369
  "",
367
370
  )
368
371
  .replace(/[^A-Za-z0-9]/g, "");
@@ -63,6 +63,7 @@ const FIXTURES: Record<string, string> = {
63
63
  "Perplexity API Key": `pplx-${filler(40)}`,
64
64
  "Tavily API Key": `tvly-${filler(20)}`,
65
65
  "Firecrawl API Key": `fc-${filler(20)}`,
66
+ "Resend API Key": `re_${filler(8)}_${filler(24)}`,
66
67
  };
67
68
 
68
69
  describe("subpath export", () => {
@@ -230,6 +230,9 @@ export const PREFIX_PATTERNS: SecretPrefixPattern[] = [
230
230
 
231
231
  // -- Firecrawl --
232
232
  { label: "Firecrawl API Key", regex: /fc-[A-Za-z0-9]{20,}/ },
233
+ // Resend documents only the `re_` prefix; the segment shape is the observed
234
+ // key format. Strict on purpose: bare `re_` prefixes ordinary identifiers.
235
+ { label: "Resend API Key", regex: /re_[A-Za-z0-9]{8}_[A-Za-z0-9]{24,}/ },
233
236
  ];
234
237
 
235
238
  /**
@@ -362,7 +365,7 @@ export function isPlaceholderValue(value: string): boolean {
362
365
  // Strip known prefixes to isolate the variable part
363
366
  const variablePart = value
364
367
  .replace(
365
- /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
368
+ /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|re_|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
366
369
  "",
367
370
  )
368
371
  .replace(/[^A-Za-z0-9]/g, "");
@@ -63,6 +63,7 @@ const FIXTURES: Record<string, string> = {
63
63
  "Perplexity API Key": `pplx-${filler(40)}`,
64
64
  "Tavily API Key": `tvly-${filler(20)}`,
65
65
  "Firecrawl API Key": `fc-${filler(20)}`,
66
+ "Resend API Key": `re_${filler(8)}_${filler(24)}`,
66
67
  };
67
68
 
68
69
  describe("subpath export", () => {
@@ -230,6 +230,9 @@ export const PREFIX_PATTERNS: SecretPrefixPattern[] = [
230
230
 
231
231
  // -- Firecrawl --
232
232
  { label: "Firecrawl API Key", regex: /fc-[A-Za-z0-9]{20,}/ },
233
+ // Resend documents only the `re_` prefix; the segment shape is the observed
234
+ // key format. Strict on purpose: bare `re_` prefixes ordinary identifiers.
235
+ { label: "Resend API Key", regex: /re_[A-Za-z0-9]{8}_[A-Za-z0-9]{24,}/ },
233
236
  ];
234
237
 
235
238
  /**
@@ -362,7 +365,7 @@ export function isPlaceholderValue(value: string): boolean {
362
365
  // Strip known prefixes to isolate the variable part
363
366
  const variablePart = value
364
367
  .replace(
365
- /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
368
+ /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|re_|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
366
369
  "",
367
370
  )
368
371
  .replace(/[^A-Za-z0-9]/g, "");
package/openapi.yaml CHANGED
@@ -21322,6 +21322,9 @@ paths:
21322
21322
  additionalProperties: false
21323
21323
  freeTextPlaceholder:
21324
21324
  type: string
21325
+ presentation:
21326
+ type: string
21327
+ const: virtual_desktop
21325
21328
  required:
21326
21329
  - id
21327
21330
  - question
@@ -21364,6 +21367,9 @@ paths:
21364
21367
  additionalProperties: false
21365
21368
  freeTextPlaceholder:
21366
21369
  type: string
21370
+ presentation:
21371
+ type: string
21372
+ const: virtual_desktop
21367
21373
  required:
21368
21374
  - id
21369
21375
  - question
@@ -21833,6 +21839,9 @@ paths:
21833
21839
  additionalProperties: false
21834
21840
  freeTextPlaceholder:
21835
21841
  type: string
21842
+ presentation:
21843
+ type: string
21844
+ const: virtual_desktop
21836
21845
  required:
21837
21846
  - id
21838
21847
  - question
@@ -21875,6 +21884,9 @@ paths:
21875
21884
  additionalProperties: false
21876
21885
  freeTextPlaceholder:
21877
21886
  type: string
21887
+ presentation:
21888
+ type: string
21889
+ const: virtual_desktop
21878
21890
  required:
21879
21891
  - id
21880
21892
  - question
@@ -25728,6 +25740,9 @@ paths:
25728
25740
  additionalProperties: false
25729
25741
  freeTextPlaceholder:
25730
25742
  type: string
25743
+ presentation:
25744
+ type: string
25745
+ const: virtual_desktop
25731
25746
  required:
25732
25747
  - id
25733
25748
  - question
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609162214.308bda0",
3
+ "version": "0.12.2-dev.202609170044.9cbb729",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2550,6 +2550,141 @@ describe("AgentLoop", () => {
2550
2550
  expect(noRetryNudge).toBeUndefined();
2551
2551
  });
2552
2552
 
2553
+ // A native web search left deferred by the same assistant turn (a
2554
+ // server_tool_use with no result, alongside the client tool call) runs on
2555
+ // the next request only if that request's tool-result message holds
2556
+ // tool_result blocks alone. The coaching then rides inside the errored
2557
+ // tool_result instead of as a trailing text block.
2558
+ test("folds retry coaching into the tool_result when the assistant turn left a server tool deferred", async () => {
2559
+ const mixedTurn: ProviderResponse = {
2560
+ content: [
2561
+ {
2562
+ type: "tool_use",
2563
+ id: "t1",
2564
+ name: "read_file",
2565
+ input: { path: "/missing.txt" },
2566
+ },
2567
+ {
2568
+ type: "server_tool_use",
2569
+ id: "srvtoolu_1",
2570
+ name: "web_search",
2571
+ input: { query: "news" },
2572
+ },
2573
+ ],
2574
+ model: "mock-model",
2575
+ usage: { inputTokens: 10, outputTokens: 5 },
2576
+ stopReason: "tool_use",
2577
+ };
2578
+ const { provider, calls } = createMockProvider([
2579
+ mixedTurn,
2580
+ textResponse("Reported the missing file."),
2581
+ ]);
2582
+ const toolExecutor = async () => ({
2583
+ content: "Error: HTTP 404",
2584
+ isError: true,
2585
+ });
2586
+
2587
+ const loop = new AgentLoop({
2588
+ provider,
2589
+ systemPrompt: "system",
2590
+ conversationId: "test-conversation",
2591
+ tools: dummyTools,
2592
+ toolExecutor,
2593
+ });
2594
+ await loop.run({
2595
+ requestId: "test-request",
2596
+ messages: [userMessage],
2597
+ onEvent: () => {},
2598
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2599
+ });
2600
+
2601
+ expect(calls).toHaveLength(2);
2602
+ const followUpMessages = calls[1].messages;
2603
+ const followUp = followUpMessages[followUpMessages.length - 1];
2604
+ expect(followUp.role).toBe("user");
2605
+ expect(followUp.content.map((b) => b.type)).toEqual(["tool_result"]);
2606
+ const erroredToolResult = followUp.content[0] as Extract<
2607
+ ContentBlock,
2608
+ { type: "tool_result" }
2609
+ >;
2610
+ expect(erroredToolResult.is_error).toBe(true);
2611
+ expect(erroredToolResult.content).toContain("Error: HTTP 404");
2612
+ expect(erroredToolResult.content).toContain("looks recoverable");
2613
+ });
2614
+
2615
+ // A deferred search answered by a message with text after the results
2616
+ // (history assembled elsewhere) is rejected by the provider as unpaired.
2617
+ // The ordering-repair retry stamps the synthetic result rather than
2618
+ // re-sending the same history.
2619
+ test("repairs a deferred search rejected as unpaired instead of re-sending the same history", async () => {
2620
+ const mixedTurn: Message = {
2621
+ role: "assistant",
2622
+ content: [
2623
+ {
2624
+ type: "tool_use",
2625
+ id: "t1",
2626
+ name: "read_file",
2627
+ input: { path: "/a" },
2628
+ },
2629
+ {
2630
+ type: "server_tool_use",
2631
+ id: "srvtoolu_1",
2632
+ name: "web_search",
2633
+ input: { query: "news" },
2634
+ },
2635
+ ],
2636
+ };
2637
+ const answeredWithText: Message = {
2638
+ role: "user",
2639
+ content: [
2640
+ {
2641
+ type: "tool_result",
2642
+ tool_use_id: "t1",
2643
+ content: "Error: HTTP 404",
2644
+ is_error: true,
2645
+ },
2646
+ { type: "text", text: "<system_notice>retry</system_notice>" },
2647
+ ],
2648
+ };
2649
+ const { provider, calls } = createMockProvider([
2650
+ new Error(
2651
+ "Anthropic API error (400): messages.1: `web_search` tool use with id `srvtoolu_1` was found without a corresponding `web_search_tool_result` block",
2652
+ ),
2653
+ textResponse("recovered"),
2654
+ ]);
2655
+ const loop = new AgentLoop({
2656
+ provider,
2657
+ systemPrompt: "system",
2658
+ conversationId: "test-conversation",
2659
+ });
2660
+ const events: AgentEvent[] = [];
2661
+
2662
+ const { history } = await loop.run({
2663
+ requestId: "test-request",
2664
+ messages: [userMessage, mixedTurn, answeredWithText],
2665
+ onEvent: collectEvents(events),
2666
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
2667
+ });
2668
+
2669
+ expect(calls).toHaveLength(2);
2670
+ expect(events.filter((e) => e.type === "error")).toHaveLength(0);
2671
+ // Deep repair stamped the synthetic result on the orphaned search, and the
2672
+ // outbound sanitizer then summarized the answered pair as text, so the
2673
+ // retry carries no unanswered server_tool_use.
2674
+ expect(calls[1].messages[1].content.map((b) => b.type)).toEqual([
2675
+ "tool_use",
2676
+ "text",
2677
+ ]);
2678
+ expect(calls[1].messages[1].content[1]).toMatchObject({
2679
+ type: "text",
2680
+ text: expect.stringContaining("results unavailable"),
2681
+ });
2682
+ expect(history[history.length - 1]).toEqual({
2683
+ role: "assistant",
2684
+ content: [{ type: "text", text: "recovered" }],
2685
+ });
2686
+ });
2687
+
2553
2688
  // Retry coaching stops after a tool fails 3 times in a row — past that the
2554
2689
  // error is likely unrecoverable and further coaching only burns tokens.
2555
2690
  test("stops appending retry coaching after 3 consecutive failures of a tool", async () => {
@@ -1650,6 +1650,61 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
1650
1650
  ).toHaveLength(0);
1651
1651
  });
1652
1652
 
1653
+ test("mixed tail answered by a tool_result plus trailing text gets a synthetic web_search_tool_result", async () => {
1654
+ // Text after the client result closes the assistant turn on the provider
1655
+ // side, which then rejects the unanswered search as unpaired. The search
1656
+ // is an orphan here, so the synthetic error result keeps the request
1657
+ // valid at the cost of that one search.
1658
+ const messages: Message[] = [
1659
+ userMsg("Do things"),
1660
+ {
1661
+ role: "assistant",
1662
+ content: [
1663
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
1664
+ {
1665
+ type: "server_tool_use",
1666
+ id: "srvtoolu_b",
1667
+ name: "web_search",
1668
+ input: { query: "test" },
1669
+ },
1670
+ ],
1671
+ },
1672
+ {
1673
+ role: "user",
1674
+ content: [
1675
+ {
1676
+ type: "tool_result",
1677
+ tool_use_id: "tu_a",
1678
+ content: "Error: HTTP 404",
1679
+ is_error: true,
1680
+ },
1681
+ { type: "text", text: "<system_notice>retry</system_notice>" },
1682
+ ],
1683
+ },
1684
+ ];
1685
+ await provider.sendMessage(messages);
1686
+
1687
+ const sent = lastStreamParams!.messages as Array<{
1688
+ role: string;
1689
+ content: Array<{ type: string; tool_use_id?: string }>;
1690
+ }>;
1691
+
1692
+ // The repaired pair leaves the mixed message: ensureToolPairing keeps the
1693
+ // client tool_result adjacent to its tool_use and moves the now-answered
1694
+ // server pair into its own assistant turn, ahead of the trailing text.
1695
+ expect(sent.map((m) => m.content.map((b) => b.type))).toEqual([
1696
+ ["text"],
1697
+ ["tool_use"],
1698
+ ["tool_result"],
1699
+ ["server_tool_use", "web_search_tool_result"],
1700
+ ["text"],
1701
+ ]);
1702
+ expect(sent[3].content[1]).toMatchObject({
1703
+ type: "web_search_tool_result",
1704
+ tool_use_id: "srvtoolu_b",
1705
+ });
1706
+ });
1707
+
1653
1708
  test("deferred mixed heartbeat shape with text and multiple searches goes out verbatim", async () => {
1654
1709
  const messages: Message[] = [
1655
1710
  userMsg("Heartbeat: check the file and the news"),
@@ -204,6 +204,22 @@ describe("collectRevealRefsFromCommand", () => {
204
204
  );
205
205
  });
206
206
 
207
+ test("parses a legacy service/field path", () => {
208
+ expect(
209
+ collectRevealRefsFromCommand(
210
+ "assistant credentials reveal resend/api_key",
211
+ ),
212
+ ).toEqual([{ service: "resend", field: "api_key" }]);
213
+ });
214
+
215
+ test("legacy path survives malformed extra arguments", () => {
216
+ expect(
217
+ collectRevealRefsFromCommand(
218
+ "assistant credentials reveal resend/api_key --json extra",
219
+ ),
220
+ ).toEqual([{ service: "resend", field: "api_key" }]);
221
+ });
222
+
207
223
  test("unparseable invocation yields no ref (fails safe)", () => {
208
224
  expect(
209
225
  collectRevealRefsFromCommand("assistant credentials reveal --service x"),
@@ -437,6 +453,13 @@ describe("redactSecretsForChat", () => {
437
453
  expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
438
454
  });
439
455
 
456
+ test("Resend key in persisted assistant text is redacted without a candidate", () => {
457
+ const key = `re_${"a".repeat(8)}_${"b".repeat(24)}`;
458
+ const out = redactSecretsForChat(`the key is ${key} and it failed`, []);
459
+ expect(out).not.toContain(key);
460
+ expect(out).toContain("\u3014redacted:Resend API Key\u3015");
461
+ });
462
+
440
463
  test("no candidate match produces the plain sentinel — never a guess", () => {
441
464
  const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
442
465
  { service: "openai", field: "api_key", value: "different-value" },
@@ -643,6 +643,59 @@ describe("repairHistory", () => {
643
643
  ]);
644
644
  });
645
645
 
646
+ test("deferred mixed tail answered by a tool_result plus trailing text gets a synthetic result", () => {
647
+ // Text after the client result closes the assistant turn on the provider
648
+ // side, which then rejects the unanswered search as unpaired. The search
649
+ // is an orphan here, so the synthetic error result keeps the request
650
+ // valid at the cost of that one search. `deepRepairHistory` is the loop's
651
+ // recovery path for that rejection.
652
+ const messages: Message[] = [
653
+ { role: "user", content: [{ type: "text", text: "Check both" }] },
654
+ {
655
+ role: "assistant",
656
+ content: [
657
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
658
+ {
659
+ type: "server_tool_use",
660
+ id: "stu_deferred",
661
+ name: "web_search",
662
+ input: { query: "news" },
663
+ },
664
+ ],
665
+ },
666
+ {
667
+ role: "user",
668
+ content: [
669
+ {
670
+ type: "tool_result",
671
+ tool_use_id: "tu_a",
672
+ content: "Error: HTTP 404",
673
+ is_error: true,
674
+ },
675
+ { type: "text", text: "<system_notice>retry</system_notice>" },
676
+ ],
677
+ },
678
+ ];
679
+
680
+ const { messages: repaired, stats } = deepRepairHistory(messages);
681
+
682
+ expect(stats.missingToolResultsInserted).toBe(1);
683
+ expect(repaired).toHaveLength(3);
684
+ expect(repaired[1].content.map((b) => b.type)).toEqual([
685
+ "tool_use",
686
+ "server_tool_use",
687
+ "web_search_tool_result",
688
+ ]);
689
+ expect(repaired[1].content[2]).toMatchObject({
690
+ type: "web_search_tool_result",
691
+ tool_use_id: "stu_deferred",
692
+ });
693
+ expect(repaired[2].content.map((b) => b.type)).toEqual([
694
+ "tool_result",
695
+ "text",
696
+ ]);
697
+ });
698
+
646
699
  test("cross-message server tool pair from a completed deferred execution survives a reload untouched", () => {
647
700
  const messages: Message[] = [
648
701
  { role: "user", content: [{ type: "text", text: "Check both" }] },
@@ -187,6 +187,26 @@ describe("Anthropic keys", () => {
187
187
  });
188
188
  });
189
189
 
190
+ // ---------------------------------------------------------------------------
191
+ // Resend
192
+ // ---------------------------------------------------------------------------
193
+ describe("Resend keys", () => {
194
+ const key = `re_${"a".repeat(8)}_${"b".repeat(24)}`;
195
+
196
+ test("detects re_ key", () => {
197
+ expectMatch(key, "Resend API Key");
198
+ });
199
+
200
+ test("redacts a key echoed from shell output", () => {
201
+ const out = redactSecrets(`$ cat .env\nRESEND=${key}\n`);
202
+ expect(out).not.toContain(key);
203
+ });
204
+
205
+ test("ignores re_ identifiers that are not key-shaped", () => {
206
+ expectNoMatch("re_0_of_1_is_deprecated_6387");
207
+ });
208
+ });
209
+
190
210
  // ---------------------------------------------------------------------------
191
211
  // OpenAI
192
212
  // ---------------------------------------------------------------------------
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { analyzeServerToolPairing } from "../providers/server-tool-pairing.js";
4
+ import type { ContentBlock, Message } from "../providers/types.js";
5
+
6
+ const DEFERRED_ID = "srvtoolu_deferred";
7
+
8
+ const opener: Message = {
9
+ role: "user",
10
+ content: [{ type: "text", text: "Check both" }],
11
+ };
12
+
13
+ /** Assistant turn calling a client tool and a native web search together. */
14
+ const mixedTail: Message = {
15
+ role: "assistant",
16
+ content: [
17
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
18
+ {
19
+ type: "server_tool_use",
20
+ id: DEFERRED_ID,
21
+ name: "web_search",
22
+ input: { query: "news" },
23
+ },
24
+ ],
25
+ };
26
+
27
+ const clientResult: ContentBlock = {
28
+ type: "tool_result",
29
+ tool_use_id: "tu_a",
30
+ content: "Error: HTTP 404",
31
+ is_error: true,
32
+ };
33
+
34
+ const guidance: ContentBlock = {
35
+ type: "text",
36
+ text: "<system_notice>retry</system_notice>",
37
+ };
38
+
39
+ describe("analyzeServerToolPairing", () => {
40
+ test("an unanswered use with nothing after it is deferred", () => {
41
+ const pairing = analyzeServerToolPairing([opener, mixedTail]);
42
+
43
+ expect(pairing.deferredUseIds).toEqual(new Set([DEFERRED_ID]));
44
+ expect(pairing.resolvedPairIds.size).toBe(0);
45
+ });
46
+
47
+ test("an unanswered use answered by tool_result blocks alone is deferred", () => {
48
+ const pairing = analyzeServerToolPairing([
49
+ opener,
50
+ mixedTail,
51
+ { role: "user", content: [clientResult] },
52
+ ]);
53
+
54
+ expect(pairing.deferredUseIds).toEqual(new Set([DEFERRED_ID]));
55
+ });
56
+
57
+ test("guidance text after the tool_result makes the use an orphan, not a deferral", () => {
58
+ // The trailing text closes the assistant turn on the provider side, which
59
+ // then rejects the unanswered use as unpaired; the repair passes stamp a
60
+ // synthetic result instead of preserving it.
61
+ const pairing = analyzeServerToolPairing([
62
+ opener,
63
+ mixedTail,
64
+ { role: "user", content: [clientResult, guidance] },
65
+ ]);
66
+
67
+ expect(pairing.deferredUseIds.size).toBe(0);
68
+ expect(pairing.resolvedPairIds.size).toBe(0);
69
+ });
70
+
71
+ test("a user text message after the tool_result message makes the use an orphan", () => {
72
+ const pairing = analyzeServerToolPairing([
73
+ opener,
74
+ mixedTail,
75
+ { role: "user", content: [clientResult] },
76
+ { role: "user", content: [{ type: "text", text: "also do this" }] },
77
+ ]);
78
+
79
+ expect(pairing.deferredUseIds.size).toBe(0);
80
+ });
81
+
82
+ test("a use answered in its own message is resolved and never deferred", () => {
83
+ const pairing = analyzeServerToolPairing([
84
+ opener,
85
+ {
86
+ role: "assistant",
87
+ content: [
88
+ {
89
+ type: "server_tool_use",
90
+ id: "srvtoolu_done",
91
+ name: "web_search",
92
+ input: { query: "news" },
93
+ },
94
+ {
95
+ type: "web_search_tool_result",
96
+ tool_use_id: "srvtoolu_done",
97
+ content: [],
98
+ },
99
+ ],
100
+ },
101
+ { role: "user", content: [guidance] },
102
+ ]);
103
+
104
+ expect(pairing.resolvedPairIds).toEqual(new Set(["srvtoolu_done"]));
105
+ expect(pairing.deferredUseIds.size).toBe(0);
106
+ });
107
+ });