diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index c81e99f2b250..71908ebae71b 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -3409,6 +3409,32 @@ describe("capability cli", () => { expect(webSearchRuntime.runWebSearch).not.toHaveBeenCalled(); }); + it("reports structured web search failures in the envelope and exits nonzero", async () => { + const webSearchRuntime = await import("../web-search/runtime.js"); + vi.mocked(webSearchRuntime.runWebSearch).mockResolvedValueOnce({ + provider: "kitchen-sink-search", + result: { + ok: false, + statusCode: 429, + error: { code: "rate_limited", message: "Kitchen Sink rate limit." }, + results: [], + }, + }); + + await expect( + runCap("capability", "web", "search", "--query", "rate limit", "--json"), + ).rejects.toThrow("exit 1"); + + expect(firstJsonOutput()).toEqual( + expect.objectContaining({ + ok: false, + capability: "web.search", + provider: "kitchen-sink-search", + error: "Kitchen Sink rate limit.", + }), + ); + }); + it("uses the infer web search provider override when resolving SecretRefs", async () => { const unresolvedConfig = { tools: { web: { search: { provider: "exa", enabled: true } } }, @@ -3623,6 +3649,33 @@ describe("capability cli", () => { ); }); + it("reports structured web fetch failures in the envelope and exits nonzero", async () => { + const webFetchRuntime = await import("../web-fetch/runtime.js"); + vi.mocked(webFetchRuntime.resolveWebFetchDefinition).mockReturnValueOnce({ + provider: { id: "kitchen-sink-fetch" }, + definition: { + execute: vi.fn(async () => ({ + ok: false, + statusCode: 504, + error: { code: "timeout", message: "Kitchen Sink fetch timed out." }, + })), + }, + } as never); + + await expect( + runCap("capability", "web", "fetch", "--url", "kitchen://fixture/timeout", "--json"), + ).rejects.toThrow("exit 1"); + + expect(firstJsonOutput()).toEqual( + expect.objectContaining({ + ok: false, + capability: "web.fetch", + provider: "kitchen-sink-fetch", + error: "Kitchen Sink fetch timed out.", + }), + ); + }); + it("surfaces available, configured, and selected for web providers", async () => { mocks.loadConfig.mockReturnValue({ tools: { diff --git a/src/cli/capability-cli/web.ts b/src/cli/capability-cli/web.ts index 96d0aeac6503..805dd94e0633 100644 --- a/src/cli/capability-cli/web.ts +++ b/src/cli/capability-cli/web.ts @@ -25,6 +25,29 @@ import { resolveLocalCapabilityRuntimeConfig, } from "./shared.js"; +function describeWebResultFailure(result: Record): string | undefined { + const statusCode = + typeof result.statusCode === "number" && Number.isFinite(result.statusCode) + ? result.statusCode + : undefined; + const error = result.error; + const errorMessage = + typeof error === "string" + ? error + : error && + typeof error === "object" && + typeof (error as { message?: unknown }).message === "string" + ? (error as { message: string }).message + : undefined; + if (result.ok !== false && (statusCode === undefined || statusCode < 400) && !errorMessage) { + return undefined; + } + return ( + errorMessage ?? + (statusCode ? `provider returned status ${statusCode}` : "provider reported failure") + ); +} + async function runWebSearchCommand(params: { query: string; provider?: string; limit?: number }) { const rawConfig = getRuntimeConfig(); const scopedTargets = getCapabilityWebSearchCommandSecretTargets(rawConfig, { @@ -51,13 +74,15 @@ async function runWebSearchCommand(params: { query: string; provider?: string; l limit: params.limit, }, }); + const error = describeWebResultFailure(result.result); return { - ok: true, + ok: error === undefined, capability: "web.search", transport: "local" as const, provider: result.provider, attempts: [], outputs: [{ result: result.result }], + ...(error ? { error } : {}), } satisfies CapabilityEnvelope; } @@ -89,13 +114,15 @@ async function runWebFetchCommand(params: { url: string; provider?: string; form url: params.url, format: params.format, }); + const error = describeWebResultFailure(result); return { - ok: true, + ok: error === undefined, capability: "web.fetch", transport: "local" as const, provider: resolved.provider.id, attempts: [], outputs: [{ result }], + ...(error ? { error } : {}), } satisfies CapabilityEnvelope; } @@ -110,14 +137,19 @@ export function registerWebCapabilityCommands(capability: Command): void { .option("--limit ", "Result limit") .option("--json", "Output JSON", false) .action(async (opts) => { + let failed = false; await runCommandWithRuntime(defaultRuntime, async () => { const result = await runWebSearchCommand({ query: String(opts.query), provider: opts.provider as string | undefined, limit: parseOptionalPositiveInteger(opts.limit, "--limit"), }); + failed = !result.ok; emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); + if (failed) { + defaultRuntime.exit(1); + } }); web @@ -128,14 +160,19 @@ export function registerWebCapabilityCommands(capability: Command): void { .option("--format ", "Format hint") .option("--json", "Output JSON", false) .action(async (opts) => { + let failed = false; await runCommandWithRuntime(defaultRuntime, async () => { const result = await runWebFetchCommand({ url: String(opts.url), provider: opts.provider as string | undefined, format: opts.format as string | undefined, }); + failed = !result.ok; emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); + if (failed) { + defaultRuntime.exit(1); + } }); web