diff --git a/docs/session-operations-export-share-fork-resume.md b/docs/session-operations-export-share-fork-resume.md index 417827b1a..010512b69 100644 --- a/docs/session-operations-export-share-fork-resume.md +++ b/docs/session-operations-export-share-fork-resume.md @@ -172,7 +172,7 @@ Interactive `/fork` creates a new session from the current one and switches the 2. Flushes pending writes. 3. Calls `SessionManager.fork()`. 4. Copies artifacts directory from old session namespace to new namespace (best-effort; non-ENOENT copy failures are logged, not fatal). -5. Updates `agent.sessionId`. +5. Updates `agent.sessionId` and inherits the previous provider prompt-cache key unless an explicit prompt-cache key is already pinned. 6. Emits `session_switch` with `reason: "fork"`. `SessionManager.fork()` behavior: @@ -184,6 +184,7 @@ Interactive `/fork` creates a new session from the current one and switches the - new timestamp - `cwd` unchanged - `parentSession` set to previous session id + - `providerPromptCacheKey` set to the previous header's inherited key, or the previous session id when none was pinned - Keeps all non-header entries unchanged in the new file. ### Non-persistent behavior @@ -200,6 +201,9 @@ Startup `--fork` is resolved before normal session creation: 2. Path-like values (`/`, `\`, or `.jsonl`) call `SessionManager.forkFrom(path, cwd, sessionDir)`. 3. Other values resolve via `resolveResumableSession(...)`: local sessions first, then global search when `sessionDir` is not forced. Matching accepts lowercased session id prefixes, full JSONL filename prefixes, and timestamp-stripped filename id suffixes. 4. The forked file is created in the current cwd/session-dir scope and becomes the active session manager for startup. +5. Full-context forks automatically seed `providerPromptCacheKey` from the source header's inherited key, falling back to the source session id. Startup drops that automatic inheritance when `--model`, `--thinking`, `--system-prompt`, `--append-system-prompt`, `--tools`, or `--no-tools` changes the provider route or prompt/tool shape. + +Use `--prompt-cache-key ` to pin the provider prompt-cache identity explicitly and independently from both the OMP session id and `--provider-session-id`. `--provider-session-id` continues to control provider session/routing headers and sticky credential selection; `--prompt-cache-key` controls the OpenAI Responses `prompt_cache_key` payload where supported. ## Resume and continue diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c5d526e2..c00e04586 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed full-context forks cold-missing OpenAI prompt caches by persisting an inherited provider prompt-cache key separately from the new OMP session id, adding `--prompt-cache-key` for explicit cache affinity, and dropping automatic inheritance when startup changes the model, thinking level, system prompt, or tool schema. ([#5035](https://github.com/can1357/oh-my-pi/issues/5035)) + ## [16.3.15] - 2026-07-09 ### Changed diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8ab71f1f8..d5a0b18d4 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -42,6 +42,7 @@ export interface Args { noSession?: boolean; sessionDir?: string; providerSessionId?: string; + providerPromptCacheKey?: string; fork?: string; /** Collab link to join at startup (set by the `join` subcommand; no CLI flag). */ join?: string; diff --git a/packages/coding-agent/src/cli/flag-tables.ts b/packages/coding-agent/src/cli/flag-tables.ts index e43fc5445..e8c8c9570 100644 --- a/packages/coding-agent/src/cli/flag-tables.ts +++ b/packages/coding-agent/src/cli/flag-tables.ts @@ -141,6 +141,9 @@ export const STRING_SETTERS: Record = { "--provider-session-id": (result, value) => { result.providerSessionId = value; }, + "--prompt-cache-key": (result, value) => { + result.providerPromptCacheKey = value; + }, "--session-dir": (result, value) => { result.sessionDir = value; }, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e832c23f2..ee25259b0 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -789,7 +789,8 @@ export function applyResolvedSystemPromptInputs( } } -async function buildSessionOptions( +/** Builds startup session options from parsed CLI flags, scoped models, and resolved session lineage. */ +export async function buildSessionOptions( parsed: Args, scopedModels: ScopedModel[], sessionManager: SessionManager | undefined, @@ -820,6 +821,25 @@ async function buildSessionOptions( if (parsed.providerSessionId) { options.providerSessionId = parsed.providerSessionId; } + if (parsed.providerPromptCacheKey) { + options.providerPromptCacheKey = parsed.providerPromptCacheKey; + options.providerPromptCacheKeySource = "explicit"; + } else { + const header = sessionManager?.getHeader(); + const scopedModelOverride = scopedModels.length > 0 && !parsed.continue && !parsed.resume; + const forkCacheShapeChanged = + scopedModelOverride || + parsed.model !== undefined || + parsed.thinking !== undefined || + parsed.systemPrompt !== undefined || + parsed.appendSystemPrompt !== undefined || + parsed.tools !== undefined || + parsed.noTools === true; + if (!forkCacheShapeChanged && header?.providerPromptCacheKey) { + options.providerPromptCacheKey = header.providerPromptCacheKey; + options.providerPromptCacheKeySource = "fork"; + } + } // Model from CLI // - supports --provider --model diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 0d27eac07..a87e637c0 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -426,6 +426,8 @@ export interface CreateAgentSessionOptions { providerSessionId?: string; /** Optional provider-facing prompt cache key, distinct from request lineage. */ providerPromptCacheKey?: string; + /** Whether `providerPromptCacheKey` is caller-pinned or inherited from a full fork. */ + providerPromptCacheKeySource?: "explicit" | "fork"; /** Absolute wall-clock deadline in Unix epoch milliseconds. */ deadline?: number; @@ -1221,6 +1223,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)), ); const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId(); + const forkCacheShapeChanged = + options.model !== undefined || + options.modelPattern !== undefined || + options.thinkingLevel !== undefined || + options.systemPrompt !== undefined || + options.customSystemPrompt !== undefined || + options.appendSystemPrompt !== undefined || + options.toolNames !== undefined || + options.customTools !== undefined; + const inheritedPromptCacheKey = forkCacheShapeChanged + ? undefined + : sessionManager.getHeader()?.providerPromptCacheKey; + const providerPromptCacheKey = options.providerPromptCacheKey ?? inheritedPromptCacheKey; + const providerPromptCacheKeySource = + options.providerPromptCacheKey !== undefined + ? (options.providerPromptCacheKeySource ?? "explicit") + : providerPromptCacheKey !== undefined + ? "fork" + : undefined; // Startup model *selection* only needs to know whether auth is configured for // a candidate's provider — never the resolved key bytes. Use the synchronous, // side-effect-free probe (`hasConfiguredAuth`): it refreshes no OAuth tokens, @@ -2704,7 +2725,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} onPayload, onResponse, sessionId: providerSessionId, - promptCacheKey: options.providerPromptCacheKey, + promptCacheKey: providerPromptCacheKey, deadline: options.deadline, transformContext, transformProviderContext, @@ -2888,6 +2909,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} agentId: resolvedAgentId, agentKind, providerSessionId: options.providerSessionId, + providerPromptCacheKeySource, parentEvalSessionId: options.parentEvalSessionId, advisorTools, titleSystemPrompt: options.titleSystemPrompt, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index edac6e685..2373f5b8c 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -809,6 +809,8 @@ export interface AgentSessionConfig { * so that credential sticky selection is consistent with the session's streaming calls. */ providerSessionId?: string; + /** Marks `agent.promptCacheKey` as fork-inherited so incompatible route changes can clear it. */ + providerPromptCacheKeySource?: "explicit" | "fork"; /** * Full advisor toolset, pre-built in `createAgentSession` against a distinct, * advisor-scoped `ToolSession` (its own `-advisor` session/agent id) so the @@ -1713,6 +1715,7 @@ export class AgentSession { #agentKind: "main" | "sub" = "main"; #providerSessionId: string | undefined; #freshProviderSessionId: string | undefined; + #inheritedProviderPromptCacheKey: string | undefined; #isDisposed = false; // Extension system #extensionRunner: ExtensionRunner | undefined = undefined; @@ -2228,6 +2231,8 @@ export class AgentSession { this.#agentId = config.agentId; this.#agentKind = config.agentKind ?? "main"; this.#providerSessionId = config.providerSessionId; + this.#inheritedProviderPromptCacheKey = + config.providerPromptCacheKeySource === "fork" ? this.agent.promptCacheKey : undefined; this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => { const event: AgentEvent = { type: "message_update", @@ -5594,6 +5599,23 @@ export class AgentSession { return this.#freshProviderSessionId ?? this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId(); } + #adoptInheritedProviderPromptCacheKey(): void { + const key = this.sessionManager.getHeader()?.providerPromptCacheKey; + if (!key) return; + if (this.#inheritedProviderPromptCacheKey !== undefined || this.agent.promptCacheKey === undefined) { + this.agent.promptCacheKey = key; + this.#inheritedProviderPromptCacheKey = key; + } + } + + #clearInheritedProviderPromptCacheKey(): void { + const key = this.#inheritedProviderPromptCacheKey; + this.#inheritedProviderPromptCacheKey = undefined; + if (key !== undefined && this.agent.promptCacheKey === key) { + this.agent.promptCacheKey = undefined; + } + } + /** * Set agent.sessionId from the session manager and install a dynamic * metadata resolver so every Anthropic API request carries @@ -6399,6 +6421,9 @@ export class AgentSession { if (this.#rebuildSystemPrompt) { const signature = this.#computeAppliedToolSignature(validToolNames, tools); if (signature !== this.#lastAppliedToolSignature) { + if (this.#lastAppliedToolSignature !== undefined) { + this.#clearInheritedProviderPromptCacheKey(); + } const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry); this.#baseSystemPrompt = built.systemPrompt; this.#baseSystemPromptBeforeMemoryPromotion = undefined; @@ -6485,9 +6510,16 @@ export class AgentSession { if (!this.#rebuildSystemPrompt) return; const activeToolNames = this.getActiveToolNames(); this.#setActiveToolNames?.(activeToolNames); + const previousBaseSystemPrompt = this.#baseSystemPrompt; const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry); this.#baseSystemPrompt = built.systemPrompt; this.#baseSystemPromptBeforeMemoryPromotion = undefined; + if ( + previousBaseSystemPrompt.length !== this.#baseSystemPrompt.length || + previousBaseSystemPrompt.some((part, index) => part !== this.#baseSystemPrompt[index]) + ) { + this.#clearInheritedProviderPromptCacheKey(); + } this.agent.setSystemPrompt(this.#baseSystemPrompt); this.#promptModelKey = this.#currentPromptModelKey(); // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with @@ -8728,6 +8760,7 @@ export class AgentSession { this.#clearCheckpointRuntimeState(); this.setTodoPhases([]); this.#freshProviderSessionId = undefined; + this.#clearInheritedProviderPromptCacheKey(); this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); @@ -8828,6 +8861,7 @@ export class AgentSession { // Update agent session ID this.#freshProviderSessionId = undefined; + this.#adoptInheritedProviderPromptCacheKey(); this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); @@ -9149,6 +9183,9 @@ export class AgentSession { this.#autoThinking = true; this.#autoResolvedLevel = undefined; this.#thinkingLevel = provisional; + if (!wasAuto) { + this.#clearInheritedProviderPromptCacheKey(); + } this.#applyThinkingLevelToAgent(provisional); if (persist) { this.settings.set("defaultThinkingLevel", AUTO_THINKING); @@ -9172,6 +9209,7 @@ export class AgentSession { this.#applyThinkingLevelToAgent(effectiveLevel); if (isChanging) { + this.#clearInheritedProviderPromptCacheKey(); this.sessionManager.appendThinkingLevelChange(effectiveLevel, effectiveLevel); if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) { this.settings.set("defaultThinkingLevel", effectiveLevel); @@ -11580,6 +11618,9 @@ export class AgentSession { const currentModel = this.model; if (currentModel) { this.#closeProviderSessionsForModelSwitch(currentModel, model); + if (!modelsAreEqual(currentModel, model)) { + this.#clearInheritedProviderPromptCacheKey(); + } } this.agent.setModel(model); @@ -14745,6 +14786,7 @@ export class AgentSession { const previousSystemPrompt = this.agent.state.systemPrompt; const previousBaseSystemPromptBeforeMemoryPromotion = this.#baseSystemPromptBeforeMemoryPromotion; const previousFreshProviderSessionId = this.#freshProviderSessionId; + const previousInheritedProviderPromptCacheKey = this.#inheritedProviderPromptCacheKey; const previousFallbackSelectedMCPToolNames = previousSessionFile ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile) : undefined; @@ -14766,6 +14808,8 @@ export class AgentSession { await this.sessionManager.setSessionFile(sessionPath); if (switchingToDifferentSession) { this.#freshProviderSessionId = undefined; + this.#clearInheritedProviderPromptCacheKey(); + this.#adoptInheritedProviderPromptCacheKey(); } this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); @@ -14919,6 +14963,7 @@ export class AgentSession { this.agent.replaceQueues(previousSteeringMessages, previousFollowUpMessages); this.#pendingNextTurnMessages = previousPendingNextTurnMessages; this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration; + this.#inheritedProviderPromptCacheKey = previousInheritedProviderPromptCacheKey; this.#checkpointState = previousCheckpointState; this.#pendingRewindReport = previousPendingRewindReport; this.#lastCompletedRewind = previousLastCompletedRewind; @@ -14994,6 +15039,7 @@ export class AgentSession { this.#rehydrateCheckpointRewindState(); this.#syncTodoPhasesFromBranch(); this.#freshProviderSessionId = undefined; + this.#clearInheritedProviderPromptCacheKey(); this.#syncAgentSessionId(); this.#rekeyHindsightMemoryForCurrentSessionId(); this.#rekeyMnemopiMemoryForCurrentSessionId(); diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index bccfb9624..59ed3e506 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -32,10 +32,14 @@ export interface SessionHeader { timestamp: string; cwd: string; parentSession?: string; + /** Provider prompt-cache identity inherited by exact-route full forks. */ + providerPromptCacheKey?: string; } export interface NewSessionOptions { parentSession?: string; + /** Provider prompt-cache identity to seed on the new session header. */ + providerPromptCacheKey?: string; /** Skip flushing the current session and delete it instead of saving. */ drop?: boolean; } diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 741df118d..1f27a1294 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -783,6 +783,7 @@ export class SessionManager { timestamp, cwd: this.#cwd, parentSession: options?.parentSession, + providerPromptCacheKey: options?.providerPromptCacheKey, }; this.#titleUpdatedAt = timestamp; @@ -1050,6 +1051,7 @@ export class SessionManager { timestamp, cwd: this.#cwd, parentSession: parentSessionId, + providerPromptCacheKey: this.#header.providerPromptCacheKey ?? parentSessionId, }; this.#sessionName = this.#header.title; this.#titleSource = this.#header.titleSource; @@ -1888,7 +1890,13 @@ export class SessionManager { const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined; const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[]; - manager.#resetToNewSession({ parentSession: sourceHeader?.id }, options?.sessionFile); + manager.#resetToNewSession( + { + parentSession: sourceHeader?.id, + providerPromptCacheKey: sourceHeader?.providerPromptCacheKey ?? sourceHeader?.id, + }, + options?.sessionFile, + ); manager.#header.title = sourceHeader?.title; manager.#header.titleSource = sourceHeader?.titleSource; manager.#sessionName = manager.#header.title; diff --git a/packages/coding-agent/test/session-fork-prompt-cache-key.test.ts b/packages/coding-agent/test/session-fork-prompt-cache-key.test.ts new file mode 100644 index 000000000..1c64a15e4 --- /dev/null +++ b/packages/coding-agent/test/session-fork-prompt-cache-key.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { type Args, parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import type { ScopedModel } from "@oh-my-pi/pi-coding-agent/config/model-resolver"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { buildSessionOptions } from "@oh-my-pi/pi-coding-agent/main"; +import { type CreateAgentSessionOptions, createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { CURRENT_SESSION_VERSION, type SessionHeader } from "@oh-my-pi/pi-coding-agent/session/session-entries"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const OPENAI_TEST_MODEL = getBundledModel("openai", "gpt-4o-mini"); + +interface ArgsWithPromptCacheKey extends Args { + providerPromptCacheKey?: string; +} + +interface SourceSessionFixture { + cwd: string; + sourceFile: string; + sourceHeader: SessionHeader; + forkSessionDir: string; +} + +async function createSourceSessionFixture(tempDir: TempDir, parentId: string): Promise { + const cwd = tempDir.join("project"); + const sourceDir = tempDir.join("source-sessions"); + const forkSessionDir = tempDir.join("forked-sessions"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.mkdir(forkSessionDir, { recursive: true }); + const sourceFile = path.join(sourceDir, `${parentId}.jsonl`); + const sourceHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: parentId, + timestamp: new Date().toISOString(), + cwd, + }; + await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); + return { cwd, sourceFile, sourceHeader, forkSessionDir }; +} + +async function createMinimalSession( + tempDir: TempDir, + options: CreateAgentSessionOptions, +): Promise<{ session: AgentSession; authStorage: AuthStorage }> { + const authStorage = await AuthStorage.create(tempDir.join("sdk-auth.db")); + authStorage.setRuntimeApiKey("openai", "test-key"); + const shouldSupplyModel = options.sessionManager?.getHeader()?.parentSession === undefined; + const result = await createAgentSession({ + ...options, + cwd: options.cwd ?? tempDir.path(), + agentDir: tempDir.path(), + authStorage, + modelRegistry: undefined, + model: shouldSupplyModel ? (options.model ?? OPENAI_TEST_MODEL) : options.model, + settings: Settings.isolated({ + "async.enabled": false, + "marketplace.autoUpdate": "off", + }), + disableExtensionDiscovery: true, + preloadedExtensions: undefined, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + workspaceTree: { + rootPath: options.cwd ?? tempDir.path(), + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }, + enableMCP: false, + enableLsp: false, + ...(options.toolNames !== undefined ? { toolNames: options.toolNames } : {}), + }); + return { session: result.session, authStorage }; +} + +describe("provider prompt-cache key session affinity", () => { + it("parses --prompt-cache-key without folding it into provider session id or prompt text", () => { + const parsed = parseArgs([ + "--provider-session-id", + "provider-lineage", + "--prompt-cache-key", + "cache-affinity", + "hello", + ]); + const promptCacheArgs: ArgsWithPromptCacheKey = parsed; + + expect(parsed.providerSessionId).toBe("provider-lineage"); + expect(promptCacheArgs.providerPromptCacheKey).toBe("cache-affinity"); + expect(parsed.messages).toEqual(["hello"]); + expect(parsed.unrecognizedFlags).toEqual([]); + }); + + it("creates an agent whose prompt-cache key can differ from provider request lineage", async () => { + using tempDir = TempDir.createSync("@omp-prompt-cache-sdk-"); + let session: AgentSession | undefined; + let authStorage: AuthStorage | undefined; + try { + const created = await createMinimalSession(tempDir, { + providerSessionId: "provider-lineage", + providerPromptCacheKey: "cache-affinity", + sessionManager: SessionManager.inMemory(tempDir.path()), + }); + session = created.session; + authStorage = created.authStorage; + + expect(session.agent.sessionId).toBe("provider-lineage"); + expect(session.agent.promptCacheKey).toBe("cache-affinity"); + expect(session.agent.promptCacheKey).not.toBe(session.agent.sessionId); + } finally { + await session?.dispose(); + authStorage?.close(); + } + }); + + it("initializes a full fork with child request lineage and parent prompt-cache affinity", async () => { + using tempDir = TempDir.createSync("@omp-prompt-cache-fork-"); + const source = await createSourceSessionFixture(tempDir, "parent-cache-session"); + const forkedManager = await SessionManager.forkFrom(source.sourceFile, source.cwd, source.forkSessionDir); + let session: AgentSession | undefined; + let authStorage: AuthStorage | undefined; + try { + const created = await createMinimalSession(tempDir, { + cwd: source.cwd, + sessionManager: forkedManager, + }); + session = created.session; + authStorage = created.authStorage; + const childSessionId = forkedManager.getSessionId(); + + expect(forkedManager.getHeader()?.parentSession).toBe(source.sourceHeader.id); + expect(childSessionId).toBeString(); + expect(childSessionId).not.toBe(source.sourceHeader.id); + expect(session.agent.sessionId).toBe(childSessionId); + expect(session.agent.promptCacheKey).toBe(source.sourceHeader.id); + expect(session.agent.promptCacheKey).not.toBe(session.agent.sessionId); + } finally { + await session?.dispose(); + authStorage?.close(); + } + }); + + it("does not auto-inherit parent prompt-cache affinity when fork startup changes request-shaping inputs", async () => { + const cases: Array<{ name: string; options: CreateAgentSessionOptions }> = [ + { + name: "model", + options: { model: OPENAI_TEST_MODEL }, + }, + { + name: "thinking", + options: { thinkingLevel: ThinkingLevel.High }, + }, + { + name: "system", + options: { customSystemPrompt: "Use a different provider prompt." }, + }, + { + name: "tools", + options: { toolNames: ["read"] }, + }, + ]; + + for (const entry of cases) { + using tempDir = TempDir.createSync(`@omp-prompt-cache-fork-${entry.name}-`); + const source = await createSourceSessionFixture(tempDir, `parent-cache-session-${entry.name}`); + const forkedManager = await SessionManager.forkFrom(source.sourceFile, source.cwd, source.forkSessionDir); + let session: AgentSession | undefined; + let authStorage: AuthStorage | undefined; + try { + const created = await createMinimalSession(tempDir, { + ...entry.options, + cwd: source.cwd, + sessionManager: forkedManager, + }); + session = created.session; + authStorage = created.authStorage; + + expect(forkedManager.getHeader()?.parentSession).toBe(source.sourceHeader.id); + expect(session.agent.promptCacheKey, entry.name).toBeUndefined(); + } finally { + await session?.dispose(); + authStorage?.close(); + } + } + }); + + it("does not pre-pin parent prompt-cache affinity when a scoped model selects the startup route", async () => { + using tempDir = TempDir.createSync("@omp-prompt-cache-scoped-model-"); + const source = await createSourceSessionFixture(tempDir, "parent-cache-session-scoped"); + const forkedManager = await SessionManager.forkFrom(source.sourceFile, source.cwd, source.forkSessionDir); + const authStorage = await AuthStorage.create(tempDir.join("scoped-auth.db")); + authStorage.setRuntimeApiKey(OPENAI_TEST_MODEL.provider, "test-key"); + try { + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + const parsed = parseArgs([ + "--cwd", + source.cwd, + "--models", + `${OPENAI_TEST_MODEL.provider}/${OPENAI_TEST_MODEL.id}`, + ]); + const scopedModels: ScopedModel[] = [ + { + model: OPENAI_TEST_MODEL, + explicitThinkingLevel: false, + }, + ]; + + const options = await buildSessionOptions( + parsed, + scopedModels, + forkedManager, + modelRegistry, + Settings.isolated({ "marketplace.autoUpdate": "off" }), + ); + + expect(options.model).toBe(OPENAI_TEST_MODEL); + expect(options.providerPromptCacheKey).toBeUndefined(); + } finally { + authStorage.close(); + } + }); +});