Skip to content

Commit 7d8ec8e

Browse files
committed
feat(coding-agent): show current websearch provider per attempt in progress updates
1 parent de58c31 commit 7d8ec8e

7 files changed

Lines changed: 180 additions & 6 deletions

File tree

packages/coding-agent/src/core/extensions/builtin/websearch/changes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-w
99
- `@mariozechner/pi-coding-agent` type/tool imports -> senpi local `../../types.ts` / `../../../types.ts`
1010
- relative `.js` import suffixes -> `.ts`
1111
- Senpi forwards the tool `AbortSignal` into native route discovery so cancellation stops waiting for pending authentication before any provider request begins, and canonicalizes one permitted terminal DNS dot in route identity so dotted and undotted aliases share one candidate.
12+
- Per-attempt search progress: `performSearch` accepts an optional `onAttempt(providerLabel, attempts)` listener fired before each provider attempt; `tool.ts` forwards it to `onUpdate`, and `SearchProgressDetails` gains optional `currentProvider`/`attempts`. The TUI partial renderer shows only the provider currently being tried (`Searching "q" via exa/backup [2/3] (max 10)`, prior attempts as a `route ...` line when expanded) instead of the full configured route; the first pre-loop update keeps upstream's full-route form. Covered by `test/websearch-progress.test.ts` and extended assertions in `test/websearch-native-tool.test.ts`.
1213
- `index.ts` diverges from upstream's provider-name bypass (`provider === "openai" || provider === "anthropic"`): the `provider_native_bypass` state is instead gated on `supportsNativeAnthropicWebSearch` / `supportsNativeOpenAiWebSearch` (+ their enable envs) from the sibling `anthropic-web-search` / `openai-web-search` builtins, and recomputed on `model_select`. Upstream's check disabled the standalone `web_search` tool for any model whose provider id is `anthropic`/`openai`, including proxied baseUrls (ccapi, quotio, …) where the injecting builtins never add the server-side tool — leaving those sessions with no web search at all, and leaving a stale bypass after mid-session model switches. Covered by `test/suite/websearch-extension-bypass.test.ts`.
1314

1415
## Conflict zones

packages/coding-agent/src/core/extensions/builtin/websearch/websearch/renderers.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ function durationText(durationMs: number): string {
3131
return durationMs >= 1000 ? `${Math.round(durationMs / 1000)}s` : `${durationMs}ms`;
3232
}
3333

34-
function attemptLabel(details: SearchDetails): string {
35-
return details.attempts
36-
? details.attempts
34+
function attemptLabel(attempts: SearchDetails["attempts"]): string {
35+
return attempts
36+
? attempts
3737
.map(
3838
(attempt) =>
3939
`${attempt.entryId ? `${attempt.provider}/${attempt.entryId}` : attempt.provider}:${attempt.error ? "failed" : attempt.resultsCount}`,
@@ -66,6 +66,18 @@ export function renderSearchResult(
6666
if (options.isPartial) {
6767
const details = result.details;
6868
if (isSearchProgressDetails(details)) {
69+
if (details.currentProvider) {
70+
const total = details.providerLabels.length;
71+
const position = Math.min((details.attempts?.length ?? 0) + 1, Math.max(total, 1));
72+
const step = total > 1 ? ` [${position}/${total}]` : "";
73+
const line = theme.fg(
74+
"warning",
75+
`Searching "${shorten(details.query, 80)}" via ${details.currentProvider}${step} (max ${details.maxResults})`,
76+
);
77+
const attempts = attemptLabel(details.attempts);
78+
const rows = options.expanded && attempts ? [line, theme.fg("muted", `route ${attempts}`)] : [line];
79+
return new Text(rows.join("\n"), 0, 0);
80+
}
6981
const route = details.providerLabels.length > 0 ? details.providerLabels.join(" -> ") : "configured providers";
7082
return new Text(
7183
theme.fg("warning", `Searching "${shorten(details.query, 80)}" via ${route} (max ${details.maxResults})`),
@@ -94,7 +106,7 @@ export function renderSearchResult(
94106

95107
if (count === 0) return new Text(summary, 0, 0);
96108

97-
const attempts = attemptLabel(details);
109+
const attempts = attemptLabel(details.attempts);
98110
const rows = options.expanded && attempts ? [summary, theme.fg("muted", `route ${attempts}`)] : [summary];
99111
const visibleLimit = options.expanded ? 8 : 3;
100112
for (const item of details.results.slice(0, visibleLimit)) {

packages/coding-agent/src/core/extensions/builtin/websearch/websearch/search.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,14 @@ function attemptFromDetails(details: SearchDetails): SearchAttempt {
244244
return attempt;
245245
}
246246

247+
export type SearchAttemptListener = (providerLabel: string, attempts: readonly SearchAttempt[]) => void;
248+
247249
export async function performSearch(
248250
config: WebsearchConfig,
249251
request: SearchRequest,
250252
signal?: AbortSignal,
251253
routingState?: SearchRoutingState,
254+
onAttempt?: SearchAttemptListener,
252255
): Promise<SearchDetails> {
253256
const startedAt = Date.now();
254257
const state = routingState ?? createSearchRoutingState(config.providers.length);
@@ -260,6 +263,7 @@ export async function performSearch(
260263
for (const index of order) {
261264
const provider = config.providers[index];
262265
if (!provider) continue;
266+
onAttempt?.(entryLabel(provider), attempts);
263267
const details = await performProviderSearch(provider, request, signal);
264268
attempts.push(attemptFromDetails(details));
265269

packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ function providerLabel(provider: SearchProviderEntry): string {
4949
}
5050

5151
function formatSearchProgressText(details: SearchProgressDetails): string {
52+
if (details.currentProvider) {
53+
const total = details.providerLabels.length;
54+
const position = Math.min((details.attempts?.length ?? 0) + 1, Math.max(total, 1));
55+
const step = total > 1 ? ` [${position}/${total}]` : "";
56+
return `Searching "${details.query}" via ${details.currentProvider}${step} (max ${details.maxResults})`;
57+
}
5258
const route = details.providerLabels.length > 0 ? details.providerLabels.join(" -> ") : "configured providers";
5359
return `Searching "${details.query}" via ${route} (max ${details.maxResults})`;
5460
}
@@ -112,7 +118,17 @@ export function createWebSearchTool(getConfig: ConfigProvider): WebSearchTool {
112118
...(params.allowed_domains === undefined ? {} : { allowedDomains: params.allowed_domains }),
113119
...(params.blocked_domains === undefined ? {} : { blockedDomains: params.blocked_domains }),
114120
};
115-
const details = await performSearch(config, request, signal, routingState);
121+
const details = await performSearch(config, request, signal, routingState, (providerLabel, attempts) => {
122+
const attemptProgress: SearchProgressDetails = {
123+
...progressDetails,
124+
currentProvider: providerLabel,
125+
attempts: [...attempts],
126+
};
127+
onUpdate?.({
128+
content: [{ type: "text", text: formatSearchProgressText(attemptProgress) }],
129+
details: attemptProgress,
130+
});
131+
});
116132
return { content: [{ type: "text", text: formatSearchText(details) }], details };
117133
},
118134
renderCall: (args, theme) => renderSearchCall(args, theme),

packages/coding-agent/src/core/extensions/builtin/websearch/websearch/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export interface SearchProgressDetails {
9595
query: string;
9696
providerLabels: string[];
9797
maxResults: number;
98+
currentProvider?: string;
99+
attempts?: SearchAttempt[];
98100
strategy?: RoutingStrategy;
99101
allowedDomains?: string[];
100102
blockedDomains?: string[];

packages/coding-agent/test/websearch-native-tool.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ describe("vendored websearch native tool", () => {
110110
);
111111

112112
// then
113-
expect(progress).toHaveLength(1);
113+
expect(progress).toHaveLength(2);
114+
expect(progress[0]?.currentProvider).toBeUndefined();
115+
expect(progress[1]?.currentProvider).toBe("openai/native");
116+
expect(progress[1]?.attempts).toEqual([]);
114117
const providerLabels = progress[0]?.providerLabels ?? [];
115118
expect(providerLabels).toHaveLength(4);
116119
expect(providerLabels[0]).toBe("native/openai");
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
import { AuthStorage } from "../src/core/auth-storage.ts";
4+
import { DEFAULT_COMPACTION_SETTINGS } from "../src/core/compaction/index.ts";
5+
import { renderSearchResult } from "../src/core/extensions/builtin/websearch/websearch/renderers.ts";
6+
import { createWebSearchTool } from "../src/core/extensions/builtin/websearch/websearch/tool.ts";
7+
import type {
8+
SearchProgressDetails,
9+
WebsearchConfig,
10+
} from "../src/core/extensions/builtin/websearch/websearch/types.ts";
11+
import type { ExtensionContext } from "../src/core/extensions/types.ts";
12+
import { ModelRegistry } from "../src/core/model-registry.ts";
13+
14+
function minimalToolContext(): ExtensionContext {
15+
return {
16+
ui: Object.create(null) as ExtensionContext["ui"],
17+
mode: "print",
18+
hasUI: false,
19+
cwd: process.cwd(),
20+
sessionManager: Object.create(null) as ExtensionContext["sessionManager"],
21+
modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()),
22+
model: undefined,
23+
serviceTier: undefined,
24+
isIdle: () => true,
25+
isProjectTrusted: () => true,
26+
signal: undefined,
27+
abort: vi.fn(),
28+
hasPendingMessages: () => false,
29+
shutdown: vi.fn(),
30+
getContextUsage: () => undefined,
31+
getCompactionSettings: () => DEFAULT_COMPACTION_SETTINGS,
32+
compact: vi.fn(),
33+
getMessageRevision: () => 0,
34+
applyCompaction: async () => ({ applied: false, reason: "rejected" }),
35+
getSystemPrompt: () => "",
36+
};
37+
}
38+
39+
const passthroughTheme = {
40+
bold: (value: string) => value,
41+
fg: (_key: string, value: string) => value,
42+
};
43+
44+
function fallbackConfig(): WebsearchConfig {
45+
return {
46+
strategy: "priority",
47+
fallback: true,
48+
auto: false,
49+
providers: [
50+
{ id: "primary", provider: "exa", apiKey: "test-key" },
51+
{ id: "backup", provider: "exa", apiKey: "test-key" },
52+
],
53+
};
54+
}
55+
56+
function exaSuccessResponse(): Response {
57+
return new Response(
58+
JSON.stringify({ results: [{ title: "Result", url: "https://example.com/a", text: "snippet" }] }),
59+
{ status: 200, headers: { "Content-Type": "application/json" } },
60+
);
61+
}
62+
63+
afterEach(() => {
64+
vi.unstubAllGlobals();
65+
});
66+
67+
describe("websearch per-attempt progress", () => {
68+
it("#given a failing first provider #when the tool executes with fallback #then emits one progress update per attempt with the current provider", async () => {
69+
// given
70+
const responses = [new Response("boom", { status: 500 }), exaSuccessResponse()];
71+
const fetchMock = vi.fn<typeof fetch>(async () => {
72+
const next = responses.shift();
73+
if (!next) throw new Error("unexpected fetch call");
74+
return next;
75+
});
76+
vi.stubGlobal("fetch", fetchMock);
77+
const tool = createWebSearchTool(() => ({ ok: true, config: fallbackConfig(), source: "test" }));
78+
const progress: SearchProgressDetails[] = [];
79+
80+
// when
81+
const result = await tool.execute(
82+
"attempt-progress",
83+
{ query: "attempt progress" },
84+
undefined,
85+
(update) => {
86+
if (update.details && "phase" in update.details && update.details.phase === "searching") {
87+
progress.push(update.details);
88+
}
89+
},
90+
minimalToolContext(),
91+
);
92+
93+
// then
94+
expect(progress).toHaveLength(3);
95+
expect(progress[0]?.currentProvider).toBeUndefined();
96+
expect(progress[1]?.currentProvider).toBe("exa/primary");
97+
expect(progress[1]?.attempts).toEqual([]);
98+
expect(progress[2]?.currentProvider).toBe("exa/backup");
99+
expect(progress[2]?.attempts).toHaveLength(1);
100+
expect(progress[2]?.attempts?.[0]?.error).toContain("HTTP 500");
101+
expect(result.details && "provider" in result.details ? result.details.entryId : undefined).toBe("backup");
102+
});
103+
104+
it("#given per-attempt progress details #when rendering partial output #then shows only the current provider with its step position", () => {
105+
// given
106+
const details: SearchProgressDetails = {
107+
phase: "searching",
108+
query: "attempt progress",
109+
providerLabels: ["exa/primary", "exa/backup"],
110+
maxResults: 10,
111+
currentProvider: "exa/backup",
112+
attempts: [{ provider: "exa", entryId: "primary", durationMs: 12, resultsCount: 0, error: "HTTP 500" }],
113+
};
114+
115+
// when
116+
const collapsed = renderSearchResult(
117+
{ content: [{ type: "text", text: "" }], details },
118+
{ expanded: false, isPartial: true },
119+
passthroughTheme,
120+
)
121+
.render(200)
122+
.join("\n");
123+
const expanded = renderSearchResult(
124+
{ content: [{ type: "text", text: "" }], details },
125+
{ expanded: true, isPartial: true },
126+
passthroughTheme,
127+
)
128+
.render(200)
129+
.join("\n");
130+
131+
// then
132+
expect(collapsed).toContain('Searching "attempt progress" via exa/backup [2/2] (max 10)');
133+
expect(collapsed).not.toContain("exa/primary ->");
134+
expect(expanded).toContain("route exa/primary:failed");
135+
});
136+
});

0 commit comments

Comments
 (0)