diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 88812012c..48af85756 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -566,6 +566,14 @@ export function buildSessionContext( let hasPersistedMCPToolSelection = false; let mode = "none"; let modeData: Record | undefined; + // Track whether an explicit `model_change` with role="default" has been + // seen on this path. Once a user (or the agent itself) records an + // explicit default, later assistant-message inference must NOT overwrite + // it: temporary fallbacks (retry fallback, context promotion) and + // server-side model downgrades both produce assistant messages tagged + // with the wrong model id, which previously clobbered the user's pick on + // resume (issue #849). + let hasExplicitDefaultModel = false; for (const entry of path) { if (entry.type === "thinking_level_change") { @@ -575,12 +583,21 @@ export function buildSessionContext( if (entry.model) { const role = entry.role ?? "default"; models[role] = entry.model; + if (role === "default") { + hasExplicitDefaultModel = true; + } } } else if (entry.type === "service_tier_change") { serviceTier = entry.serviceTier ?? undefined; } else if (entry.type === "message" && entry.message.role === "assistant") { - // Infer default model from assistant messages - models.default = `${entry.message.provider}/${entry.message.model}`; + // Legacy fallback: infer default model from assistant messages only + // when no explicit `model_change` (role=default) entry has been + // recorded yet. Newer sessions always record an explicit default + // model_change at the start of the conversation, so this branch is + // only used to keep pre-model_change sessions working. + if (!hasExplicitDefaultModel) { + models.default = `${entry.message.provider}/${entry.message.model}`; + } } else if (entry.type === "compaction") { compaction = entry; } else if (entry.type === "ttsr_injection") { diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 0cedc4132..433e4bfc3 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -895,8 +895,8 @@ describe("buildSessionContext", () => { ]; const loaded = buildSessionContext(entries); - // model_change is later overwritten by assistant message's model info - expect(loaded.models.default).toBe("anthropic/claude-sonnet-4-5"); + // Issue #849: explicit model_change wins over assistant-message inference. + expect(loaded.models.default).toBe("openai/gpt-4"); expect(loaded.thinkingLevel).toBe("high"); }); }); diff --git a/packages/coding-agent/test/issue-849-repro.test.ts b/packages/coding-agent/test/issue-849-repro.test.ts new file mode 100644 index 000000000..4d0f5a990 --- /dev/null +++ b/packages/coding-agent/test/issue-849-repro.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "bun:test"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { + buildSessionContext, + type ModelChangeEntry, + type SessionEntry, + type SessionMessageEntry, +} from "@oh-my-pi/pi-coding-agent/session/session-manager"; + +/** + * Issue #849: After a user explicitly switches to gpt-5.5, the session reverts + * to gpt-5.4 on resume. + * + * Root cause hypothesis: buildSessionContext walks entries in path order and + * overwrites `models.default` from every assistant message's reported model. + * When a temporary fallback (e.g. retry fallback or a server-side downgrade + * in the codex provider) emits an assistant message tagged with the older + * model id, that id clobbers the user's explicitly chosen default. + * + * Contract under test: an explicit `model_change` with role="default" must + * win over assistant-message inference from later messages produced under a + * temporary or downgraded model. + */ +describe("issue #849: explicit default model survives later assistant-message inference", () => { + function makeAssistantEntry( + id: string, + parentId: string | null, + provider: string, + model: string, + ): SessionMessageEntry { + const message: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "ok" }], + api: "openai-codex-responses", + provider: provider as AssistantMessage["provider"], + model, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.parse(`2026-04-30T00:00:0${id.slice(-1)}Z`), + }; + return { + type: "message", + id, + parentId, + timestamp: new Date(message.timestamp).toISOString(), + message, + }; + } + + function makeModelChange(id: string, parentId: string | null, model: string, role: string): ModelChangeEntry { + return { + type: "model_change", + id, + parentId, + timestamp: new Date().toISOString(), + model, + role, + }; + } + + it("preserves explicit user-selected default when a later assistant message reports a downgraded model", () => { + // User explicitly picks gpt-5.5 as default. + // Then a temporary fallback (retry / context promotion) appends a + // model_change with role="temporary" pointing at gpt-5.4, and the + // next assistant message is produced under that temporary model. + const entries: SessionEntry[] = [ + makeModelChange("a1", null, "openai-codex/gpt-5.5", "default"), + makeAssistantEntry("a2", "a1", "openai-codex", "gpt-5.5"), + makeModelChange("a3", "a2", "openai-codex/gpt-5.4", "temporary"), + makeAssistantEntry("a4", "a3", "openai-codex", "gpt-5.4"), + ]; + + const ctx = buildSessionContext(entries); + expect(ctx.models.default).toBe("openai-codex/gpt-5.5"); + }); + + it("preserves explicit user-selected default when the codex backend reports a different model id", () => { + // User picks gpt-5.5; the assistant message returned by the upstream + // codex backend is tagged "gpt-5.4" (server-side downgrade / + // stale id mapping). Resume must still restore what the user picked. + const entries: SessionEntry[] = [ + makeModelChange("b1", null, "openai-codex/gpt-5.5", "default"), + makeAssistantEntry("b2", "b1", "openai-codex", "gpt-5.4"), + ]; + + const ctx = buildSessionContext(entries); + expect(ctx.models.default).toBe("openai-codex/gpt-5.5"); + }); + + it("still infers default from assistant messages when no model_change entry exists", () => { + // Backwards compatibility: legacy sessions have no model_change entries + // and rely on assistant-message inference. + const entries: SessionEntry[] = [makeAssistantEntry("c1", null, "openai-codex", "gpt-5.4")]; + + const ctx = buildSessionContext(entries); + expect(ctx.models.default).toBe("openai-codex/gpt-5.4"); + }); +}); diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index d7536e7d0..20470062f 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -151,8 +151,11 @@ describe("buildSessionContext", () => { msg("3", "2", "assistant", "hi"), ]; const ctx = buildSessionContext(entries); - // Assistant message overwrites model change - expect(ctx.models.default).toBe("anthropic/claude-test"); + // Issue #849: an explicit model_change with role="default" must NOT + // be silently overwritten by a later assistant message tagged with a + // different model id. Temporary fallbacks and provider-side + // downgrades both produce such mismatched messages. + expect(ctx.models.default).toBe("openai/gpt-4"); }); });