diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3da20ebf2..206ae9da2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1924,6 +1924,8 @@ - Fixed in-session `/resume` to restore both the last user-selected temporary model and persisted plan/goal mode state instead of falling back to the default model with plan mode off. - Fixed the `/resume` session picker overflowing short viewports: the visible window was hardcoded to 5 entries (and assumed 3 lines each), but titled sessions render 4 lines, so on a typical-height terminal the picker's header and search box scrolled off the top and the first entry was hidden until you scrolled the terminal up. The visible-entry count is now derived from the live terminal height (budgeting the worst-case 4-line titled entry plus the picker's chrome), so the whole picker fits the viewport and grows on taller terminals. - Fixed the Agent Control Center and Extension Control Center dashboards overflowing the terminal: they were mounted inline below the chat transcript, so the combined height exceeded the viewport — the tab bar and controls scrolled off the top into native scrollback, and every state change yanked the view back to the bottom. Both dashboards now render as full-screen overlays sized to the live terminal height (`process.stdout.rows`), re-fit on resize, fill the viewport, and reserve space for the footer keyhints so the controls stay visible. +- Fixed `auto` thinking mode being silently dropped when a session is resumed (`--continue`/`--resume`/in-app switch). The session log persisted only the resolved per-turn effort, not the `auto` selector, so resume froze the session at the last concrete level and never reclassified again. The log now records the configured selector (`auto` vs concrete) alongside the resolved effort, so resumed `auto` sessions stay in auto (shown as pending until the next turn reclassifies) and manual concrete pins still restore as concrete — including a pin whose level matches the effort `auto` had just resolved to. +- Fixed transcript scrollback stability on terminals with eager erase risk so completed assistant messages remain stable while new streaming lines are rendering - Fixed Ctrl+R history search results to remain globally sorted by prompt recency after merging FTS prefix matches with substring fallback matches. - Fixed Exa web search with no stored or environment credential to use the public Exa MCP fallback again, preserving the auth storage → `EXA_API_KEY` → `mcp.exa.ai` resolution order ([#1860](https://github.com/can1357/oh-my-pi/issues/1860)). - Fixed ACP plan-mode writes to `local://PLAN.md` so session-local plan artifacts are written to OMP's local artifact root instead of being routed through the editor `writeTextFile` bridge, avoiding Zen's `Internal error` and making the plan readable after creation ([#1863](https://github.com/can1357/oh-my-pi/issues/1863)). diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 452cd54b9..a2640858a 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1288,7 +1288,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => { let level = options.thinkingLevel; if (level === undefined && hasExistingSession && hasThinkingEntry) { - level = parseThinkingLevel(existingSession.thinkingLevel); + level = + parseConfiguredThinkingLevel(existingSession.configuredThinkingLevel) ?? + parseThinkingLevel(existingSession.thinkingLevel); } if (level === undefined && !hasThinkingEntry && restoredSessionThinkingLevel !== undefined) { level = restoredSessionThinkingLevel; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 110970847..8637a56cb 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -6844,7 +6844,7 @@ export class AgentSession { this.#pendingNextTurnMessages = []; this.#scheduledHiddenNextTurnGeneration = undefined; - this.sessionManager.appendThinkingLevelChange(this.thinkingLevel); + this.sessionManager.appendThinkingLevelChange(this.thinkingLevel, this.configuredThinkingLevel()); this.sessionManager.appendServiceTierChange(this.serviceTier ?? null); if (nextDiscoverySessionToolNames) { await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false }); @@ -7243,16 +7243,20 @@ export class AgentSession { return; } + const wasAuto = this.#autoThinking; this.#autoThinking = false; this.#autoResolvedLevel = undefined; const effectiveLevel = resolveThinkingLevelForModel(this.model, level); - const isChanging = effectiveLevel !== this.#thinkingLevel; + // Leaving auto must persist even when the resolved effort is unchanged (e.g. + // auto resolved to medium, then the user pins medium): otherwise the latest + // session entry keeps `configured: "auto"` and resume re-enables auto. + const isChanging = wasAuto || effectiveLevel !== this.#thinkingLevel; this.#thinkingLevel = effectiveLevel; this.#applyThinkingLevelToAgent(effectiveLevel); if (isChanging) { - this.sessionManager.appendThinkingLevelChange(effectiveLevel); + this.sessionManager.appendThinkingLevelChange(effectiveLevel, effectiveLevel); if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) { this.settings.set("defaultThinkingLevel", effectiveLevel); } @@ -7341,7 +7345,7 @@ export class AgentSession { this.#thinkingLevel = effort; this.#applyThinkingLevelToAgent(effort); if (shouldPersistResolution) { - this.sessionManager.appendThinkingLevelChange(effort); + this.sessionManager.appendThinkingLevelChange(effort, AUTO_THINKING); } this.#emit({ type: "thinking_level_changed", @@ -11617,18 +11621,26 @@ export class AgentSession { .some(entry => entry.type === "service_tier_change"); const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel")); const configuredServiceTier = this.settings.get("serviceTier"); - // Session log entries store only concrete levels. When `auto` has resolved - // for a turn, the persisted context may already carry that concrete level - // even if the branch scan races a just-flushed thinking entry under isolated - // parallel test workers. Prefer the concrete context value in that case; - // otherwise keep the configured `auto` selector so fresh sessions still - // classify their first turn. + // Restore the thinking selector. Each change persists the configured + // selector (`auto` or a concrete level), so prefer it: an `auto` session + // resumes in auto mode (reclassifying the next turn) instead of freezing at + // the last resolved level. Entries written before the `configured` field + // existed fall back to the concrete level (legacy pin-on-resume behavior). + // With no thinking entry, fall back to the global default so fresh sessions + // still classify their first turn. + const restoredConfigured = sessionContext.configuredThinkingLevel; const restoredThinkingLevel: ConfiguredThinkingLevel | undefined = hasThinkingEntry || (defaultThinkingLevel === AUTO_THINKING && sessionContext.thinkingLevel !== "off") - ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) + ? restoredConfigured === AUTO_THINKING + ? AUTO_THINKING + : (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel; if (restoredThinkingLevel === AUTO_THINKING) { this.#autoThinking = true; + // Resume in auto (pending) like a fresh auto session: the next user + // turn reclassifies. We intentionally do not seed the last resolved + // effort, so the cold (--continue) and in-app switch paths display + // identically as `auto` until then. this.#autoResolvedLevel = undefined; this.#thinkingLevel = resolveProvisionalAutoLevel(this.model); } else { diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index 08d4339e7..2d9fa3932 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -7,6 +7,8 @@ import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } export interface SessionContext { messages: AgentMessage[]; thinkingLevel?: string; + /** Configured thinking selector (`"auto"` or a concrete level) from the latest change. */ + configuredThinkingLevel?: string; serviceTier?: ServiceTier; /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */ models: Record; @@ -134,6 +136,7 @@ export function buildSessionContext( // Extract settings and find compaction let thinkingLevel: string | undefined = "off"; + let configuredThinkingLevel: string | undefined; let serviceTier: ServiceTier | undefined; const models: Record = {}; let compaction: CompactionEntry | null = null; @@ -154,6 +157,7 @@ export function buildSessionContext( for (const entry of path) { if (entry.type === "thinking_level_change") { thinkingLevel = entry.thinkingLevel ?? "off"; + configuredThinkingLevel = entry.configured ?? entry.thinkingLevel ?? undefined; } else if (entry.type === "model_change") { // New format: { model: "provider/id", role?: string } if (entry.model) { @@ -388,6 +392,7 @@ export function buildSessionContext( messages, cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined, thinkingLevel, + configuredThinkingLevel, serviceTier, models, injectedTtsrRules, diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index 5b180c9ef..b6ef5c77c 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -37,6 +37,12 @@ export interface SessionMessageEntry extends SessionEntryBase { export interface ThinkingLevelChangeEntry extends SessionEntryBase { type: "thinking_level_change"; thinkingLevel?: string | null; + /** + * The user-configured selector at the time of this change: `"auto"` when auto + * mode was active, otherwise the concrete level. Absent on entries written + * before auto-mode persistence existed; readers fall back to `thinkingLevel`. + */ + configured?: string | null; } export interface ModelChangeEntry extends SessionEntryBase { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index bc9e0c87c..925d5799f 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -293,6 +293,7 @@ export type ReadonlySessionManager = Pick< | "putBlobSync" >; + interface SessionManagerStateSnapshot { cwd: string; sessionDir: string; @@ -1158,11 +1159,13 @@ export class SessionManager { return entry.id; } - appendThinkingLevelChange(thinkingLevel?: string): string { + /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ + appendThinkingLevelChange(thinkingLevel?: string, configured?: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", ...this.#freshEntryFields(), thinkingLevel: thinkingLevel ?? null, + configured: configured ?? null, }; this.#recordEntry(entry); return entry.id; diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 569a7bdaa..12536f42b 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -272,7 +272,7 @@ describe("AgentSession role model thinking behavior", () => { expect(session.agent.state.thinkingLevel).toBe(Effort.Medium); }); - it("restores the last resolved auto effort instead of pending auto on resume", async () => { + it("keeps auto active on resume (pending until the next turn reclassifies)", async () => { const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); const agent = new Agent({ initialState: { @@ -310,11 +310,107 @@ describe("AgentSession role model thinking behavior", () => { expect(sessionFile).toBeDefined(); await session.sessionManager.flush(); + expect(await session.switchSession(sessionFile!)).toBe(true); + expect(session.isAutoThinking).toBe(true); + expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING); + // Resumes in auto and pending — not frozen to the last resolved level, and + // not pre-seeded; the next user turn reclassifies. + expect(session.autoResolvedThinkingLevel()).toBeUndefined(); + }); + + it("keeps a manual concrete pin (not auto) on resume even when the global default is auto", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const agent = new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + thinkingLevel: resolveProvisionalAutoLevel(model), + }, + }); + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-manual-resume.db")); + authStorages.push(authStorage); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-manual-resume.yml")); + const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + sessionSettings = Settings.isolated(); + sessionSettings.set("defaultThinkingLevel", AUTO_THINKING); + session = new AgentSession({ + agent, + sessionManager, + settings: sessionSettings, + modelRegistry, + thinkingLevel: AUTO_THINKING, + }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Medium); + + // User pins a concrete level mid-session; it must survive resume as-is and + // must not be reinterpreted as `auto` just because the global default is auto. + session.setThinkingLevel(Effort.Low); + expect(session.isAutoThinking).toBe(false); + await session.prompt("Pinned concrete turn"); + expect(classifierSpy).not.toHaveBeenCalled(); + session.sessionManager.appendMessage(createAssistantMessage("done")); + + const sessionFile = session.sessionFile; + expect(sessionFile).toBeDefined(); + await session.sessionManager.flush(); + + expect(await session.switchSession(sessionFile!)).toBe(true); + expect(session.isAutoThinking).toBe(false); + expect(session.configuredThinkingLevel()).toBe(Effort.Low); + expect(session.thinkingLevel).toBe(Effort.Low); + }); + + it("persists a concrete pin that matches the auto-resolved effort so resume stays concrete", async () => { + const model = getAnthropicModelOrThrow("claude-sonnet-4-5"); + const agent = new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + thinkingLevel: resolveProvisionalAutoLevel(model), + }, + }); + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-pin-eq.db")); + authStorages.push(authStorage); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-pin-eq.yml")); + const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + sessionSettings = Settings.isolated(); + sessionSettings.set("defaultThinkingLevel", AUTO_THINKING); + session = new AgentSession({ + agent, + sessionManager, + settings: sessionSettings, + modelRegistry, + thinkingLevel: AUTO_THINKING, + }); + vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Medium); + + // Auto resolves to medium. + await session.prompt("Implement a focused parser fix"); + expect(session.autoResolvedThinkingLevel()).toBe(Effort.Medium); + + // User then pins the *same* effort: selector changes auto -> medium even though + // the effort is unchanged, so it must persist as a concrete pin (entry + + // defaultThinkingLevel), not silently stay `configured: "auto"`. + session.setThinkingLevel(Effort.Medium, true); + expect(session.isAutoThinking).toBe(false); + expect(sessionSettings.get("defaultThinkingLevel")).toBe(Effort.Medium); + session.sessionManager.appendMessage(createAssistantMessage("done")); + + const sessionFile = session.sessionFile; + expect(sessionFile).toBeDefined(); + await session.sessionManager.flush(); + expect(await session.switchSession(sessionFile!)).toBe(true); expect(session.isAutoThinking).toBe(false); expect(session.configuredThinkingLevel()).toBe(Effort.Medium); - expect(session.thinkingLevel).toBe(Effort.Medium); - expect(session.agent.state.thinkingLevel).toBe(Effort.Medium); }); it("falls back to a concrete auto level when classification fails", async () => {