From f1e2e51a4b84427a71fa0660eb5ef2c567f83ea3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 7 Jun 2026 03:37:48 +0200 Subject: [PATCH] feat(coding-agent): added /fresh to reset provider state while keeping session files - Added `AgentSession.freshSession()` to rotate provider-facing IDs and prune provider stream state. - Added `/fresh` command handling in the builtin registry and mode command flow. - Kept persisted session metadata intact during `/fresh` and cleared transient IDs on session switches. - Invalidated `appendOnlyContext` and provider caches when refreshing provider state. --- ...ion-operations-export-share-fork-resume.md | 3 +- packages/coding-agent/CHANGELOG.md | 2 + .../modes/controllers/command-controller.ts | 12 +++ .../src/modes/interactive-mode.ts | 4 + packages/coding-agent/src/modes/types.ts | 1 + .../coding-agent/src/session/agent-session.ts | 41 ++++++- .../src/slash-commands/builtin-registry.ts | 25 +++++ .../test/agent-session-fresh.test.ts | 101 ++++++++++++++++++ .../test/slash-commands/fresh.test.ts | 43 ++++++++ 9 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/agent-session-fresh.test.ts create mode 100644 packages/coding-agent/test/slash-commands/fresh.test.ts diff --git a/docs/session-operations-export-share-fork-resume.md b/docs/session-operations-export-share-fork-resume.md index 0173137c1..0d6de4f70 100644 --- a/docs/session-operations-export-share-fork-resume.md +++ b/docs/session-operations-export-share-fork-resume.md @@ -1,4 +1,4 @@ -# Session Operations: export, dump, share, fork, resume/continue +# Session Operations: export, dump, share, fresh, fork, resume/continue This document describes operator-visible behavior for session export/share/fork/resume operations as currently implemented. @@ -19,6 +19,7 @@ This document describes operator-visible behavior for session export/share/fork/ | `/export [path]` | Interactive slash command | No | No | HTML file | | `--export [outputPath]` | CLI startup fast-path | No runtime session mutation | No active session; reads target file | HTML file | | `/share` | Interactive slash command | No | No | Temp HTML + share URL/gist | +| `/fresh` | Interactive slash command | Yes (provider-facing in-memory id/state only) | No; keeps current session file/header | None | | `/fork` | Interactive slash command | Yes (active session identity changes) | Creates new session file and switches current session to it (persistent mode only) | Copies artifact directory to new session namespace when present | | `--fork ` | CLI startup | Yes after session creation | Creates a new session fork from the selected source into current cwd/session dir | None | | `/resume` | Interactive slash command | Yes (active in-memory state replaced) | Switches to selected existing session file | None | diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8ce20eb81..037d2a243 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### Added +- Added `/fresh` to rotate the provider-facing session id and clear in-memory provider stream/cache state without changing the local session file. + - Added a `ChatBlock` transcript primitive (`modes/components/chat-block.ts`) and a single `ctx.present(...)` sink (with `ctx.resetTranscript()`) so chat output is mounted in one place instead of the repeated `chatContainer.addChild(...)` + `ui.requestRender()` pattern scattered across controllers. `ChatBlock` carries a React/Svelte-style lifecycle — `onMount` starts effects, `onCleanup` registers teardown, `finish()` self-completes (stops timers and freezes the block at its final content), and `dispose()`/`resetTranscript()` tears everything down — so animated blocks own their own resources instead of leaking `setInterval`/`requestRender` bookkeeping into callers. The MCP "Connecting…" spinner is now such a block. ### Changed diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index c19f93269..59a4b89de 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -802,6 +802,18 @@ export class CommandController { await this.#runNewSessionFlow(); } + async handleFreshCommand(): Promise { + const result = this.ctx.session.freshSession(); + if (!result) { + this.ctx.showWarning("Wait for the current response to finish or abort it before refreshing provider state."); + return; + } + const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorTopBorder(); + this.ctx.showStatus(`Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`); + } + async handleDropCommand(): Promise { if (!this.ctx.sessionManager.getSessionFile()) { this.ctx.showError("Nothing to drop (in-memory session)"); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index eb5b8b630..c596e9df2 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -2806,6 +2806,10 @@ export class InteractiveMode implements InteractiveModeContext { return this.#commandController.handleClearCommand(); } + handleFreshCommand(): Promise { + return this.#commandController.handleFreshCommand(); + } + handleDropCommand(): Promise { this.#prepareSessionSwitch(); return this.#commandController.handleDropCommand(); diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index c49595d4e..f5c09625c 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -247,6 +247,7 @@ export interface InteractiveModeContext { handleDumpCommand(): void; handleDebugTranscriptCommand(): Promise; handleClearCommand(): Promise; + handleFreshCommand(): Promise; handleDropCommand(): Promise; handleForkCommand(): Promise; handleBashCommand(command: string, excludeFromContext?: boolean): Promise; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 746ef86e0..dd14bd898 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -471,6 +471,12 @@ export interface SessionStats { cost: number; } +export interface FreshSessionResult { + previousSessionId: string; + sessionId: string; + closedProviderSessions: number; +} + /** Internal marker for hook messages queued through the agent loop */ // ============================================================================ // Constants @@ -922,6 +928,7 @@ export class AgentSession { #agentId: string | undefined; #agentRegistry: AgentRegistry | undefined; #providerSessionId: string | undefined; + #freshProviderSessionId: string | undefined; #isDisposed = false; // Extension system #extensionRunner: ExtensionRunner | undefined = undefined; @@ -2946,6 +2953,10 @@ export class AgentSession { this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent); } + #activeProviderSessionId(sessionId?: string): string { + return this.#freshProviderSessionId ?? this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId(); + } + /** * Set agent.sessionId from the session manager and install a dynamic * metadata resolver so every Anthropic API request carries @@ -2958,7 +2969,7 @@ export class AgentSession { * `#syncAgentSessionId()` on every such event. */ #syncAgentSessionId(sessionId?: string): void { - const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId(); + const sid = this.#activeProviderSessionId(sessionId); this.agent.sessionId = sid; this.agent.setMetadataResolver((provider: string) => buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage), @@ -3088,6 +3099,23 @@ export class AgentSession { this.#providerSessionState.clear(); } + freshSession(): FreshSessionResult | undefined { + if (this.isStreaming) return undefined; + const previousSessionId = this.sessionId; + const closedProviderSessions = this.#providerSessionState.size; + this.#closeAllProviderSessions("fresh session"); + this.#freshProviderSessionId = Bun.randomUUIDv7(); + this.#syncAgentSessionId(); + this.#rekeyHindsightMemoryForCurrentSessionId(); + this.#rekeyMnemopiMemoryForCurrentSessionId(); + this.agent.appendOnlyContext?.invalidateForModelChange(); + return { + previousSessionId, + sessionId: this.sessionId, + closedProviderSessions, + }; + } + // ========================================================================= // Read-only State Access // ========================================================================= @@ -3992,7 +4020,7 @@ export class AgentSession { /** Current session ID */ get sessionId(): string { - return this.#providerSessionId ?? this.sessionManager.getSessionId(); + return this.#activeProviderSessionId(); } getEvalSessionId(): string | null { if (this.#parentEvalSessionId !== undefined) return this.#parentEvalSessionId; @@ -5162,6 +5190,7 @@ export class AgentSession { } await this.sessionManager.newSession(options); this.setTodoPhases([]); + this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); @@ -5259,6 +5288,7 @@ export class AgentSession { } // Update agent session ID + this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); @@ -6226,6 +6256,7 @@ export class AgentSession { this.#cancelOwnAsyncJobs(); await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined); this.agent.reset(); + this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); @@ -8941,6 +8972,7 @@ export class AgentSession { const previousTools = [...this.agent.state.tools]; const previousBaseSystemPrompt = this.#baseSystemPrompt; const previousSystemPrompt = this.agent.state.systemPrompt; + const previousFreshProviderSessionId = this.#freshProviderSessionId; const previousFallbackSelectedMCPToolNames = previousSessionFile ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile) : undefined; @@ -8952,6 +8984,9 @@ export class AgentSession { try { await this.sessionManager.setSessionFile(sessionPath); + if (switchingToDifferentSession) { + this.#freshProviderSessionId = undefined; + } this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); @@ -9061,6 +9096,7 @@ export class AgentSession { return true; } catch (error) { this.sessionManager.restoreState(previousSessionState); + this.#freshProviderSessionId = previousFreshProviderSessionId; this.#syncAgentSessionId(previousSessionState.sessionId); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); @@ -9159,6 +9195,7 @@ export class AgentSession { this.sessionManager.createBranchedSession(selectedEntry.parentId); } this.#syncTodoPhasesFromBranch(); + this.#freshProviderSessionId = undefined; this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index bfb059a70..57ecac800 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -21,6 +21,7 @@ import { } from "../extensibility/plugins/marketplace"; import { resolveMemoryBackend } from "../memory-backend"; import type { InteractiveModeContext } from "../modes/types"; +import type { FreshSessionResult } from "../session/agent-session"; import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; import { getChangelogPath, parseChangelog } from "../utils/changelog"; import { buildContextReportText } from "./helpers/context-report"; @@ -52,6 +53,11 @@ function refreshStatusLine(ctx: InteractiveModeContext): void { ctx.ui.requestRender(); } +function formatFreshSessionResult(result: FreshSessionResult): string { + const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; + return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`; +} + const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime): SlashCommandResult => { runtime.ctx.editor.setText(""); void runtime.ctx.shutdown(); @@ -770,6 +776,25 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ await runtime.ctx.handleClearCommand(); }, }, + { + name: "fresh", + description: "Reset provider stream state without changing the local transcript", + handle: async (_command, runtime) => { + const result = runtime.session.freshSession(); + if (!result) { + await runtime.output( + "Wait for the current response to finish or abort it before refreshing provider state.", + ); + return commandConsumed(); + } + await runtime.output(formatFreshSessionResult(result)); + return commandConsumed(); + }, + handleTui: async (_command, runtime) => { + runtime.ctx.editor.setText(""); + await runtime.ctx.handleFreshCommand(); + }, + }, { name: "drop", description: "Delete the current session and start a new one", diff --git a/packages/coding-agent/test/agent-session-fresh.test.ts b/packages/coding-agent/test/agent-session-fresh.test.ts new file mode 100644 index 000000000..aa294d743 --- /dev/null +++ b/packages/coding-agent/test/agent-session-fresh.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { Agent, AppendOnlyContextManager } from "@oh-my-pi/pi-agent-core"; +import type { ProviderSessionState } from "@oh-my-pi/pi-ai"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +interface FreshHarness { + agent: Agent; + session: AgentSession; + sessionManager: SessionManager; +} + +const cleanup: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanup.length > 0) { + const run = cleanup.pop(); + if (run) await run(); + } +}); + +async function createFreshHarness(): Promise { + const tempDir = TempDir.createSync("@pi-agent-session-fresh-"); + const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const sessionManager = SessionManager.create(tempDir.path(), path.join(tempDir.path(), "sessions")); + const agent = new Agent({ + initialState: { + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }); + const session = new AgentSession({ + agent, + sessionManager, + settings: Settings.isolated(), + modelRegistry, + }); + cleanup.push(async () => { + await session.dispose(); + authStorage.close(); + tempDir.removeSync(); + }); + return { agent, session, sessionManager }; +} + +describe("AgentSession fresh provider state", () => { + it("rotates only the provider-facing session id and prunes cached stream state", async () => { + const { agent, session, sessionManager } = await createFreshHarness(); + const persistedSessionId = sessionManager.getSessionId(); + const persistedSessionFile = sessionManager.getSessionFile(); + const persistedHeaderId = sessionManager.getHeader()?.id; + let closeCount = 0; + const providerState: ProviderSessionState = { + close() { + closeCount += 1; + }, + }; + session.providerSessionState.set("websocket", providerState); + + const appendOnlyContext = new AppendOnlyContextManager(); + agent.setAppendOnlyContext(appendOnlyContext); + appendOnlyContext.syncMessages([{ role: "user", content: "cached context" }]); + appendOnlyContext.build({ systemPrompt: ["Test"], messages: [], tools: [] }, { intentTracing: false }); + + const result = session.freshSession(); + + expect(result).toBeDefined(); + if (!result) return; + expect(result.previousSessionId).toBe(persistedSessionId); + expect(result.sessionId).not.toBe(persistedSessionId); + expect(result.closedProviderSessions).toBe(1); + expect(agent.sessionId).toBe(result.sessionId); + expect(session.sessionId).toBe(result.sessionId); + expect(sessionManager.getSessionId()).toBe(persistedSessionId); + expect(sessionManager.getHeader()?.id).toBe(persistedHeaderId); + expect(sessionManager.getSessionFile()).toBe(persistedSessionFile); + expect(closeCount).toBe(1); + expect(session.providerSessionState.size).toBe(0); + expect(appendOnlyContext.log.length).toBe(0); + expect(appendOnlyContext.prefix.built).toBe(false); + }); + + it("drops the transient provider id when a real new session starts", async () => { + const { session, sessionManager } = await createFreshHarness(); + const freshResult = session.freshSession(); + expect(freshResult).toBeDefined(); + if (!freshResult) return; + + await session.newSession(); + + expect(session.sessionId).toBe(sessionManager.getSessionId()); + expect(session.sessionId).not.toBe(freshResult.sessionId); + }); +}); diff --git a/packages/coding-agent/test/slash-commands/fresh.test.ts b/packages/coding-agent/test/slash-commands/fresh.test.ts new file mode 100644 index 000000000..d20af962b --- /dev/null +++ b/packages/coding-agent/test/slash-commands/fresh.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "bun:test"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; + +function createRuntimeHarness(handleFreshCommand: InteractiveModeContext["handleFreshCommand"]) { + const setText = vi.fn(); + return { + setText, + handleFreshCommand, + runtime: { + ctx: { + editor: { setText } as unknown as InteractiveModeContext["editor"], + handleFreshCommand, + } as InteractiveModeContext, + handleBackgroundCommand: () => {}, + }, + }; +} + +describe("/fresh slash command", () => { + it("awaits provider-state refresh before resolving", async () => { + const deferred = Promise.withResolvers(); + const handleFreshCommand = vi.fn(() => deferred.promise); + const harness = createRuntimeHarness(handleFreshCommand); + + let settled = false; + const execution = executeBuiltinSlashCommand("/fresh", harness.runtime).then(result => { + settled = true; + return result; + }); + + await Promise.resolve(); + + expect(harness.setText).toHaveBeenCalledWith(""); + expect(handleFreshCommand).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + deferred.resolve(); + + expect(await execution).toBe(true); + expect(settled).toBe(true); + }); +});