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:
@@ -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 |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user