Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> } })
?.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<boolean> {
// Fast path: sessions we created ourselves (survives brief post-delete window).
if (isTrackedInternalCaptureSession(sessionID)) {
Expand Down Expand Up @@ -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 },
Expand All @@ -1012,6 +1048,8 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
],
noReply: true,
agent,
...(current ? { model: current.model } : {}),
...(current?.variant ? { variant: current.variant } : {}),
},
});

Expand All @@ -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) });
Expand Down
91 changes: 89 additions & 2 deletions tests/compaction-agent-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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;
Expand All @@ -96,6 +146,7 @@ function runCompactionScenario(opts: {
memories: Array<{ memory: string; tags?: string[] }>;
messages: Array<{ info: Record<string, unknown> }>;
sessionAgent?: string;
sessionModel?: Record<string, unknown>;
compactionEnabled?: boolean;
}) {
const dir = mkdtempSync(join(tmpdir(), "opencode-mem-compaction-agent-"));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
});
Loading