diff --git a/.agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs b/.agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs new file mode 100644 index 000000000..304eb5f83 --- /dev/null +++ b/.agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs @@ -0,0 +1,95 @@ +// Run from the repository root: node --import tsx .agents/skills/senpi-qa/scripts/scenarios/resume-effort-qa.mjs --self-test +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { writeResumeEffortFixture } from "../../../../../packages/coding-agent/test/suite/resume-effort-fixtures.ts"; +import { evidenceDir, guardRealAuth, makeSandbox, repoRoot } from "../lib/common.mjs"; +import { startFakeModelServer } from "../lib/fake-model-server.mjs"; +import { hermeticEnv, writeMockModelsJson } from "../lib/mock-loop-support.mjs"; +import { TargetRpcClient } from "../lib/target-rpc-client.mjs"; + +const slugIndex = process.argv.indexOf("--evidence"); +const evidence = evidenceDir(slugIndex < 0 ? "resume-effort" : process.argv[slugIndex + 1]); +const guard = guardRealAuth(); +const rows = []; +const scenarios = [ + { name: "orphan-recovery", expected: "xhigh", baseline: "xhigh", inline: ["xhigh"] }, + { name: "explicit-thinking", args: ["--thinking", "high"], expected: "high", baseline: "high", inline: ["xhigh", "high"] }, + // The shipped Astra map excludes off/minimal: an explicit off clamps to low. + { name: "explicit-off-clamped", args: ["--thinking", "off"], expected: "low", baseline: "low", inline: ["xhigh", "low"] }, + { name: "model-suffix", args: ["--model", "openai/gpt-6-astra:high"], expected: "high", baseline: "high", inline: ["xhigh", "high"] }, + { name: "intact-cache-baseline", intact: true, expected: "xhigh", baseline: "medium", inline: ["xhigh"] }, + { name: "intact-explicit-override", intact: true, args: ["--thinking", "high"], expected: "high", baseline: "medium", inline: ["xhigh", "high"] }, + { name: "later-selection", later: "high", expected: "high", baseline: "high", inline: ["xhigh", "high"] }, +]; + +for (const scenario of scenarios) { + const box = makeSandbox("resume-effort"); + const server = await startFakeModelServer({ turns: [{ text: "RESUME_EFFORT_QA_OK" }] }); + let client; + try { + writeMockModelsJson(box.agentDir, server, "openai-responses", { + id: "gpt-6-astra", reasoning: true, contextWindow: 600_000, maxTokens: 32_000, + }); + const settingsPath = join(box.agentDir, "settings.json"); + const settings = JSON.stringify({ + defaultProvider: "openai", defaultModel: "gpt-6-astra", defaultThinkingLevel: "minimal", + modelThinkingLevels: { "openai/gpt-6-astra": "low" }, + compaction: { enabled: false }, retry: { enabled: false }, + }); + writeFileSync(settingsPath, settings); + const manager = writeResumeEffortFixture(box.cwd, { provider: "openai", intact: scenario.intact }); + if (scenario.later) manager.appendThinkingLevelChange(scenario.later, { level: scenario.later, source: "explicit" }); + const sessionFile = manager.getSessionFile(); + assert.ok(sessionFile); + client = new TargetRpcClient({ + env: hermeticEnv(box.env), cwd: box.cwd, targetRoot: repoRoot(), + extraArgs: ["--session", sessionFile, "--no-extensions", "--no-skills", "--no-tools", ...(scenario.args ?? [])], + }); + const state = await client.send({ type: "get_state" }); + assert.equal(state.success, true); + assert.equal(state.data.thinkingLevel, scenario.expected); + assert.equal(state.data.model.id, "gpt-6-astra"); + assert.equal(state.data.sessionId, manager.getSessionId()); + // Subscribe before triggering the turn; no sleeps or polling. + const completed = client.waitFor((event) => event.message.type === "agent_end"); + const [ack] = await Promise.all([client.send({ type: "prompt", message: "SYNTHETIC_QA_PROMPT" }), completed]); + assert.equal(ack.success, true); + const reply = await client.send({ type: "get_last_assistant_text" }); + assert.equal(reply.data.text, "RESUME_EFFORT_QA_OK"); + const requests = server.requests.filter((request) => request.method === "POST"); + assert.equal(requests.length, 1); + const request = requests[0].body; + assert.equal(request.model, "gpt-6-astra"); + const inline = request.input.filter((item) => item.type === "configuration_update").map((item) => item.reasoning.effort); + assert.deepEqual(inline, scenario.inline); + assert.equal(request.reasoning?.effort, scenario.baseline); + await client.close(); + assert.equal(client.child.exitCode, 0); + if (!scenario.args) assert.equal(readFileSync(settingsPath, "utf8"), settings); + // Reopen the same on-disk history without overrides to prove durable precedence. + client = new TargetRpcClient({ + env: hermeticEnv(box.env), cwd: box.cwd, targetRoot: repoRoot(), + extraArgs: ["--session", sessionFile, "--no-extensions", "--no-skills", "--no-tools"], + }); + const reopened = await client.send({ type: "get_state" }); + assert.equal(reopened.success, true); + assert.equal(reopened.data.thinkingLevel, scenario.expected); + await client.close(); + assert.equal(client.child.exitCode, 0); + const row = { + name: scenario.name, pass: true, localEffort: state.data.thinkingLevel, + requestBaseline: request.reasoning?.effort, inlineEfforts: inline, + requests: requests.length, exitCode: client.child.exitCode, reopenedEffort: reopened.data.thinkingLevel, + }; + rows.push(row); + console.log(JSON.stringify(row)); + } finally { + if (client && client.child.exitCode === null) await client.close(); + await server.stop(); + box.cleanup(); + } +} +assert.equal(guard.assertUnchanged(), true); +writeFileSync(join(evidence, "resume-effort-qa.json"), `${JSON.stringify({ rows, realAuthUnchanged: true }, null, 2)}\n`); +console.log(`PASS: ${rows.length} real source CLI --session/RPC scenarios; evidence ${evidence}`); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a426652c..92c619671 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,8 @@ ### Fixed +- Resuming a GPT-6 Astra session restores a supported surviving configuration effort when missing ancestry makes the original thinking selection unreachable, instead of replacing it with remembered startup defaults. Explicit overrides and later thinking selections still win, and the resumed inline configuration agrees with the selected effort ([#1596](https://github.com/code-yeongyu/senpi/pull/1596) by [@rlaope](https://github.com/rlaope)). + ### Removed ## [2026.9.12-2] - 2026-09-12 diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 79c0a4a3a..5595be834 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-09-11 - Recover surviving session configuration effort on resume + +### What changed + +- `packages/coding-agent/src/core/sdk.ts`: restores supported reachable configuration effort before remembered defaults when no thinking selection survives. Uses the existing native GPT-6 Astra configuration scope and appends a final inline update when explicit or later selections disagree with historical configuration. + +### Why + +- Missing parent ancestry can make earlier thinking selections unreachable while leaving a session configuration update intact. Ignoring that update silently replaces the session effort with an unrelated startup default; retaining an older inline update can also override an explicit selection on the wire. + +### Why an extension could not handle it + +- `packages/coding-agent/src/core/sdk.ts` selects and persists resume effort before extension startup. The SDK must establish consistent local and inline state without repairing ancestry or changing global settings. + +### Expected merge conflict zones + +- `packages/coding-agent/src/core/sdk.ts`: initial thinking selection precedence and existing-session message restoration. Preserve the original reasoning baseline and append-only history semantics. + ## 2026-09-12 - `app.question.answer` keybinding and `/answer` command for the async ask-user widget (senpi#1623) ### What changed diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9cbd2b693..8f31f152f 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -338,6 +338,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingLevel = existingSession.thinkingLevel as ThinkingLevel; thinkingSelection = existingSession.thinkingSelection; } + // Match the native positional configuration-update scope in the Responses converter. + const hasApplicableConfiguration = + model?.reasoning && + model.id === "gpt-6-astra" && + (model.provider === "openai" || model.provider === "openai-codex") && + existingSession.configurationUpdate !== undefined; + if (thinkingLevel === undefined && hasExistingSession && model && hasApplicableConfiguration) { + // Missing ancestry can leave the configuration reachable but lose the original selection. + // Recover only supported levels, without inventing explicit-selection provenance. + thinkingLevel = getSupportedThinkingLevels(model).find( + (level) => level === existingSession.configurationUpdate?.effort, + ); + } if (thinkingLevel === undefined && model) { const remembered = settingsManager.getModelThinkingLevel(model.provider, model.id); if (remembered !== undefined) { @@ -502,9 +515,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Restore messages if session has existing data if (hasExistingSession) { agent.state.messages = existingSession.messages; - if (!hasThinkingEntry) { + if (!hasThinkingEntry || (hasApplicableConfiguration && thinkingLevel !== existingSession.thinkingLevel)) { sessionManager.appendThinkingLevelChange(thinkingLevel, thinkingSelection); } + if (hasApplicableConfiguration && existingSession.configurationUpdate?.effort !== thinkingLevel) { + // An explicit override or later thinking selection must also win over inline history. + // Append rather than rewrite the cache prefix or its original request baseline. + sessionManager.appendConfigurationUpdate(thinkingLevel); + agent.state.messages = sessionManager.buildSessionContext().messages; + } } else { // Save initial model and thinking level for new sessions so they can be restored on resume if (model) { diff --git a/packages/coding-agent/test/suite/resume-effort-fixtures.ts b/packages/coding-agent/test/suite/resume-effort-fixtures.ts new file mode 100644 index 000000000..5d5f3d2d2 --- /dev/null +++ b/packages/coding-agent/test/suite/resume-effort-fixtures.ts @@ -0,0 +1,58 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { type SessionEntry, type SessionHeader, SessionManager } from "../../src/core/session-manager.ts"; + +/** Synthetic history only: earlier selections exist on disk but may be unreachable. */ +export function writeResumeEffortFixture( + directory: string, + options: { provider?: string; modelId?: string; effort?: string; intact?: boolean } = {}, +): SessionManager { + const timestamp = "2026-09-01T00:00:00.000Z"; + const header: SessionHeader = { + type: "session", + version: 3, + id: "00000000-0000-4000-8000-000000000001", + timestamp, + cwd: directory, + }; + const entries: SessionEntry[] = [ + { type: "thinking_level_change", id: "baseline", parentId: null, timestamp, thinkingLevel: "medium" }, + { type: "thinking_level_change", id: "selection", parentId: "baseline", timestamp, thinkingLevel: "xhigh" }, + { + type: "thinking_level_change", + id: "other-branch", + parentId: "baseline", + timestamp, + thinkingLevel: "max", + }, + { + type: "message", + id: "reply", + parentId: options.intact ? "selection" : "missing-parent", + timestamp, + message: { + ...fauxAssistantMessage("SYNTHETIC_HISTORY", { timestamp: 1 }), + provider: options.provider ?? "openai-codex", + model: options.modelId ?? "gpt-6-astra", + }, + }, + { + type: "configuration_update", + id: "configuration", + parentId: "reply", + timestamp, + reasoning: { effort: options.effort ?? "xhigh" }, + }, + { + type: "message", + id: "prompt", + parentId: "configuration", + timestamp, + message: { role: "user", content: "SYNTHETIC_PROMPT", timestamp: 2 }, + }, + ]; + const path = join(directory, "resume-effort.jsonl"); + writeFileSync(path, `${[header, ...entries].map((entry) => JSON.stringify(entry)).join("\n")}\n`); + return SessionManager.open(path); +} diff --git a/packages/coding-agent/test/suite/rpc-worker-routing.test.ts b/packages/coding-agent/test/suite/rpc-worker-routing.test.ts index 1900c8479..008041458 100644 --- a/packages/coding-agent/test/suite/rpc-worker-routing.test.ts +++ b/packages/coding-agent/test/suite/rpc-worker-routing.test.ts @@ -151,61 +151,75 @@ it("starts real session workers under Node as well as Bun", async () => { } }, 60_000); -it("broadcasts question prompts across IPC and hydrates a late attachment", async () => { - const host = await startWorkerHost( - `export default function(pi) { +it.each(["answered", "comment-submitted"] as const)( + "broadcasts question prompts across IPC and hydrates a late attachment (%s)", + async (outcome) => { + const host = await startWorkerHost( + `export default function(pi) { pi.registerCommand("ask-question", {description: "question fixture", handler: async (_args, ctx) => { const result = await ctx.ui.question({requestId: "tool-question", waitForAnswer: true, timeoutMs: 60000, questions: ["q1", "q2"].map(id => ({id, header: id, question: id, options: [{label: "A"}, {label: "B"}], multiSelect: false}))}); ctx.ui.notify(JSON.stringify(result)); }}); }`, - { socket: true }, - ); - try { - const a = await host.connect(); - const b = await host.connect(); - const opened = await a.request({ type: "open_session", cwd: host.cwd, capabilities: ["question"] }); - const sessionId = opened.data?.sessionId; - await a.request({ type: "set_client_info", sessionId, capabilities: ["question"] }); - await b.request({ type: "open_session", cwd: host.cwd, sessionPath: opened.data?.state?.sessionFile }); - const qa = a.wait((r) => r.type === "extension_ui_request" && r.method === "question"); - const qb = b.wait((r) => r.type === "extension_ui_request" && r.method === "question"); - const prompt = a.request({ type: "prompt", sessionId, message: "/ask-question" }); - const frame = await qa; - expect((await qb).id).toBe(frame.id); - const c = await host.connect(); - const replay = c.wait((r) => r.type === "extension_ui_request" && r.method === "question"); - const attached = await c.request({ - type: "open_session", - cwd: host.cwd, - sessionPath: opened.data?.state?.sessionFile, - }); - expect(attached.data?.state?.pendingQuestions).toEqual([expect.objectContaining({ id: frame.id })]); - expect((await replay).id).toBe(frame.id); - const updated = a.wait((r) => r.type === "question_updated"); - b.send({ type: "extension_ui_progress", sessionId, id: frame.id, answers: { q1: { selected: ["A"] } } }); - expect((await updated).remainingMs).toBeGreaterThan(0); - // A submission with neither an answer nor a comment carries no decision: it is - // rejected and the question stays pending for every attachment. - const incomplete = b.wait((r) => r.error === "question_incomplete"); - b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: {}, comment: "" }); - await incomplete; - const ra = a.wait((r) => r.type === "question_resolved"); - const rb = b.wait((r) => r.type === "question_resolved"); - // A partial answer map is a decision on every surface (ask-user/pending.ts): it - // resolves the question as answered and reports the ids left unanswered. - b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: { q1: { selected: ["A"] } } }); - const resolution = { outcome: "answered", answers: { q1: { selected: ["A"] } }, unanswered: ["q2"] }; - expect(await ra).toMatchObject(resolution); - expect(await rb).toMatchObject(resolution); - expect((await prompt).success).toBe(true); - const late = b.wait((r) => r.error === "question_already_resolved"); - b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: {}, comment: "do it" }); - await late; - expect(c.records.filter((r) => r.method === "question")).toHaveLength(1); - const state = await c.request({ type: "get_state", sessionId }); - expect(state.data?.pendingQuestions).toEqual([]); - } finally { - await host.dispose(); - } -}, 60_000); + { socket: true }, + ); + try { + const a = await host.connect(); + const b = await host.connect(); + const opened = await a.request({ type: "open_session", cwd: host.cwd, capabilities: ["question"] }); + const sessionId = opened.data?.sessionId; + await a.request({ type: "set_client_info", sessionId, capabilities: ["question"] }); + await b.request({ type: "open_session", cwd: host.cwd, sessionPath: opened.data?.state?.sessionFile }); + const qa = a.wait((r) => r.type === "extension_ui_request" && r.method === "question"); + const qb = b.wait((r) => r.type === "extension_ui_request" && r.method === "question"); + const prompt = a.request({ type: "prompt", sessionId, message: "/ask-question" }); + const frame = await qa; + expect((await qb).id).toBe(frame.id); + const c = await host.connect(); + const replay = c.wait((r) => r.type === "extension_ui_request" && r.method === "question"); + const attached = await c.request({ + type: "open_session", + cwd: host.cwd, + sessionPath: opened.data?.state?.sessionFile, + }); + expect(attached.data?.state?.pendingQuestions).toEqual([expect.objectContaining({ id: frame.id })]); + expect((await replay).id).toBe(frame.id); + const updated = a.wait((r) => r.type === "question_updated"); + b.send({ type: "extension_ui_progress", sessionId, id: frame.id, answers: { q1: { selected: ["A"] } } }); + expect((await updated).remainingMs).toBeGreaterThan(0); + const incomplete = b.wait((r) => r.error === "question_incomplete"); + b.send({ + type: "extension_ui_response", + sessionId, + id: frame.id, + answers: {}, + comment: "", + }); + await incomplete; + const resolved = [a, b, c].map((peer) => + peer.wait((r) => r.type === "question_resolved" && r.id === frame.id), + ); + const answers = outcome === "answered" ? { q1: { selected: ["A"] } } : {}; + const comment = outcome === "answered" ? "" : "do it"; + b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers, comment }); + for (const record of await Promise.all(resolved)) { + expect(record).toMatchObject({ + outcome, + answers, + comment, + unanswered: outcome === "answered" ? ["q2"] : ["q1", "q2"], + }); + } + expect((await prompt).success).toBe(true); + const late = b.wait((r) => r.error === "question_already_resolved"); + b.send({ type: "extension_ui_response", sessionId, id: frame.id, answers: {} }); + await late; + expect(c.records.filter((r) => r.method === "question")).toHaveLength(1); + const state = await c.request({ type: "get_state", sessionId }); + expect(state.data?.pendingQuestions).toEqual([]); + } finally { + await host.dispose(); + } + }, + 60_000, +); diff --git a/packages/coding-agent/test/suite/sdk-resume-effort.test.ts b/packages/coding-agent/test/suite/sdk-resume-effort.test.ts new file mode 100644 index 000000000..f3d42a743 --- /dev/null +++ b/packages/coding-agent/test/suite/sdk-resume-effort.test.ts @@ -0,0 +1,222 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentSession } from "../../src/core/agent-session.ts"; +import { type CreateAgentSessionOptions, createAgentSession } from "../../src/core/sdk.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { SettingsManager } from "../../src/core/settings-manager.ts"; +import { createTestResourceLoader } from "../utilities.ts"; +import { createHarness, type Harness } from "./harness.ts"; +import { writeResumeEffortFixture } from "./resume-effort-fixtures.ts"; + +const harnesses: Harness[] = []; +const sessions: AgentSession[] = []; +afterEach(() => { + for (const session of sessions.splice(0)) session.dispose(); + for (const harness of harnesses.splice(0)) harness.cleanup(); +}); + +async function fixture(options: Parameters[1] = {}) { + const provider = options.provider ?? "openai-codex"; + const modelId = options.modelId ?? "gpt-6-astra"; + const harness = await createHarness({ + provider, + models: [{ id: modelId, reasoning: true, contextWindow: 600_000, maxTokens: 32_000 }], + settings: { defaultThinkingLevel: "minimal", compaction: { enabled: false } }, + }); + harnesses.push(harness); + harness.settingsManager.setModelThinkingLevel(provider, modelId, "low"); + const manager = writeResumeEffortFixture(harness.tempDir, options); + const resume = async (overrides: Partial = {}) => { + const { session } = await createAgentSession({ + cwd: harness.tempDir, + agentDir: harness.tempDir, + authStorage: harness.authStorage, + modelRegistry: harness.modelRegistry, + settingsManager: harness.settingsManager, + sessionManager: manager, + resourceLoader: createTestResourceLoader(), + ...overrides, + }); + sessions.push(session); + return session; + }; + return { harness, manager, resume }; +} + +function inlineEfforts(session: AgentSession): string[] { + return session.messages.flatMap((message) => (message.role === "configurationUpdate" ? [message.effort] : [])); +} + +describe("SDK resume effort", () => { + it.each(["openai", "openai-codex"])( + "recovers surviving configuration before remembered defaults on %s", + async (provider) => { + // Given missing ancestry and an unrelated branch with a different selection. + const { harness, manager, resume } = await fixture({ provider }); + expect(manager.getBranch().some((entry) => entry.type === "thinking_level_change")).toBe(false); + expect(manager.buildSessionContext().thinkingLevel).toBe("off"); + + // When the real SDK resumes the persisted session. + const session = await resume(); + + // Then local state, durable recovery and inline effort agree, without changing defaults. + expect(session.thinkingLevel).toBe("xhigh"); + expect(inlineEfforts(session)).toEqual(["xhigh"]); + expect(manager.buildSessionContext().thinkingLevel).toBe("xhigh"); + expect(harness.settingsManager.getModelThinkingLevel(provider, "gpt-6-astra")).toBe("low"); + expect(harness.settingsManager.getDefaultThinkingLevel()).toBe("minimal"); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Missing persisted fixture"); + const reopened = SessionManager.open(sessionFile); + expect((await resume({ sessionManager: reopened })).thinkingLevel).toBe("xhigh"); + }, + ); + + it.each(["high", "off"] satisfies ThinkingLevel[])( + "honors explicit %s and reconciles inline effort", + async (thinkingLevel) => { + // Given orphaned history with xhigh in its inline configuration. + const { resume } = await fixture(); + // When a caller explicitly selects another level (the --thinking SDK seam). + const session = await resume({ thinkingLevel }); + // Then the final positional update does not override that choice on the wire. + expect(session.thinkingLevel).toBe(thinkingLevel); + expect(inlineEfforts(session)).toEqual(["xhigh", thinkingLevel]); + }, + ); + + it("honors a model-suffix selection instead of recovering configuration", async () => { + // Given the CLI's pre-resolved model suffix selection. + const { harness, resume } = await fixture(); + // When that explicit model/effort is passed to the SDK. + const session = await resume({ + model: harness.getModel(), + thinkingLevel: "high", + thinkingSelection: { level: "high", source: "explicit" }, + }); + // Then it wins both locally and in the inline stream. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session).at(-1)).toBe("high"); + }); + + it("persists an explicit override of intact configuration for subsequent resumes", async () => { + // Given an intact xhigh selection/configuration resumed with explicit high. + const { resume } = await fixture({ intact: true }); + await resume({ thinkingLevel: "high" }); + // When resumed again without CLI overrides. + const session = await resume(); + // Then the override remains the durable selection, not just a transient inline update. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session)).toEqual(["xhigh", "high"]); + expect(session.agent.state.reasoningBaseline).toBe("medium"); + }); + + it("preserves intact thinking history and its cache baseline", async () => { + // Given an intact medium -> xhigh branch and an unrelated max branch. + const { resume } = await fixture({ intact: true }); + // When resumed without overrides. + const session = await resume(); + // Then the branch selection and original baseline remain intact. + expect(session.thinkingLevel).toBe("xhigh"); + expect(session.agent.state.reasoningBaseline).toBe("medium"); + expect(inlineEfforts(session)).toEqual(["xhigh"]); + }); + + it("keeps a later genuine thinking selection ahead of older configuration", async () => { + // Given reachable high after the surviving xhigh configuration. + const { manager, resume } = await fixture(); + manager.appendThinkingLevelChange("high", { level: "high", source: "explicit" }); + // When resumed without overrides. + const session = await resume(); + // Then both local and effective inline state reflect the later selection. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session)).toEqual(["xhigh", "high"]); + }); + + it("recovers configuration before the global default when no per-model memory exists", async () => { + // Given global low with no remembered per-model level. + const { resume } = await fixture(); + const settingsManager = SettingsManager.inMemory({ defaultThinkingLevel: "low" }); + // When resumed. + const session = await resume({ settingsManager }); + // Then the session's surviving effort wins. + expect(session.thinkingLevel).toBe("xhigh"); + }); + + it("uses remembered defaults when neither selection nor configuration survives", async () => { + // Given the orphan branch before its configuration update. + const { manager, resume } = await fixture(); + manager.branch("reply"); + // When resumed. + const session = await resume(); + // Then the existing startup fallback remains unchanged. + expect(session.thinkingLevel).toBe("low"); + expect(inlineEfforts(session)).toEqual([]); + }); + + it("does not apply Astra configuration to an explicit different model", async () => { + // Given Astra history but an explicit replacement model without remembered effort. + const { harness, resume } = await fixture(); + const model = { ...harness.getModel(), id: "replacement-model" }; + // When the SDK receives the model override. + const session = await resume({ model }); + // Then its global default wins, not the prior model's configuration. + expect(session.model?.id).toBe("replacement-model"); + expect(session.thinkingLevel).toBe("minimal"); + }); + + it("does not recover configuration when reasoning is disabled on the model", async () => { + // Given an explicit model capability override. + const { harness, resume } = await fixture(); + // When the non-reasoning model resumes. + const session = await resume({ model: { ...harness.getModel(), reasoning: false } }); + // Then reasoning stays off. + expect(session.thinkingLevel).toBe("off"); + }); + + it("preserves a genuine effort change made after recovery on the next resume", async () => { + // Given a recovered session followed by a real session-only user selection. + const { resume } = await fixture(); + const recovered = await resume(); + recovered.setSessionThinkingLevel("high"); + // When the updated session resumes again. + const session = await resume(); + // Then the later selection wins locally and inline, with no duplicate update. + expect(session.thinkingLevel).toBe("high"); + expect(inlineEfforts(session)).toEqual(["xhigh", "high"]); + }); + + it.each(["invalid-effort", "", "MAX"])("does not recover invalid effort %j", async (effort) => { + // Given an unrecognized persisted effort string. + const { resume } = await fixture({ effort }); + // When resumed. + const session = await resume(); + // Then the remembered setting, not an invented/clamped recovery, wins. + expect(session.thinkingLevel).toBe("low"); + }); + + it("does not recover an effort excluded by the restored model's supported levels", async () => { + // Given a model override that does not support xhigh. + const { harness, resume } = await fixture(); + const model = { ...harness.getModel(), thinkingLevelMap: { low: "low", high: "high" } }; + // When that model is resumed. + const session = await resume({ model }); + // Then a supported remembered default wins instead of clamping xhigh to high. + expect(session.thinkingLevel).toBe("low"); + }); + + it.each([ + { provider: "faux", modelId: "gpt-6-astra" }, + { provider: "openai-codex", modelId: "gpt-5.6" }, + { provider: "openai", modelId: "gpt-6-astra-fast" }, + ])("ignores configuration outside the native model scope: $provider/$modelId", async (options) => { + // Given a model to which native configuration updates do not apply. + const { manager, resume } = await fixture(options); + const updatesBefore = manager.getEntries().filter((entry) => entry.type === "configuration_update"); + // When resumed. + const session = await resume(); + // Then defaults still apply and no model-inapplicable reconciliation is appended. + expect(session.thinkingLevel).toBe("low"); + expect(manager.getEntries().filter((entry) => entry.type === "configuration_update")).toEqual(updatesBefore); + }); +});