@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.
- package/docs/desktop-browser-cli.md +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
- package/openapi.yaml +15 -0
- package/package.json +1 -1
- package/src/__tests__/agent-loop.test.ts +135 -0
- package/src/__tests__/anthropic-provider.test.ts +55 -0
- package/src/__tests__/chat-credential-redaction.test.ts +23 -0
- package/src/__tests__/history-repair.test.ts +53 -0
- package/src/__tests__/secret-scanner.test.ts +20 -0
- package/src/__tests__/server-tool-pairing.test.ts +107 -0
- package/src/__tests__/tool-result-follow-up.test.ts +99 -0
- package/src/agent/loop.ts +18 -12
- package/src/agent/tool-result-follow-up.ts +70 -0
- package/src/api/events/question-request.ts +1 -0
- package/src/cli/commands/browser.help.ts +5 -1
- package/src/daemon/chat-credential-redaction.ts +16 -1
- package/src/desktop/desktop-automation-lease.test.ts +87 -0
- package/src/desktop/desktop-automation-lease.ts +51 -2
- package/src/desktop/desktop-help.ts +16 -0
- package/src/desktop/desktop-wallpaper.test.ts +12 -1
- package/src/permissions/question-prompter.test.ts +31 -0
- package/src/permissions/question-prompter.ts +2 -0
- package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
- package/src/providers/server-tool-pairing.ts +16 -7
- package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
- package/src/tools/ask-question/ask-question-tool.ts +119 -23
- package/src/tools/browser/browser-execution.ts +7 -1
- 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
|
|
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
|
|
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
|
|
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
|
@@ -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
|
+
});
|