Merge PR #1865: fix(session): keep auto thinking mode active across session resume (@msimon)
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user