From 78ef6805f34165eb45b48ec8432fdda42820c602 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 19 Aug 2026 09:44:22 +0000 Subject: [PATCH] fix(sdk): thread response model into after_provider_response context ExtensionRunner.emitAfterProviderResponse accepted the response model but discarded it, calling createContext() with no model. Response-scoped hooks therefore saw the primary session model in ctx.model and ctx.models.current() even when the response came from a cross-provider side request, so an extension that revokes a credential on an HTTP 402 could target the wrong provider. Call createContext(model) to match emitBeforeProviderRequest, plus a regression test asserting both fields expose the response model. Fixes #8955 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/extensibility/extensions/runner.ts | 5 +- .../test/extensions-runner.test.ts | 74 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fdd5ae758..ad8937020 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed - Fixed Claude Code marketplace plugins ignoring the `enabledPlugins` switch in `~/.claude/settings.json` and `.claude/settings(.local).json`: a plugin turned off for a project no longer loads there, and a local-scope install enabled for a project loads even when its recorded `projectPath` is a different directory +- Fixed `after_provider_response` extension handlers receiving the primary session model in `ctx.model` and `ctx.models.current()` for cross-provider side requests. `ExtensionRunner.emitAfterProviderResponse` accepted the response model but discarded it, so a handler revoking a credential on an HTTP 402 could target the wrong provider. It now threads the response model into the context, matching `emitBeforeProviderRequest` ([#8955](https://github.com/can1357/oh-my-pi/issues/8955)). - Fixed task and eval subagents discovering newly added agent definitions while resolving their role aliases from stale startup settings. Subagent preflight now atomically reloads persisted settings before agent discovery while preserving live runtime overrides. - Fixed images returned by tools mounted under `xd://` rendering only as file links instead of inline terminal graphics. - Resume Cursor idle-stall turns after completed MCP/todo tool results. The watchdog already closes the Connect stream, so unmarked blocks no longer need the `exec-resolved` marker to continue. diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 60e8189d2..031b96bed 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -1665,8 +1665,9 @@ export class ExtensionRunner { return currentPayload; } - async emitAfterProviderResponse(response: ProviderResponseMetadata, _model?: Model): Promise { - const ctx = this.createContext(); + /** Runs response hooks with the model that produced that provider response. */ + async emitAfterProviderResponse(response: ProviderResponseMetadata, model?: Model): Promise { + const ctx = this.createContext(model); for (const ext of this.extensions) { const handlers = ext.handlers.get("after_provider_response"); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 6de740abe..614ac791a 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -761,6 +761,80 @@ describe("ExtensionRunner", () => { expect(errors[0]?.event).toBe("after_provider_response"); expect(errors[0]?.error).toContain("response failed"); }); + + it("exposes the response model instead of the primary session model", async () => { + const primaryModel = getBundledModel("openai-codex", "gpt-5.6-sol"); + const requestModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!primaryModel || !requestModel) throw new Error("Expected bundled cross-provider models to exist"); + + const eventsPath = path.join(tempDir.path(), "after-provider-response-model.jsonl"); + const extCode = ` + import * as fs from "node:fs"; + + export default function(pi) { + pi.on("after_provider_response", async (_event, ctx) => { + const current = ctx.models.current(); + fs.appendFileSync( + ${JSON.stringify(eventsPath)}, + JSON.stringify({ + model: ctx.model && { provider: ctx.model.provider, id: ctx.model.id }, + current: current && { provider: current.provider, id: current.id }, + }) + "\\n", + ); + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "after-response-model.ts"), extCode); + + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + runner.initialize( + { + sendMessage: () => {}, + sendUserMessage: () => {}, + appendEntry: () => {}, + setLabel: () => {}, + getActiveTools: () => [], + getAllTools: () => [], + setActiveTools: async () => {}, + getCommands: () => [], + setModel: async () => false, + getThinkingLevel: () => undefined, + setThinkingLevel: () => {}, + getSessionName: () => undefined, + setSessionName: async () => {}, + }, + { + getModel: () => primaryModel, + isIdle: () => true, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: async () => {}, + getSystemPrompt: () => [], + }, + ); + + await runner.emitAfterProviderResponse( + { status: 402, headers: {}, requestId: "req_402", metadata: { provider: requestModel.provider } }, + requestModel, + ); + + const expected = { provider: requestModel.provider, id: requestModel.id }; + const events = fs + .readFileSync(eventsPath, "utf8") + .trim() + .split("\n") + .map(line => JSON.parse(line)); + expect(events).toEqual([{ model: expected, current: expected }]); + }); }); describe("session_stop", () => {