diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index a2ee092ec3..6a126ff478 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -906,6 +906,13 @@ function isNativeToolSearchResultBlock(block: unknown): block is Record= 100 && status < 600 ? status : undefined; +} + function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): MessageCreateParamsStreaming { const messages = params.messages; if (!Array.isArray(messages) || messages.length === 0) return params; @@ -1482,25 +1489,40 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( throw error; } }; - const { params: sentParams, response } = await retryProviderRequest( - async () => { - try { - return await createRequest(); - } catch (error) { - if (unsignedThinkingReplay !== "text" && isInvalidUnsignedThinkingSignatureError(error)) { - unsignedThinkingReplay = "text"; - if (fallbackKey) unsignedThinkingTextReplayFallbacks.add(fallbackKey); - return createRequest(); + let requestOutcome: { params: MessageCreateParamsStreaming; response: Response }; + try { + requestOutcome = await retryProviderRequest( + async () => { + try { + return await createRequest(); + } catch (error) { + if (unsignedThinkingReplay !== "text" && isInvalidUnsignedThinkingSignatureError(error)) { + unsignedThinkingReplay = "text"; + if (fallbackKey) unsignedThinkingTextReplayFallbacks.add(fallbackKey); + return createRequest(); + } + throw error; } - throw error; - } - }, - { - maxRetries: options?.maxRetries, - maxRetryDelayMs: options?.maxRetryDelayMs, - signal: requestSignal, - }, - ); + }, + { + maxRetries: options?.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs, + signal: requestSignal, + }, + ); + } catch (error) { + // The SDK rejects HTTP failures instead of returning a Response, so a + // rejected request never reached onResponse. Deliver the numeric status + // once, after every internal retry, before the caller handles the error; + // errors without a status (network, aborts) report nothing rather than + // a fabricated code. + const status = httpStatusOfError(error); + if (status !== undefined) { + await options?.onResponse?.({ status, headers: {} }, model); + } + throw error; + } + const { params: sentParams, response } = requestOutcome; await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 045e166f18..44ce8915a3 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,4 +1,22 @@ +## 2026-09-08 - Deliver provider HTTP status on rejected Anthropic requests (senpi #1481) + +### What changed + +- `packages/ai/src/api/anthropic-messages.ts`: when the complete `retryProviderRequest` operation finally rejects, a numeric HTTP status carried by the SDK error (`APIError.status`) is delivered once through `options.onResponse` (`httpStatusOfError`) before the error is rethrown. Success-path delivery is unchanged; errors without a status (network, aborts) report nothing rather than a fabricated code. +- `packages/ai/test/anthropic-on-response-error.test.ts`: a rejecting fake client proves status 400 and 500 reach `onResponse` exactly once and that a status-less error produces no callback. + +### Why + +- The SDK turns HTTP failures into rejections instead of a Response, so the success-only `onResponse` never fired for them. The native tool-search adapter's permanent 400 fallback (`noteResponseStatus`, senpi #1481) was unreachable on the live error path, and any other `after_provider_response` extension was blind to error statuses. + +### Why an extension could not handle it + +- The status exists only inside the provider's own request error object; an extension observing the payload hook or the assistant error message cannot recover the HTTP code. + +### Expected merge conflict zones + +- MEDIUM: the request construction block in `packages/ai/src/api/anthropic-messages.ts` (upstream has no error-path callback); LOW: the new test file (fork-only). ## 2026-09-08 - Anthropic tool references resolve against the request's own tools (senpi native tool-search 400) ### What changed diff --git a/packages/ai/test/anthropic-on-response-error.test.ts b/packages/ai/test/anthropic-on-response-error.test.ts new file mode 100644 index 0000000000..f8cd7f24f4 --- /dev/null +++ b/packages/ai/test/anthropic-on-response-error.test.ts @@ -0,0 +1,89 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import { describe, expect, it, vi } from "vitest"; +import { getModel } from "../src/compat.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import type { Context } from "../src/types.ts"; + +/** + * The Anthropic SDK turns HTTP failures into a rejected `APIError`, so a 400 + * never produced a Response object and `onResponse` was never called. The + * native tool-search adapter (and every `after_provider_response` extension) + * needs the status anyway: its permanent 400 fallback is keyed on it. Deliver + * the numeric status on the rejection path once, after internal retries, and + * never fabricate one for errors that carry no status. + */ + +function createRejectingAnthropicClient(error: unknown): Anthropic { + return { + beta: { + messages: { + create: () => ({ + asResponse: async () => { + throw error; + }, + }), + }, + }, + } as unknown as Anthropic; +} + +function anthropicApiError(status: number, message: string): Error & { status: number } { + const error = new Error( + `${status} {"type":"error","error":{"type":"invalid_request_error","message":"${message}"}}`, + ) as Error & { + status: number; + }; + error.status = status; + return error; +} + +function streamWith( + error: unknown, + onResponse: (response: { status: number; headers: unknown }, model: unknown) => void, +) { + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + tools: [], + }; + return streamAnthropic(getModel("anthropic", "claude-haiku-4-5"), context, { + apiKey: "fake-key", + client: createRejectingAnthropicClient(error), + onResponse: onResponse as never, + }); +} + +describe("Anthropic onResponse on rejected requests", () => { + it("reports the HTTP status when the SDK rejects with an APIError", async () => { + const onResponse = vi.fn(); + const s = streamWith( + anthropicApiError(400, "Tool reference 'mcp__925c__memory' not found in available tools"), + onResponse, + ); + + const message = await s.result(); + expect(message.stopReason).toBe("error"); + expect(String(message.errorMessage)).toContain("400"); + expect(onResponse).toHaveBeenCalledTimes(1); + expect(onResponse).toHaveBeenCalledWith(expect.objectContaining({ status: 400 }), expect.anything()); + }); + + it("reports other numeric statuses too", async () => { + const onResponse = vi.fn(); + const s = streamWith(anthropicApiError(500, "internal error"), onResponse); + + const message = await s.result(); + expect(message.stopReason).toBe("error"); + expect(onResponse).toHaveBeenCalledTimes(1); + expect(onResponse).toHaveBeenCalledWith(expect.objectContaining({ status: 500 }), expect.anything()); + }); + + it("does not report a fabricated status for errors that carry none", async () => { + const onResponse = vi.fn(); + const s = streamWith(new Error("socket hangup"), onResponse); + + const message = await s.result(); + expect(message.stopReason).toBe("error"); + expect(String(message.errorMessage)).toContain("socket hangup"); + expect(onResponse).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 532bb41436..45ab1fe639 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,10 @@ ### Fixed +- A rejected Anthropic request now reports its HTTP status through the provider response hook: previously the Anthropic SDK's rejection path never reached `onResponse`/`after_provider_response`, so the native tool-search adapter's permanent 400 fallback was dead code on the live error path (senpi #1481). Errors without a numeric status (network failures, aborts) report nothing rather than a fabricated code. + +- A native tool-search 400 no longer demotes the session to a weaker model: the turn is retried once in place on the same model with native injection already disabled for the session, and only a second rejection consults the fallback chain (senpi #1482). + - `/gpt-account add` now shows the OpenAI Codex login-method chooser as a real selector (`Browser login (default)` / `Device code login (headless)`) instead of an empty text input that failed with `Unknown OpenAI Codex login method:` on Enter. The device-code flow prints the user code next to the verification URL, the browser flow opens the browser in the terminal UI and still prints the URL, and the paste-the-code dialog closes by itself once the local callback completes the login. `/claude-account add` shares the same prompt relay ([#1485](https://github.com/code-yeongyu/senpi/issues/1485)). - Anthropic requests no longer fail with `Tool reference '' not found in available tools` after a native tool search: references that come back under a gateway namespace (`mcp____`) are folded onto the request's own tool names before the request is sent, references that no longer resolve are dropped, and a search result left with no references is demoted to text instead of being replayed verbatim. A history tool call whose only justification was such a dangling reference is demoted like any other unavailable call, so one stale native search result can no longer hard-error the model and force a fallback. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2e43a43789..c0f236b2ed 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -7690,6 +7690,14 @@ export class AgentSession { ); } + private _takeNativeToolSearchInjectionFailure(): string | null { + try { + return getToolSearchService().takeNativeInjectionFailure(); + } catch { + return null; + } + } + private _getProviderRetryDelayMs(errorMessage: string): number | undefined { const markerMs = parseRetryAfterMsMarker(errorMessage); if (markerMs !== undefined) return markerMs; @@ -7822,6 +7830,7 @@ export class AgentSession { const hardErrorFallback = options.hardErrorFallback === true; const sameModelRemint = options.sameModelRemint === true; let switchedFallback = false; + let sameModelNativeRecovery = false; let is429TierRouted = false; let hintTierDelayMs: number | undefined; const tryFallback = async ( @@ -7855,25 +7864,37 @@ export class AgentSession { return "not-handled"; } } else if (hardErrorFallback) { - // A non-retryable provider failure must never replay on the same model. - // Billing-class failures never recover on this account, so the fallback - // switch pins as the session model instead of reverting after the cooldown. - const reason = isBillingErrorMessage(errorMessage) ? "billing" : "hard-error"; - switchedFallback = await tryFallback(reason, { errorMessage }); - if (!switchedFallback) { - const exhaustedChainKey = this._retryFallback.exhaustedChainKey; - if (exhaustedChainKey) { - this._emit({ - type: "retry_fallback_exhausted", - chainKey: exhaustedChainKey, - lastError: errorMessage, - }); + // A rejected native tool-search request is recoverable in place: the + // adapter is disabled for the session on the 400, so the SAME model can + // succeed on the immediate next attempt and the fallback chain must not + // demote the user to a weaker model. The pending flag is consumed once, + // so a second rejection takes the ordinary hard-error path below. + const nativeSearchFailure = this._takeNativeToolSearchInjectionFailure(); + if (nativeSearchFailure !== null) { + sameModelNativeRecovery = true; + // The recovery starts fresh, mirroring the fallback branch's attempt bookkeeping. + this._retryAttempt = 1; + } else { + // A non-retryable provider failure must never replay on the same model. + // Billing-class failures never recover on this account, so the fallback + // switch pins as the session model instead of reverting after the cooldown. + const reason = isBillingErrorMessage(errorMessage) ? "billing" : "hard-error"; + switchedFallback = await tryFallback(reason, { errorMessage }); + if (!switchedFallback) { + const exhaustedChainKey = this._retryFallback.exhaustedChainKey; + if (exhaustedChainKey) { + this._emit({ + type: "retry_fallback_exhausted", + chainKey: exhaustedChainKey, + lastError: errorMessage, + }); + } + this._resolveRetry(); + return "not-handled"; } - this._resolveRetry(); - return "not-handled"; + // The fallback starts fresh; the failed model's transient attempts do not carry over. + this._retryAttempt = 1; } - // The fallback starts fresh; the failed model's transient attempts do not carry over. - this._retryAttempt = 1; } else if (isRefusal) { // Refusals are only retried through a new chain candidate. They never use // same-model retries or the transient over-budget fallback escape hatch. @@ -8169,11 +8190,12 @@ export class AgentSession { this._retryAttempt, this._retryRandom(), ); - const delayMs = switchedFallback - ? 0 - : is429TierRouted - ? (hintTierDelayMs ?? providerDelayMs ?? localExponentialMs) - : (nonTierProviderDelayMs ?? localExponentialMs); + const delayMs = + switchedFallback || sameModelNativeRecovery + ? 0 + : is429TierRouted + ? (hintTierDelayMs ?? providerDelayMs ?? localExponentialMs) + : (nonTierProviderDelayMs ?? localExponentialMs); // Prepare before auto_retry_start so an immediate Esc can cancel the retry sleep. this._retryAbortController = new AbortController(); diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 895947496b..0999ee8947 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,3 +1,22 @@ +## Same-model recovery for a native tool-search 400 (2026-09-08) + +### What changed + +- `packages/coding-agent/src/core/agent-session.ts`: the hard-error fallback branch first consumes the session's pending native tool-search injection failure (`_takeNativeToolSearchInjectionFailure`). When present, it skips `tryFallback()` and runs the shared retry scheduling (zero-delay `auto_retry_start`, failed-message removal, continuation) on the SAME model — the adapter is already disabled for the session, so the next attempt succeeds in place and the user is not demoted to a weaker model. The pending flag is consumed once, so a second rejection takes the ordinary hard-error chain. +- `packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts`: two cases pin the contract — one same-model retry with no `retry_fallback_applied` events, and a normal fallback switch on the second consecutive 400. + +### Why + +- A native tool-search 400 hard-errored the model and the hard-error branch always switched to the next fallback candidate (`RetryFallbackController` intentionally excludes the current model), demoting the user mid-task even though the same model succeeds once native injection is off (senpi #1482). + +### Why an extension could not handle it + +- No hook exists at the fallback-decision point; the retry branch is session-owned. The extension can only record that its own request was rejected (the pending flag on the provider-scoped `ToolSearchService`) — consuming it must happen in the session. + +### Expected merge conflict zones + +- MEDIUM: the `hardErrorFallback` branch and retry-delay computation in `agent-session.ts` (fork-heavy area); LOW: the suite test additions. + ## GPT-6 Astra high-reasoning warning parity (2026-09-08) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md b/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md index d5d50d9c6a..55ea25e10c 100644 --- a/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md @@ -1,5 +1,22 @@ # Tool Search Builtin Changes +## 2026-09-08 - Wire the native 400 fallback into a session recovery signal (senpi #1481/#1482) + +### What changed + +- `service.ts`: `ToolSearchService` carries a one-shot pending flag (`noteNativeInjectionFailure` / `takeNativeInjectionFailure`) recording that a native-injected request was rejected. +- `index.ts`: the adapter's `onFallback` now records that reason on the service, so the session's retry branch can recover in place (senpi #1482) instead of falling back blindly. +- `test/tool-search/native-anthropic.test.ts`: a wiring case drives `emitBeforeProviderRequest` (with a supported Anthropic model and an MCP feed) and `after_provider_response` 400, asserting the flag is set once, consumed once, and injection stays off afterwards. + +### Why + +- `AnthropicNativeToolSearchAdapter` already disables itself permanently on a 400, but nothing told the session WHY the current turn failed; the flag is the provider-scope-scoped channel between the extension and the session's retry branch. + +### Expected merge conflict zones + +- LOW: the adapter construction site in `index.ts` and the service class; both are fork-owned. + + ## 2026-09-04 - Gate native tool-search on model support and fix the tool_reference field ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts b/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts index 495b14e3e9..e42254216e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts @@ -45,6 +45,7 @@ export function createToolSearchExtension(service: ToolSearchService): Extension return doc?.source === "extension" && !pi.getActiveTools().includes(name); }, searchToolName: TOOL_SEARCH_TOOL_NAME, + onFallback: (reason) => service.noteNativeInjectionFailure(reason), }); pi.on("before_provider_request", (event, ctx) => nativeAdapter.applyBeforeRequest(event.model ?? ctx.model, event.payload), diff --git a/packages/coding-agent/src/core/extensions/builtin/tool-search/service.ts b/packages/coding-agent/src/core/extensions/builtin/tool-search/service.ts index 3a44e97ad8..e05a45a6d4 100644 --- a/packages/coding-agent/src/core/extensions/builtin/tool-search/service.ts +++ b/packages/coding-agent/src/core/extensions/builtin/tool-search/service.ts @@ -42,6 +42,20 @@ export class ToolSearchService { }); } + #nativeInjectionFailure: string | null = null; + + /** Record that a native-injected request was rejected; the session consumes it once. */ + noteNativeInjectionFailure(reason: string): void { + this.#nativeInjectionFailure = reason; + } + + /** Consume the pending native-injection failure, if any (one-shot). */ + takeNativeInjectionFailure(): string | null { + const reason = this.#nativeInjectionFailure; + this.#nativeInjectionFailure = null; + return reason; + } + bindRuntime(runtime: RuntimeApi): void { this.#runtime = runtime; } diff --git a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts index b386aea7c9..ddf5b877a0 100644 --- a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts +++ b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts @@ -1,5 +1,6 @@ import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; +import { getToolSearchService } from "../../src/core/extensions/builtin/tool-search/service.ts"; import type { SelectorCooldowns } from "../../src/core/retry-fallback/cooldown.ts"; import { createHarness, type Harness } from "./harness.ts"; @@ -19,6 +20,12 @@ function cooldownsFor(harness: Harness): SelectorCooldowns { return cooldowns; } +const testToolSearchRuntime = { + getAllTools: () => [], + getActiveTools: () => [], + setActiveTools: () => {}, +} as const; + describe("retry fallback hard errors", () => { const harnesses: Harness[] = []; afterEach(() => { @@ -222,4 +229,63 @@ describe("retry fallback hard errors", () => { expect(harness.eventsOfType("auto_retry_start")).toEqual([]); expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); }); + it("retries a native tool-search 400 once on the same model with injection disabled", async () => { + // A hard 400 whose request carried native injection must recover in place: + // the adapter is already disabled for the session, so the same model can + // succeed on the next attempt and the fallback chain is not the recovery. + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, maxRetries: 3, baseDelayMs: 60_000, fallbackChains: { [primary]: [fallback] } }, + }, + }); + harnesses.push(harness); + getToolSearchService(testToolSearchRuntime).noteNativeInjectionFailure("native tool-search 400"); + harness.setResponses([ + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "invalid_request_error: Tool reference 'mcp__925c__memory' not found in available tools", + }), + fauxAssistantMessage("recovered in place"), + ]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1", "faux-1"]); + expect(harness.session.model?.id).toBe("faux-1"); + expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); + expect(harness.eventsOfType("auto_retry_start")).toHaveLength(1); + // No model switch means no fallback lifecycle events; the recovery is a plain same-model retry. + expect(harness.eventsOfType("retry_fallback_succeeded")).toEqual([]); + expect(harness.session.state.messages.at(-1)).toMatchObject({ role: "assistant" }); + }); + + it("falls back normally when the same model 400s again after the native recovery", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, maxRetries: 3, baseDelayMs: 60_000, fallbackChains: { [primary]: [fallback] } }, + }, + }); + harnesses.push(harness); + getToolSearchService(testToolSearchRuntime).noteNativeInjectionFailure("native tool-search 400"); + harness.setResponses([ + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "invalid_request_error: Tool reference 'mcp__925c__memory' not found in available tools", + }), + fauxAssistantMessage("", { stopReason: "error", errorMessage: "invalid_request_error: still rejected" }), + fauxAssistantMessage("fallback answer"), + ]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1", "faux-1", "faux-2"]); + expect( + harness.events + .filter((event) => event.type === "retry_fallback_applied") + .map((event) => (event.type === "retry_fallback_applied" ? event.reason : "")), + ).toEqual(["hard-error"]); + expect(harness.eventsOfType("auto_retry_start")).toHaveLength(2); + }); }); diff --git a/packages/coding-agent/test/tool-search/native-anthropic.test.ts b/packages/coding-agent/test/tool-search/native-anthropic.test.ts index dc63e72d38..8b8e006f73 100644 --- a/packages/coding-agent/test/tool-search/native-anthropic.test.ts +++ b/packages/coding-agent/test/tool-search/native-anthropic.test.ts @@ -1,12 +1,5 @@ -// Todo 33 — Anthropic native tool-search adapter (gated GO by the todo-29 spike). -// -// Exercises the request-side injection + HARD RULES against the request -// validator mock (which 400s on violation exactly as the API would), the -// tool_reference expansion, the 400 -> local-fallback path, the config-off -// no-op, and Metis M5 co-residence with anthropic-web-search + a cache_control -// tail tool + a service_tier field. - import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { getModel } from "@earendil-works/pi-ai/compat"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { addAnthropicWebSearchToPayload } from "../../src/core/extensions/builtin/anthropic-web-search/index.ts"; @@ -19,6 +12,7 @@ import { installMcpNativeToolSearchGate, isMcpNativeToolSearchEnabled, } from "../../src/core/extensions/builtin/tool-search/native-search.ts"; +import { getToolSearchService } from "../../src/core/extensions/builtin/tool-search/service.ts"; import type { ExtensionAPI, ExtensionFactory } from "../../src/core/extensions/types.ts"; import { mockAnthropicExpandToolReferences, @@ -399,3 +393,65 @@ describe("todo33 anthropic native: M5 co-residence with web-search + cache tail expect(validateAnthropicToolSearchPayload(final)).toEqual({ status: 200 }); }); }); + +describe("native 400 pending recovery signal", () => { + it("wires the adapter's 400 fallback into a session-consumable pending retry signal", async () => { + installMcpNativeToolSearchGate(() => true); + const searchableExtension: ExtensionFactory = (pi: ExtensionAPI) => { + pi.registerTool({ + name: "mcp_memory", + label: "Memory", + description: "Look up stored memory notes", + exposure: "search", + parameters: Type.Object({ query: Type.String() }), + execute: async () => ({ content: [{ type: "text" as const, text: "ok" }], details: {} }), + }); + }; + const harness = await createHarness({ + extensionFactories: [ + { factory: toolSearchExtension, path: "" }, + { factory: searchableExtension, path: "/workspace/extensions/memory.ts" }, + ], + }); + try { + const service = getToolSearchService(); + service.feed( + "mcp", + [ + { + name: "mcp_memory", + label: "Memory", + aliases: [], + description: "Look up stored memory notes", + keywords: [], + source: "mcp", + group: "mcp", + ownerLabel: "mcp", + registrationId: "mcp\0mcp\0mcp_memory", + }, + ], + { activate: () => {} }, + ); + await harness.getExtensionRunner().emit({ type: "session_start", reason: "startup" }); + const payload = { model: "claude-fable-5-1", tools: [] as unknown[], messages: [] }; + const injected = await harness.getExtensionRunner().emitBeforeProviderRequest(payload, undefined, { + model: getModel("anthropic", "claude-fable-5-1"), + headers: {}, + }); + expect(JSON.stringify(injected)).toContain("tool_search_tool_bm25"); + + await harness.getExtensionRunner().emit({ type: "after_provider_response", status: 400, headers: {} }); + + expect(service.takeNativeInjectionFailure()).toEqual(expect.stringContaining("400")); + expect(service.takeNativeInjectionFailure()).toBeNull(); + const untouched = await harness.getExtensionRunner().emitBeforeProviderRequest(payload, undefined, { + model: getModel("anthropic", "claude-fable-5-1"), + headers: {}, + }); + expect(JSON.stringify(untouched)).not.toContain("tool_search_tool_bm25"); + } finally { + installMcpNativeToolSearchGate(() => false); + harness.cleanup(); + } + }); +});