From 149777d1d58e6d9d443729e7371bfdb4730c6860 Mon Sep 17 00:00:00 2001 From: Jiali Chen Date: Wed, 30 Sep 2026 05:12:52 +0800 Subject: [PATCH] fix(compaction): keep session model and variant on memory restore The session.compacted handler passed agent but not model/variant when injecting restored memories. OpenCode resolves the model as input.model ?? agent.model ?? session.model, so an agent with its own model took over the session after each compaction. Read the session's current model from session.get() and pass model and variant with the injection. A "default" variant is omitted, matching OpenCode's own normalization. --- src/index.ts | 39 +++++++++ tests/compaction-agent-preservation.test.ts | 91 ++++++++++++++++++++- 2 files changed, 128 insertions(+), 2 deletions(-) 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(); + }); +});