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.
This commit is contained in:
can1357
2026-06-07 03:37:48 +02:00
parent 99e91138a4
commit f1e2e51a4b
9 changed files with 229 additions and 3 deletions
@@ -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 <session.jsonl> [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 <id\|path>` | 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 |
+2
View File
@@ -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
@@ -802,6 +802,18 @@ export class CommandController {
await this.#runNewSessionFlow();
}
async handleFreshCommand(): Promise<void> {
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<void> {
if (!this.ctx.sessionManager.getSessionFile()) {
this.ctx.showError("Nothing to drop (in-memory session)");
@@ -2806,6 +2806,10 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#commandController.handleClearCommand();
}
handleFreshCommand(): Promise<void> {
return this.#commandController.handleFreshCommand();
}
handleDropCommand(): Promise<void> {
this.#prepareSessionSwitch();
return this.#commandController.handleDropCommand();
+1
View File
@@ -247,6 +247,7 @@ export interface InteractiveModeContext {
handleDumpCommand(): void;
handleDebugTranscriptCommand(): Promise<void>;
handleClearCommand(): Promise<void>;
handleFreshCommand(): Promise<void>;
handleDropCommand(): Promise<void>;
handleForkCommand(): Promise<void>;
handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
@@ -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();
@@ -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<SlashCommandSpec> = [
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",
@@ -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<void>> = [];
afterEach(async () => {
while (cleanup.length > 0) {
const run = cleanup.pop();
if (run) await run();
}
});
async function createFreshHarness(): Promise<FreshHarness> {
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);
});
});
@@ -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<void>();
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);
});
});