diff --git a/src/index.ts b/src/index.ts index 5b56c708..a215d16e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,6 +140,41 @@ export async function resolveSessionAgent( return undefined; } +/** + * Resolve the session's current model (the server-side `session.model`, written by the most + * recent real user message and left untouched by compaction). Compaction memory injection must + * pass it explicitly: OpenCode resolves `input.model ?? agent.model ?? session.model`, so when the + * active agent declares its own model the injected message would otherwise switch the session to + * that agent's default model and variant. + */ +export async function resolveSessionModel( + client: unknown, + sessionID: string +): Promise<{ model: { providerID: string; modelID: string }; variant?: string } | undefined> { + const sessionClient = (client as { session?: { get?: (args: unknown) => Promise } }) + ?.session; + if (typeof sessionClient?.get !== "function") return undefined; + + try { + const session = unwrapSdkData<{ + model?: { providerID?: string; id?: string; variant?: string }; + }>(await sessionClient.get({ path: { id: sessionID } })); + const model = session?.model; + if (typeof model?.providerID !== "string" || typeof model?.id !== "string") return undefined; + const variant = + typeof model.variant === "string" && model.variant && model.variant !== "default" + ? model.variant + : undefined; + return { + model: { providerID: model.providerID, modelID: model.id }, + ...(variant ? { variant } : {}), + }; + } catch (error) { + log("resolveSessionModel: session.get failed", { sessionID, error: String(error) }); + return undefined; + } +} + async function isInternalCaptureSession(client: unknown, sessionID: string): Promise { // Fast path: sessions we created ourselves (survives brief post-delete window). if (isTrackedInternalCaptureSession(sessionID)) { @@ -998,6 +1033,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { ); return; } + const current = await resolveSessionModel(ctx.client, sessionID); await ctx.client.session.prompt({ path: { id: sessionID }, @@ -1012,6 +1048,8 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { ], noReply: true, agent, + ...(current ? { model: current.model } : {}), + ...(current?.variant ? { variant: current.variant } : {}), }, }); @@ -1032,6 +1070,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { sessionID, count: memoriesResult.results.length, agent: agent ?? null, + model: current ? `${current.model.providerID}/${current.model.modelID}` : null, }); } catch (error) { log("Compaction handler error", { error: String(error) }); diff --git a/tests/compaction-agent-preservation.test.ts b/tests/compaction-agent-preservation.test.ts index cd4b75a2..279ce066 100644 --- a/tests/compaction-agent-preservation.test.ts +++ b/tests/compaction-agent-preservation.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveSessionAgent } from "../src/index.js"; +import { resolveSessionAgent, resolveSessionModel } from "../src/index.js"; const tempDirs: string[] = []; @@ -76,6 +76,56 @@ describe("resolveSessionAgent (#236)", () => { }); }); +describe("resolveSessionModel", () => { + it("returns the session's current model and variant", async () => { + const client = { + session: { + get: async () => ({ + data: { model: { providerID: "deepseek", id: "deepseek-flash", variant: "max" } }, + }), + }, + }; + + await expect(resolveSessionModel(client, "ses-1")).resolves.toEqual({ + model: { providerID: "deepseek", modelID: "deepseek-flash" }, + variant: "max", + }); + }); + + it("omits the default variant", async () => { + const client = { + session: { + get: async () => ({ + data: { model: { providerID: "anthropic", id: "claude-opus-5-5", variant: "default" } }, + }), + }, + }; + + await expect(resolveSessionModel(client, "ses-1")).resolves.toEqual({ + model: { providerID: "anthropic", modelID: "claude-opus-5-5" }, + }); + }); + + it("returns undefined when the session has no model or cannot be read", async () => { + await expect( + resolveSessionModel({ session: { get: async () => ({ data: {} }) } }, "ses-1") + ).resolves.toBeUndefined(); + await expect( + resolveSessionModel( + { + session: { + get: async () => { + throw new Error("boom"); + }, + }, + }, + "ses-1" + ) + ).resolves.toBeUndefined(); + await expect(resolveSessionModel({}, "ses-1")).resolves.toBeUndefined(); + }); +}); + const indexUrl = new URL("../src/index.js", import.meta.url).href; const clientUrl = new URL("../src/services/client.js", import.meta.url).href; const configUrl = new URL("../src/config.js", import.meta.url).href; @@ -96,6 +146,7 @@ function runCompactionScenario(opts: { memories: Array<{ memory: string; tags?: string[] }>; messages: Array<{ info: Record }>; sessionAgent?: string; + sessionModel?: Record; compactionEnabled?: boolean; }) { const dir = mkdtempSync(join(tmpdir(), "opencode-mem-compaction-agent-")); @@ -149,7 +200,9 @@ mock.module(${JSON.stringify(languageUrl)}, () => ({ getLanguageName: () => "Eng const mockClient = { session: { - get: async () => ({ data: ${JSON.stringify({ agent: opts.sessionAgent })} }), + get: async () => ({ + data: ${JSON.stringify({ agent: opts.sessionAgent, model: opts.sessionModel })}, + }), messages: async () => ({ data: ${JSON.stringify(opts.messages)} }), prompt: async (args) => { promptCalls.push(args); @@ -297,3 +350,37 @@ describe("session.compacted agent preservation (#236)", () => { expect(result.parsed?.promptCalls).toEqual([]); }); }); + +describe("session.compacted model preservation", () => { + it("passes the session's current model and variant to session.prompt", () => { + const result = runCompactionScenario({ + sessionAgent: "my-orchestrator", + sessionModel: { providerID: "deepseek", id: "deepseek-flash", variant: "max" }, + memories: [{ memory: "remember this", tags: ["t1"] }], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.promptCalls).toHaveLength(1); + expect(result.parsed?.promptCalls[0]?.body?.model).toEqual({ + providerID: "deepseek", + modelID: "deepseek-flash", + }); + expect(result.parsed?.promptCalls[0]?.body?.variant).toBe("max"); + }); + + it("leaves model unset when the session has no current model", () => { + const result = runCompactionScenario({ + sessionAgent: "my-orchestrator", + memories: [{ memory: "remember this", tags: ["t1"] }], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.promptCalls).toHaveLength(1); + expect(result.parsed?.promptCalls[0]?.body?.model).toBeUndefined(); + expect(result.parsed?.promptCalls[0]?.body?.variant).toBeUndefined(); + }); +});