From f209087cfda41ad004c23fdb2583a6e61fef3866 Mon Sep 17 00:00:00 2001 From: Alexander Kirilin Date: Sat, 27 Jun 2026 15:41:06 -0400 Subject: [PATCH] fix(omp): keep moved sessions resumable --- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/config/provider-globals.ts | 25 +++++++ .../modes/controllers/command-controller.ts | 41 ++---------- .../src/modes/interactive-mode.ts | 21 +----- .../src/session/session-manager.ts | 5 +- .../src/slash-commands/builtin-registry.ts | 28 ++------ .../coding-agent/test/acp-builtins.test.ts | 29 +++++++- .../test/config/provider-globals.test.ts | 31 +++++++++ .../modes/controllers/move-command.test.ts | 67 +++++++++++++++++++ .../test/session-manager/move-to.test.ts | 17 +++++ 10 files changed, 188 insertions(+), 80 deletions(-) create mode 100644 packages/coding-agent/src/config/provider-globals.ts create mode 100644 packages/coding-agent/test/config/provider-globals.test.ts create mode 100644 packages/coding-agent/test/modes/controllers/move-command.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c98881efa..73a44787a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/move ` to relocate the current session file and artifacts into the target directory's resume bucket instead of switching to a new empty target session, so `/resume` from the target directory shows the moved conversation. + ## [16.2.2] - 2026-06-27 ### Added diff --git a/packages/coding-agent/src/config/provider-globals.ts b/packages/coding-agent/src/config/provider-globals.ts new file mode 100644 index 000000000..9fdd5d4a3 --- /dev/null +++ b/packages/coding-agent/src/config/provider-globals.ts @@ -0,0 +1,25 @@ +import * as imageGen from "../tools/image-gen"; +import * as webSearch from "../web/search"; + +interface ProviderGlobalSettings { + get(path: "providers.webSearchExclude"): unknown; + get(path: "providers.webSearch"): unknown; + get(path: "providers.image"): unknown; +} + +export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void { + const excludedWebSearchProviders = settings.get("providers.webSearchExclude"); + if (Array.isArray(excludedWebSearchProviders)) { + webSearch.setExcludedSearchProviders(excludedWebSearchProviders.filter(webSearch.isSearchProviderId)); + } + + const webSearchProvider = settings.get("providers.webSearch"); + if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) { + webSearch.setPreferredSearchProvider(webSearchProvider); + } + + const imageProvider = settings.get("providers.image"); + if (imageGen.isImageProviderPreference(imageProvider)) { + imageGen.setPreferredImageProvider(imageProvider); + } +} diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 67f0463aa..dbc2baa17 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -42,7 +42,6 @@ import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage"; import type { CompactMode } from "../../session/compact-modes"; import type { NewSessionOptions } from "../../session/session-entries"; -import { SessionManager } from "../../session/session-manager"; import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types"; import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account"; import { outputMeta } from "../../tools/output-meta"; @@ -914,13 +913,13 @@ export class CommandController { } /** - * `/move` — switch to a fresh empty session in a different directory. + * `/move` — relocate the current session to a different directory. * * With no `targetPath` (TUI only), opens an autocomplete overlay so the user * can pick or type a directory. With a `targetPath`, resolves it directly. * If the target directory does not exist, the user is asked whether to create - * it. A brand-new empty session is then started in the target directory and - * the current session is left behind (resumable via `/resume`). + * it. The active session file and artifacts are moved into the target + * directory's session bucket so `/resume` from that directory can find it. */ async handleMoveCommand(targetPath?: string): Promise { if (this.ctx.session.isStreaming) { @@ -982,47 +981,17 @@ export class CommandController { } } - let newSessionFile: string | undefined; try { - // Create a fresh empty session file in the target directory's session - // folder, then switch to it. The current session is left behind and - // remains resumable via /resume. - newSessionFile = SessionManager.createEmptySessionFile(resolvedPath); - const switched = await this.ctx.session.switchSession(newSessionFile); - if (!switched) { - await this.ctx.sessionManager.dropSession(newSessionFile); - return; - } + await this.ctx.sessionManager.moveTo(resolvedPath); } catch (err) { - if (newSessionFile) { - try { - await this.ctx.sessionManager.dropSession(newSessionFile); - } catch (dropErr) { - this.ctx.showError( - `Move failed: ${err instanceof Error ? err.message : String(err)}; failed to remove empty session: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`, - ); - return; - } - } this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`); return; } - this.ctx.session.markMovedFromEmptySessionFile(newSessionFile!); await this.ctx.applyCwdChange(resolvedPath); - - this.ctx.chatContainer.clear(); - this.ctx.pendingMessagesContainer.clear(); - this.ctx.compactionQueuedMessages = []; - this.ctx.streamingComponent = undefined; - this.ctx.streamingMessage = undefined; - this.ctx.pendingTools.clear(); - this.ctx.statusLine.invalidate(); - this.ctx.statusLine.setSessionStartTime(Date.now()); - this.ctx.updateEditorTopBorder(); this.ctx.updateEditorBorderColor(); await this.ctx.reloadTodos(); - this.ctx.ui.requestRender(true, { clearScrollback: true }); + this.ctx.ui.requestRender(); this.ctx.present([ new Spacer(1), diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e47ff23f7..913837cc3 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -53,6 +53,7 @@ import { reset as resetCapabilities } from "../capability"; import type { CollabGuestLink } from "../collab/guest"; import type { CollabHost } from "../collab/host"; import { KeybindingsManager } from "../config/keybindings"; +import { applyProviderGlobalsFromSettings } from "../config/provider-globals"; import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings"; import { clearClaudePluginRootsCache } from "../discovery/helpers"; import type { @@ -100,7 +101,6 @@ import { STTController, type SttState } from "../stt"; import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt"; import { formatTaskId } from "../task/render"; import type { LspStartupServerInfo } from "../tools"; -import { isImageProviderPreference, setPreferredImageProvider } from "../tools/image-gen"; import { normalizeLocalScheme } from "../tools/path-utils"; import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils"; import { setAutoQaConsentHandler } from "../tools/report-tool-issue"; @@ -113,12 +113,6 @@ import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color"; import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator"; -import { - isSearchProviderId, - isSearchProviderPreference, - setExcludedSearchProviders, - setPreferredSearchProvider, -} from "../web/search"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import { ChatBlock, type ChatBlockHost } from "./components/chat-block"; @@ -1037,18 +1031,7 @@ export class InteractiveMode implements InteractiveModeContext { // module-level search/image provider state reflects the destination // project's configuration. Without this, the previous project's // exclusions leak and newly-excluded providers are still used. - const excludedWebSearchProviders = settings.get("providers.webSearchExclude"); - if (Array.isArray(excludedWebSearchProviders)) { - setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId)); - } - const webSearchProvider = settings.get("providers.webSearch"); - if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) { - setPreferredSearchProvider(webSearchProvider); - } - const imageProvider = settings.get("providers.image"); - if (isImageProviderPreference(imageProvider)) { - setPreferredImageProvider(imageProvider); - } + applyProviderGlobalsFromSettings(settings); } // Re-warm plugin roots, capabilities, slash commands, and the ssh tool so // the next prompt sees everything scoped to the new project directory. diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 041cedafa..9f17c184c 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1526,9 +1526,8 @@ export class SessionManager { /** * Create a fresh empty session file in the default session directory for * `cwd`, writing only the session header. The returned path can be passed to - * `setSessionFile` / `AgentSession.switchSession` to start a new empty - * session in that directory. Used by `/move` to switch projects without - * dragging the current conversation along. + * `setSessionFile` / `AgentSession.switchSession` when a caller explicitly + * needs a brand-new persisted session at a cwd-derived path. */ static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string { const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage); diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 83cce6db9..9f97085ac 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -6,6 +6,7 @@ import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui"; import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils"; import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; import { CollabHost } from "../collab/host"; +import { applyProviderGlobalsFromSettings } from "../config/provider-globals"; import type { SettingPath, SettingValue } from "../config/settings"; import { settings } from "../config/settings"; import { @@ -29,7 +30,6 @@ import type { InteractiveModeContext } from "../modes/types"; import type { AgentSession, FreshSessionResult } from "../session/agent-session"; import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes"; import { resolveResumableSession } from "../session/session-listing"; -import { SessionManager } from "../session/session-manager"; import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; import { expandTilde, resolveToCwd } from "../tools/path-utils"; import { urlHyperlinkAlways } from "../tui"; @@ -1593,8 +1593,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ }, { name: "move", - description: "Switch to a fresh session in a different directory", - acpDescription: "Start a fresh session in a different directory", + description: "Move the current session to a different directory", + acpDescription: "Move the current session to a different directory", inlineHint: "[]", allowArgs: true, handle: async (command, runtime) => { @@ -1609,32 +1609,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ } catch { return usage(`Directory does not exist: ${resolvedPath}`, runtime); } - let newSessionFile: string | undefined; try { - newSessionFile = SessionManager.createEmptySessionFile(resolvedPath); - const switched = await runtime.session.switchSession(newSessionFile); - if (!switched) { - await runtime.sessionManager.dropSession(newSessionFile); - return usage("Move cancelled.", runtime); - } + await runtime.sessionManager.moveTo(resolvedPath); } catch (err) { - if (newSessionFile) { - try { - await runtime.sessionManager.dropSession(newSessionFile); - } catch (dropErr) { - return usage( - `Move failed: ${errorMessage(err)}; failed to remove empty session: ${errorMessage(dropErr)}`, - runtime, - ); - } - } return usage(`Move failed: ${errorMessage(err)}`, runtime); } - runtime.session.markMovedFromEmptySessionFile(newSessionFile!); setProjectDir(resolvedPath); + await runtime.settings.reloadForCwd(resolvedPath); + applyProviderGlobalsFromSettings(runtime.settings); // Reload plugin/capability caches so the next prompt sees commands and // capabilities scoped to the new cwd. await runtime.reloadPlugins(); + await runtime.notifyConfigChanged?.(); await runtime.notifyTitleChanged?.(); await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`); return commandConsumed(); diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts index 4bb7bd60e..f42c2a217 100644 --- a/packages/coding-agent/test/acp-builtins.test.ts +++ b/packages/coding-agent/test/acp-builtins.test.ts @@ -12,7 +12,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import type { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { executeAcpBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins"; -import { removeWithRetries } from "@oh-my-pi/pi-utils"; +import { removeWithRetries, setProjectDir } from "@oh-my-pi/pi-utils"; interface FakeAcpBuiltinSession { fastMode: boolean; @@ -745,6 +745,33 @@ describe("wave 3 commands", () => { expect(output[0]).toContain("does not exist"); }); + it("/move: relocates the current session instead of switching to an empty target session", async () => { + const { output, runtime, session, fakeSessionManager } = createRuntime(); + const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-target-")); + const originalProjectDir = process.cwd(); + const reloadForCwd = spyOn(runtime.settings, "reloadForCwd"); + let configNotified = 0; + runtime.notifyConfigChanged = () => { + configNotified++; + }; + + try { + const result = await executeAcpBuiltinSlashCommand(`/move ${targetDir}`, runtime); + + expect(result).toEqual({ consumed: true }); + expect(fakeSessionManager._movedTo).toBe(targetDir); + expect(fakeSessionManager.getCwd()).toBe(targetDir); + expect(session._switchedTo).toBeUndefined(); + expect(session._movedFromEmptySessionFile).toBeUndefined(); + expect(reloadForCwd).toHaveBeenCalledWith(targetDir); + expect(configNotified).toBe(1); + expect(output[0]).toContain(`Moved to ${targetDir}.`); + } finally { + setProjectDir(originalProjectDir); + await fs.rm(targetDir, { recursive: true, force: true }); + } + }); + // /memory it("/memory unknown: returns usage message", async () => { const { output, runtime } = createRuntime(); diff --git a/packages/coding-agent/test/config/provider-globals.test.ts b/packages/coding-agent/test/config/provider-globals.test.ts new file mode 100644 index 000000000..81e4d6dcc --- /dev/null +++ b/packages/coding-agent/test/config/provider-globals.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { applyProviderGlobalsFromSettings } from "@oh-my-pi/pi-coding-agent/config/provider-globals"; +import * as imageGen from "@oh-my-pi/pi-coding-agent/tools/image-gen"; +import * as webSearch from "@oh-my-pi/pi-coding-agent/web/search"; + +describe("applyProviderGlobalsFromSettings", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reapplies valid web and image provider globals from cwd-scoped settings", () => { + const excludeSpy = vi.spyOn(webSearch, "setExcludedSearchProviders").mockImplementation(() => {}); + const webSpy = vi.spyOn(webSearch, "setPreferredSearchProvider").mockImplementation(() => {}); + const imageSpy = vi.spyOn(imageGen, "setPreferredImageProvider").mockImplementation(() => {}); + + applyProviderGlobalsFromSettings({ + get(path: "providers.webSearchExclude" | "providers.webSearch" | "providers.image"): unknown { + const values: Record = { + "providers.webSearchExclude": ["exa", "not-a-provider", "gemini"], + "providers.webSearch": "perplexity", + "providers.image": "xai", + }; + return values[path]; + }, + }); + + expect(excludeSpy).toHaveBeenCalledWith(["exa", "gemini"]); + expect(webSpy).toHaveBeenCalledWith("perplexity"); + expect(imageSpy).toHaveBeenCalledWith("xai"); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/move-command.test.ts b/packages/coding-agent/test/modes/controllers/move-command.test.ts new file mode 100644 index 000000000..e6bf15a20 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/move-command.test.ts @@ -0,0 +1,67 @@ +import { beforeAll, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller"; +import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; + +function createMoveContext(sourceDir: string) { + const state = { cwd: sourceDir, movedTo: undefined as string | undefined }; + const present = vi.fn(); + const applyCwdChange = vi.fn(async (cwd: string) => { + expect(state.cwd).toBe(cwd); + }); + const ctx = { + session: { isStreaming: false }, + sessionManager: { + getCwd: () => state.cwd, + moveTo: vi.fn(async (cwd: string) => { + state.cwd = cwd; + state.movedTo = cwd; + }), + dropSession: vi.fn(async () => {}), + }, + showHookCustom: vi.fn(), + showHookConfirm: vi.fn(), + showError: vi.fn(), + showWarning: vi.fn(), + applyCwdChange, + updateEditorBorderColor: vi.fn(), + reloadTodos: vi.fn(async () => {}), + ui: { requestRender: vi.fn() }, + present, + } as unknown as InteractiveModeContext; + return { ctx, state, present }; +} + +describe("CommandController /move", () => { + beforeAll(async () => { + const theme = await getThemeByName("dark"); + if (!theme) throw new Error("Expected dark theme"); + setThemeInstance(theme); + }); + + it("relocates the active session before re-scoping cwd-derived state", async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-source-")); + const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-target-")); + try { + const { ctx, state, present } = createMoveContext(sourceDir); + const controller = new CommandController(ctx); + + await controller.handleMoveCommand(targetDir); + + expect(state.movedTo).toBe(targetDir); + expect(ctx.sessionManager.dropSession).not.toHaveBeenCalled(); + expect(ctx.applyCwdChange).toHaveBeenCalledWith(targetDir); + expect(ctx.updateEditorBorderColor).toHaveBeenCalled(); + expect(ctx.reloadTodos).toHaveBeenCalled(); + expect(ctx.ui.requestRender).toHaveBeenCalledWith(); + expect(present).toHaveBeenCalled(); + expect(ctx.showError).not.toHaveBeenCalled(); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + await fs.rm(targetDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/session-manager/move-to.test.ts b/packages/coding-agent/test/session-manager/move-to.test.ts index f4a4728b4..84e5592b3 100644 --- a/packages/coding-agent/test/session-manager/move-to.test.ts +++ b/packages/coding-agent/test/session-manager/move-to.test.ts @@ -109,6 +109,23 @@ describe("SessionManager.moveTo", () => { expect(hasAssistantEntry(entries)).toBe(true); }); + it("makes the moved session visible to resume from the target cwd", async () => { + const session = SessionManager.create(cwdA); + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendMessage(makeAssistantMessage()); + await session.flush(); + const oldFile = session.getSessionFile()!; + + await session.moveTo(cwdB); + + const movedFile = session.getSessionFile()!; + const sourceSessions = await SessionManager.list(cwdA); + const targetSessions = await SessionManager.list(cwdB); + + expect(sourceSessions.some(item => item.path === oldFile)).toBe(false); + expect(targetSessions.some(item => item.path === movedFile)).toBe(true); + }); + it("succeeds on fresh session without ENOENT, then deferred persistence works", async () => { const session = SessionManager.create(cwdA); // No messages — file never written to disk