Merge PR #1865: fix(session): keep auto thinking mode active across session resume (@msimon)

This commit is contained in:
can1357
2026-06-21 17:09:50 +02:00
7 changed files with 142 additions and 16 deletions
+2
View File
@@ -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)).
+3 -1
View File
@@ -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;
@@ -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 {
@@ -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<string, string>;
@@ -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<string, string> = {};
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,
@@ -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 {
@@ -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;
@@ -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 () => {