diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index fb3e37a57..8000019b5 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Fixed + +- Fixed `transformContext` receiving the loop config object as the `signal` argument instead of the actual `AbortSignal`, so hooks that check `signal.aborted` or call `signal.addEventListener` now work correctly under abort/timeout conditions +- Fixed `appendOnlyContext` not being re-evaluated after `setModel()` — the mode was decided once at session construction based on the initial model's provider, so switching from/to DeepSeek (or changing `provider.appendOnlyContext`) mid-session produced incorrect mode behavior ## [15.2.3] - 2026-05-22 ### Added diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 4a2626874..0771535a6 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -647,12 +647,19 @@ async function streamAssistantResponse( const llmMessages = await config.convertToLlm(messages); const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model); - // Build LLM context - const llmContext: Context = { - systemPrompt: context.systemPrompt, - messages: normalizedMessages, - tools: normalizeTools(context.tools, !!config.intentTracing), - }; + // Build LLM context — append-only mode caches system prompt + tools + // AND keeps an append-only message log so prior-turn bytes are stable. + let llmContext: Context; + if (config.appendOnlyContext) { + config.appendOnlyContext.syncMessages(normalizedMessages); + llmContext = config.appendOnlyContext.build(context); + } else { + llmContext = { + systemPrompt: context.systemPrompt, + messages: normalizedMessages, + tools: normalizeTools(context.tools, !!config.intentTracing), + }; + } const streamFunction = streamFn || streamSimple; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index c5a9e45fc..5a027ad28 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -21,6 +21,7 @@ import { type ToolResultMessage, } from "@oh-my-pi/pi-ai"; import { agentLoop, agentLoopContinue } from "./agent-loop"; +import type { AppendOnlyContextManager } from "./append-only-context"; import type { HarmonyAuditEvent } from "./harmony-leak"; import type { AgentContext, @@ -227,6 +228,11 @@ export interface AgentOptions { * {@link AgentLoopConfig.telemetry} for the full surface. */ telemetry?: AgentLoopConfig["telemetry"]; + /** + * Immutable context mode — stabilizes system prompt + tool spec bytes + * across turns so DeepSeek/Anthropic prefix caches hit at maximum rate. + */ + appendOnlyContext?: AppendOnlyContextManager; } export interface AgentPromptOptions { @@ -292,6 +298,7 @@ export class Agent { #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise; #onBeforeYield?: () => Promise | void; #telemetry?: AgentLoopConfig["telemetry"]; + #appendOnlyContext?: AppendOnlyContextManager; /** Buffered Cursor tool results with text length at time of call (for correct ordering) */ #cursorToolResultBuffer: CursorToolResultEntry[] = []; @@ -346,6 +353,7 @@ export class Agent { this.beforeToolCall = opts.beforeToolCall; this.afterToolCall = opts.afterToolCall; this.#telemetry = opts.telemetry; + this.#appendOnlyContext = opts.appendOnlyContext; } /** @@ -541,6 +549,14 @@ export class Agent { return this.#state; } + get appendOnlyContext(): AppendOnlyContextManager | undefined { + return this.#appendOnlyContext; + } + + setAppendOnlyContext(manager?: AppendOnlyContextManager): void { + this.#appendOnlyContext = manager; + } + subscribe(fn: (e: AgentEvent) => void): () => void { this.#listeners.add(fn); return () => this.#listeners.delete(fn); @@ -925,6 +941,7 @@ export class Agent { cursorOnToolResult, transformToolCallArguments: this.#transformToolCallArguments, intentTracing: this.#intentTracing, + appendOnlyContext: this.#appendOnlyContext, beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined, afterToolCall: this.afterToolCall ? (ctx, signal) => this.afterToolCall?.(ctx, signal) : undefined, onAssistantMessageEvent: this.#onAssistantMessageEvent, diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts new file mode 100644 index 000000000..49ba0e30d --- /dev/null +++ b/packages/agent/src/append-only-context.ts @@ -0,0 +1,291 @@ +/** + * Append-only context mode — stabilizes the byte prefix sent to the LLM + * across turns so provider prefix caches (DeepSeek, Anthropic, etc.) + * hit at the maximum possible rate. + * + * Two mechanisms: + * + * 1. **StablePrefix** — system prompt + tool specs are computed once + * and frozen. Subsequent turns reuse the exact same byte sequence + * unless `invalidate()` is called (e.g. after MCP reconnect). + * + * 2. **AppendOnlyLog** — messages only grow; prior turns are never + * re-serialized. Combined with a stable prefix, only the user's new + * message delta is a cache miss each turn. + */ + +import type { Context, Message, Tool } from "@oh-my-pi/pi-ai"; +import type { AgentContext, AgentTool } from "./types"; + +// --------------------------------------------------------------------------- +// StablePrefix (formerly ImmutablePrefix) +// --------------------------------------------------------------------------- + +/** Frozen system prompt + tool spec snapshot. */ +export interface StablePrefixSnapshot { + systemPrompt: string[]; + tools: Tool[]; + fingerprint: string; +} + +/** + * A frozen prefix (system prompt + tools) that produces stable byte + * sequences across `build()` calls. + * + * The first `build()` snapshots the live state. Subsequent calls reuse + * the cached copy until `invalidate()` is called or the live state's + * fingerprint changes. + */ +export class StablePrefix { + #snapshot: StablePrefixSnapshot | null = null; + #version = 0; + + get fingerprint(): string { + return this.#snapshot?.fingerprint ?? ""; + } + get version(): number { + return this.#version; + } + get built(): boolean { + return this.#snapshot !== null; + } + + /** + * Build or rebuild from live context. + * Returns `true` if the prefix actually changed (cache miss imminent). + */ + build(context: AgentContext): boolean { + const snapshot = takeSnapshot(context); + if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) { + return false; + } + this.#snapshot = snapshot; + this.#version++; + return true; + } + + /** Force rebuild on the next `build()` call. */ + invalidate(): void { + this.#snapshot = null; + } + + /** + * Returns the cached prefix. + * @throws if `build()` was never called. + */ + toContext(): { systemPrompt: string[]; tools: Tool[] } { + const s = this.#snapshot; + if (!s) throw new Error("StablePrefix.toContext() called before build()"); + return { systemPrompt: s.systemPrompt, tools: s.tools }; + } +} + +// --------------------------------------------------------------------------- +// AppendOnlyLog +// --------------------------------------------------------------------------- + +/** + * Append-only message log at the `Message[]` (provider-level) layer. + * + * The only mutation path is `replaceTail()`, reserved for compaction. + * Every other operation is append-only. + */ +export class AppendOnlyLog { + #entries: Message[] = []; + + get length(): number { + return this.#entries.length; + } + + append(message: any): void { + this.#entries.push(message); + } + + extend(messages: any[]): void { + for (const m of messages) this.#entries.push(m); + } + + /** Replace the last entry — only legal for compaction. */ + replaceTail(replacement: any): void { + const idx = this.#entries.length - 1; + if (idx >= 0) this.#entries[idx] = replacement; + } + + /** Returns a shallow copy of all entries. */ + toMessages(): Message[] { + return this.#entries.slice(); + } + + /** Direct readonly access for in-place inspection. */ + entries(): readonly Message[] { + return this.#entries; + } + + clear(): void { + this.#entries = []; + } +} + +// --------------------------------------------------------------------------- +// AppendOnlyContextManager +// --------------------------------------------------------------------------- + +/** + * Manages a stable prefix + append-only log for the agent loop. + * + * Call `build(context)` each turn to get a `Context` with stable + * `systemPrompt` and `tools` and append-only messages. Call + * `syncMessages(normalizedMessages)` after `convertToLlm` each + * turn to keep the log in sync. + * + * Example: + * ``` + * const mgr = new AppendOnlyContextManager(); + * const ctx = mgr.build(context); // first call snapshots prefix + * mgr.syncMessages(normalized); // grow the log + * ctx = mgr.build(context); // subsequent calls use cache + * ``` + */ +export class AppendOnlyContextManager { + readonly prefix = new StablePrefix(); + readonly log = new AppendOnlyLog(); + /** How many normalized messages were synced into the log as of the last sync. */ + #lastSyncCount = 0; + /** Rolling digest of synced message content — detects in-place rewrites. */ + #syncedDigest = 0; + + build(context: AgentContext): Context { + this.prefix.build(context); + const { systemPrompt, tools } = this.prefix.toContext(); + return { systemPrompt, messages: this.log.toMessages(), tools }; + } + + /** + * Sync normalized (provider-level) messages into the append-only log. + * + * Detects both compaction (shorter array) and in-place rewrites + * (same length, changed content via a rolling digest). + */ + syncMessages(normalizedMessages: any[]): void { + // Detect in-place rewrites of already-synced messages. + if ( + this.#lastSyncCount > 0 && + this.#lastSyncCount <= normalizedMessages.length && + this.#computeDigest(normalizedMessages.slice(0, this.#lastSyncCount)) !== this.#syncedDigest + ) { + this.log.clear(); + this.#lastSyncCount = 0; + } + + // Compaction — array shrunk. + if (normalizedMessages.length < this.#lastSyncCount) { + this.log.clear(); + this.#lastSyncCount = 0; + } + + const newMsgs = normalizedMessages.slice(this.#lastSyncCount); + for (const msg of newMsgs) { + this.log.append(msg); + } + + this.#lastSyncCount = normalizedMessages.length; + this.#syncedDigest = this.#computeDigest(normalizedMessages); + } + + /** Reset prefix + log for a model/provider switch while mode stays active. */ + invalidateForModelChange(): void { + this.prefix.invalidate(); + this.log.clear(); + this.#lastSyncCount = 0; + this.#syncedDigest = 0; + } + + /** Reset the sync cursor AND clear the log. */ + resetSyncCursor(): void { + this.log.clear(); + this.#lastSyncCount = 0; + this.#syncedDigest = 0; + } + + appendMessage(message: any): void { + this.log.append(message); + } + + replaceTailMessage(message: any): void { + this.log.replaceTail(message); + } + + invalidate(): void { + this.prefix.invalidate(); + } + + reset(context: AgentContext): void { + this.prefix.invalidate(); + this.log.clear(); + this.#lastSyncCount = 0; + this.#syncedDigest = 0; + this.prefix.build(context); + } + + /** Fast rolling digest of message content. */ + #computeDigest(messages: any[]): number { + let hash = 0; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (msg && typeof msg === "object") { + const payload = + String(msg.role) + (typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? "")); + for (let j = 0; j < payload.length; j++) { + hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0; + } + } + } + return hash >>> 0; + } +} + +// --------------------------------------------------------------------------- +// Snapshot helpers +// --------------------------------------------------------------------------- + +/** + * Produce a stable serialization of tools that matches what + * `normalizeTools(tools, false)` outputs (no intent injection). + * + * The spread `{ ...agentTool }` preserves all own enumerable properties + * that survive JSON.stringify — functions are dropped, but strings, + * booleans, objects are included. + */ +function normalizeTool(t: AgentTool): Tool { + const description = t.description ?? ""; + return { ...t, parameters: t.parameters, description }; +} + +function takeSnapshot(context: AgentContext): StablePrefixSnapshot { + const systemPrompt = [...context.systemPrompt]; + const tools = (context.tools ?? []).map(normalizeTool); + return { + systemPrompt, + tools, + fingerprint: computeFingerprint(systemPrompt, tools), + }; +} + +function computeFingerprint(systemPrompt: string[], tools: Tool[]): string { + const payload = JSON.stringify({ + s: systemPrompt, + t: tools.map(t => ({ + n: t.name, + d: t.description, + p: t.parameters, + s: t.strict, + cf: t.customFormat, + cw: t.customWireName, + })), + }); + let hash = 0; + for (let i = 0; i < payload.length; i++) { + hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 6a17b5d88..30331ee37 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,6 +2,8 @@ export * from "./agent"; // Loop functions export * from "./agent-loop"; +// Append-only context mode +export * from "./append-only-context"; // Compaction export * from "./compaction"; export * from "./harmony-leak"; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 0b2c3bcc9..c159b363e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -15,6 +15,7 @@ import type { ToolResultMessage, TSchema, } from "@oh-my-pi/pi-ai"; +import type { AppendOnlyContextManager } from "./append-only-context"; import type { HarmonyAuditEvent } from "./harmony-leak"; import type { AgentRunCoverage, AgentRunSummary } from "./run-collector"; import type { AgentTelemetryConfig } from "./telemetry"; @@ -154,6 +155,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * then strips from arguments before executing tools. */ intentTracing?: boolean; + /** + * Append-only context mode — stabilizes system prompt + tool spec bytes + * across turns so provider prefix caches hit at maximum rate. + * + * When set, the loop reads messages from the append-only log (stable + * byte prefix) and caches system prompt + tools. Tools exclude per-turn + * `_i` intent fields. + */ + appendOnlyContext?: AppendOnlyContextManager; /** * Inspect assistant streaming events before they are published to the outer agent event stream. diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts new file mode 100644 index 000000000..efe8b9949 --- /dev/null +++ b/packages/agent/test/append-only-context.test.ts @@ -0,0 +1,578 @@ +import { describe, expect, it } from "bun:test"; +import type { Message, Tool } from "@oh-my-pi/pi-ai"; +import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "../src/append-only-context"; +import type { AgentContext, AgentTool } from "../src/types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeContext(overrides?: Partial): AgentContext { + return { + systemPrompt: ["You are a helpful assistant.", "Be concise."], + messages: [], + tools: [], + ...overrides, + }; +} + +function makeTool(name: string, description?: string, parameters?: Record): AgentTool { + return { + name, + description: description ?? `Tool ${name}`, + parameters: parameters ?? { type: "object", properties: {} }, + label: name, + execute: async () => ({ content: [{ type: "text", text: "done" }] }), + } as AgentTool; +} + +// --------------------------------------------------------------------------- +// StablePrefix +// --------------------------------------------------------------------------- + +describe("StablePrefix", () => { + it("builds and returns cached system prompt + tools", () => { + const p = new StablePrefix(); + const ctx = makeContext({ + systemPrompt: ["You are a helpful assistant."], + tools: [makeTool("read")], + }); + + const changed = p.build(ctx); + expect(changed).toBe(true); + expect(p.built).toBe(true); + + const { systemPrompt, tools } = p.toContext(); + expect(systemPrompt).toEqual(["You are a helpful assistant."]); + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe("read"); + }); + + it("returns false on identical rebuild", () => { + const p = new StablePrefix(); + const ctx = makeContext({ systemPrompt: ["Hello"] }); + + p.build(ctx); + const changed = p.build(ctx); + expect(changed).toBe(false); + }); + + it("returns true when system prompt changes", () => { + const p = new StablePrefix(); + const ctx = makeContext({ systemPrompt: ["Old prompt"] }); + p.build(ctx); + + const changed = p.build(makeContext({ systemPrompt: ["New prompt"] })); + expect(changed).toBe(true); + }); + + it("returns true when tools change", () => { + const p = new StablePrefix(); + p.build(makeContext({ tools: [makeTool("read")] })); + + const changed = p.build(makeContext({ tools: [makeTool("read"), makeTool("write")] })); + expect(changed).toBe(true); + }); + + it("returns true when tool description changes", () => { + const p = new StablePrefix(); + p.build(makeContext({ tools: [makeTool("read", "Original desc")] })); + + const changed = p.build(makeContext({ tools: [makeTool("read", "Updated desc")] })); + expect(changed).toBe(true); + }); + + it("invalidate forces rebuild", () => { + const p = new StablePrefix(); + const ctx = makeContext({ systemPrompt: ["Stable"] }); + p.build(ctx); + + p.invalidate(); + expect(p.built).toBe(false); + + const changed = p.build(ctx); + expect(changed).toBe(true); + }); + + it("toContext() throws when not built", () => { + const p = new StablePrefix(); + expect(() => p.toContext()).toThrow("build()"); + }); + + it("fingerprint changes across rebuilds", () => { + const p = new StablePrefix(); + const ctx1 = makeContext({ systemPrompt: ["Prompt A"] }); + p.build(ctx1); + const fp1 = p.fingerprint; + + const ctx2 = makeContext({ systemPrompt: ["Prompt B"] }); + p.build(ctx2); + const fp2 = p.fingerprint; + + expect(fp1).not.toBe(fp2); + }); + + it("fingerprint stable for identical context", () => { + const p = new StablePrefix(); + p.build(makeContext({ systemPrompt: ["Stable"], tools: [makeTool("foo")] })); + const fp1 = p.fingerprint; + + p.build(makeContext({ systemPrompt: ["Stable"], tools: [makeTool("foo")] })); + const fp2 = p.fingerprint; + + expect(fp1).toBe(fp2); + }); + + it("version increases on each rebuild", () => { + const p = new StablePrefix(); + expect(p.version).toBe(0); + + p.build(makeContext({ systemPrompt: ["V1"] })); + expect(p.version).toBe(1); + + p.build(makeContext({ systemPrompt: ["V2"] })); + expect(p.version).toBe(2); + + p.build(makeContext({ systemPrompt: ["V2"] })); + expect(p.version).toBe(2); // unchanged = no increment + }); +}); + +// --------------------------------------------------------------------------- +// AppendOnlyLog +// --------------------------------------------------------------------------- + +describe("AppendOnlyLog", () => { + it("starts empty", () => { + const log = new AppendOnlyLog(); + expect(log.length).toBe(0); + expect(log.toMessages()).toEqual([]); + }); + + it("appends messages", () => { + const log = new AppendOnlyLog(); + log.append({ role: "user", content: "hello" } as any); + log.append({ role: "assistant", content: "world" } as any); + expect(log.length).toBe(2); + expect(log.toMessages()).toHaveLength(2); + }); + + it("toMessages returns a copy of the array", () => { + const log = new AppendOnlyLog(); + const msg = { role: "user", content: "test" }; + log.append(msg); + const msgs = log.toMessages(); + // Array is a copy — mutating it doesn't affect the log + msgs.pop(); + expect(log.length).toBe(1); + }); + + it("replaceTail replaces last entry", () => { + const log = new AppendOnlyLog(); + log.append({ role: "user", content: "old" }); + log.replaceTail({ role: "user", content: "new" }); + expect(log.toMessages()).toHaveLength(1); + expect(log.toMessages()[0]!.content).toBe("new"); + }); + + it("replaceTail is no-op on empty log", () => { + const log = new AppendOnlyLog(); + log.replaceTail({ role: "user", content: "nope" }); + expect(log.length).toBe(0); + }); + + it("extend appends multiple messages", () => { + const log = new AppendOnlyLog(); + log.extend([ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ]); + expect(log.length).toBe(2); + }); + + it("clear resets the log", () => { + const log = new AppendOnlyLog(); + log.append({ role: "user", content: "x" }); + log.clear(); + expect(log.length).toBe(0); + }); + + it("entries readonly access returns internal array", () => { + const log = new AppendOnlyLog(); + log.append({ role: "user", content: "test" }); + expect(log.entries()).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// AppendOnlyContextManager +// --------------------------------------------------------------------------- + +describe("AppendOnlyContextManager", () => { + it("build() returns context with stable prefix on first call", () => { + const mgr = new AppendOnlyContextManager(); + const ctx = makeContext({ + systemPrompt: ["You are a bot."], + tools: [makeTool("read")], + }); + + const result = mgr.build(ctx); + + expect(result.systemPrompt).toEqual(["You are a bot."]); + expect(result.tools).toHaveLength(1); + expect(result.messages).toEqual([]); + }); + + it("build() returns same systemPrompt and tools on subsequent calls", () => { + const mgr = new AppendOnlyContextManager(); + const ctx = makeContext({ + systemPrompt: ["Original prompt"], + tools: [makeTool("read")], + }); + + mgr.build(ctx); + + // Same context — should reuse cached prefix + const result = mgr.build(ctx); + expect(result.systemPrompt).toEqual(["Original prompt"]); + expect(result.tools).toHaveLength(1); + }); + + it("build() detects changed system prompt and rebuilds", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext({ systemPrompt: ["Old"] })); + + const result = mgr.build(makeContext({ systemPrompt: ["New"] })); + expect(result.systemPrompt).toEqual(["New"]); + }); + + it("prefix.fingerprint changes when tools change", () => { + const mgr = new AppendOnlyContextManager(); + + mgr.build(makeContext({ tools: [makeTool("read")] })); + const fp1 = mgr.prefix.fingerprint; + + mgr.build(makeContext({ tools: [makeTool("read"), makeTool("write")] })); + const fp2 = mgr.prefix.fingerprint; + + expect(fp1).not.toBe(fp2); + }); + + it("appendMessage grows the log", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + mgr.appendMessage({ role: "user", content: "hello" } as any); + mgr.appendMessage({ role: "assistant", content: "world" } as any); + + const result = mgr.build(makeContext()); + expect(result.messages).toHaveLength(2); + expect(result.messages[0]!.role).toBe("user"); + expect(result.messages[1]!.role).toBe("assistant"); + }); + + it("appendMessage messages appear in every subsequent build()", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + mgr.appendMessage({ role: "user", content: "q1" }); + const r1 = mgr.build(makeContext()); + expect(r1.messages).toHaveLength(1); + + mgr.appendMessage({ role: "assistant", content: "a1" }); + const r2 = mgr.build(makeContext()); + expect(r2.messages).toHaveLength(2); + expect(r2.messages[1]!.content).toBe("a1"); + }); + + it("invalidate forces prefix rebuild", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext({ systemPrompt: ["V1"] })); + + mgr.invalidate(); + const result = mgr.build(makeContext({ systemPrompt: ["V2"] })); + expect(result.systemPrompt).toEqual(["V2"]); + }); + + it("reset clears log and prefix", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext({ systemPrompt: ["Original"] })); + mgr.appendMessage({ role: "user", content: "hello" }); + + const freshCtx = makeContext({ systemPrompt: ["Fresh start"] }); + mgr.reset(freshCtx); + + const result = mgr.build(freshCtx); + expect(result.systemPrompt).toEqual(["Fresh start"]); + expect(result.messages).toHaveLength(0); + }); + + it("replaceTailMessage updates last log entry", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + mgr.appendMessage({ role: "user", content: "old" }); + mgr.replaceTailMessage({ role: "user", content: "new" }); + + const result = mgr.build(makeContext()); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.content).toBe("new"); + }); + + it("build propagates tool spec description default", () => { + const mgr = new AppendOnlyContextManager(); + const toolWithNoDesc = makeTool("bare"); + delete (toolWithNoDesc as any).description; + + const ctx = makeContext({ tools: [toolWithNoDesc] }); + const result = mgr.build(ctx); + + const tool: Tool | undefined = result.tools?.[0]; + expect(tool).toBeDefined(); + expect(tool!.description).toBe(""); + }); + + it("tools returned from build are frozen in the cache", () => { + const mgr = new AppendOnlyContextManager(); + const ctx = makeContext({ tools: [makeTool("read")] }); + + const r1 = mgr.build(ctx); + const r2 = mgr.build(ctx); + + expect(r1.tools).toHaveLength(1); + expect(r2.tools).toHaveLength(1); + // Same name, same structure + expect(r1.tools![0]!.name).toBe(r2.tools![0]!.name); + }); + + it("tolerates context with no tools", () => { + const mgr = new AppendOnlyContextManager(); + const ctx = makeContext({ tools: undefined as any }); + + const result = mgr.build(ctx); + expect(result.tools).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Fingerprint determinism +// --------------------------------------------------------------------------- + +describe("fingerprint determinism", () => { + it("identical context produces identical fingerprint", () => { + const p1 = new StablePrefix(); + const p2 = new StablePrefix(); + + const ctx = makeContext({ + systemPrompt: ["Rule 1", "Rule 2"], + tools: [makeTool("read", "Read files"), makeTool("edit", "Edit files")], + }); + + p1.build(ctx); + p2.build(ctx); + + expect(p1.fingerprint).toBe(p2.fingerprint); + }); + + it("tool order changes fingerprint", () => { + const p1 = new StablePrefix(); + const p2 = new StablePrefix(); + + const tools = [makeTool("a", "Tool A"), makeTool("b", "Tool B")]; + p1.build(makeContext({ tools })); + + // Create a context where tool b has "Tool B" too + // so the fingerprint changes with name order + const otherTools = [makeTool("b", "Tool B"), makeTool("a", "Tool A")]; + p2.build(makeContext({ tools: otherTools })); + + expect(p1.fingerprint).not.toBe(p2.fingerprint); + }); + + it("system prompt array structure changes fingerprint", () => { + const p1 = new StablePrefix(); + const p2 = new StablePrefix(); + + // ["A", "B"] and ["A\nB"] have the same joined text but different + // array structure — must produce different fingerprints. + p1.build(makeContext({ systemPrompt: ["A", "B"] })); + p2.build(makeContext({ systemPrompt: ["A\nB"] })); + + expect(p1.fingerprint).not.toBe(p2.fingerprint); + }); +}); + +// --------------------------------------------------------------------------- +// AppendOnlyLog message sync +// --------------------------------------------------------------------------- + +describe("message sync", () => { + it("syncMessages on first call appends all messages", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + const msgs: Message[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi" }, + ] as any; + mgr.syncMessages(msgs); + + const result = mgr.build(makeContext()); + expect(result.messages).toHaveLength(2); + expect(result.messages[0]!.content).toBe("Hello"); + expect(result.messages[1]!.content).toBe("Hi"); + }); + + it("syncMessages on subsequent calls only appends delta", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + mgr.syncMessages([{ role: "user", content: "q1" }]); + const r1 = mgr.build(makeContext()); + expect(r1.messages).toHaveLength(1); + + mgr.syncMessages([ + { role: "user", content: "q1" }, + { role: "assistant", content: "a1" }, + ]); + const r2 = mgr.build(makeContext()); + expect(r2.messages).toHaveLength(2); + expect(r2.messages[1]!.content).toBe("a1"); + }); + + it("syncMessages with unchanged messages is a no-op (same length, no new entries)", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + mgr.syncMessages([{ role: "user", content: "q1" }]); + + const before = mgr.log.length; + + // Same array length → nothing new to append + mgr.syncMessages([{ role: "user", content: "q1" }]); + expect(mgr.log.length).toBe(before); + }); + + it("syncMessages resets log when array shrinks (compaction)", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + mgr.syncMessages([ + { role: "user", content: "q1" }, + { role: "assistant", content: "a1" }, + { role: "user", content: "q2" }, + ]); + expect(mgr.log.length).toBe(3); + + // Simulate compaction — array shrinks + mgr.syncMessages([{ role: "user", content: "q2" }]); + expect(mgr.log.length).toBe(1); + expect(mgr.log.toMessages()[0]!.content).toBe("q2"); + }); + + it("build + syncMessages integration: messages come from log, not from context.messages", () => { + const mgr = new AppendOnlyContextManager(); + + // First turn: build with empty context, sync first message + mgr.build(makeContext()); + mgr.syncMessages([{ role: "user", content: "turn1" }]); + const r1 = mgr.build(makeContext()); + expect(r1.messages).toHaveLength(1); + expect(r1.messages[0]!.content).toBe("turn1"); + + // Second turn: sync second message + mgr.syncMessages([ + { role: "user", content: "turn1" }, + { role: "assistant", content: "resp1" }, + ]); + const r2 = mgr.build(makeContext()); + expect(r2.messages).toHaveLength(2); + expect(r2.messages[1]!.content).toBe("resp1"); + }); + + it("resetSyncCursor forces full re-sync on next call", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + mgr.syncMessages([{ role: "user", content: "old" }]); + + mgr.resetSyncCursor(); + mgr.syncMessages([{ role: "user", content: "fresh" }]); + + const result = mgr.build(makeContext()); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.content).toBe("fresh"); + }); + + it("detects in-place rewrite of already-synced messages", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + // Sync two messages + mgr.syncMessages([ + { role: "user", content: "q1" }, + { role: "assistant", content: "original long result" }, + ]); + expect(mgr.log.length).toBe(2); + + // Same length, but second message content changed (simulates tool-output pruning) + mgr.syncMessages([ + { role: "user", content: "q1" }, + { role: "assistant", content: "[pruned]" }, + ]); + // Log should have been reset and re-synced with the new content + expect(mgr.log.length).toBe(2); + const msgs = mgr.build(makeContext()).messages; + expect(msgs[1]!.content).toBe("[pruned]"); + }); + + it("detects in-place rewrite via digest mismatch", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + mgr.syncMessages([{ role: "user", content: "hello" }]); + expect(mgr.log.length).toBe(1); + + // Content changed but length same + mgr.syncMessages([{ role: "user", content: "world" }]); + + const msgs = mgr.build(makeContext()).messages; + expect(msgs).toHaveLength(1); + expect(msgs[0]!.content).toBe("world"); + }); + + it("no-op when content unchanged", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext()); + + mgr.syncMessages([ + { role: "user", content: "q1" }, + { role: "assistant", content: "a1" }, + ]); + + const before = mgr.log.length; + mgr.syncMessages([ + { role: "user", content: "q1" }, + { role: "assistant", content: "a1" }, + ]); + // Length unchanged — no new messages appended, no clear + expect(mgr.log.length).toBe(before); + }); + + it("invalidateForModelChange resets prefix and log", () => { + const mgr = new AppendOnlyContextManager(); + mgr.build(makeContext({ systemPrompt: ["Before"] })); + mgr.syncMessages([{ role: "user", content: "hello" }]); + + mgr.invalidateForModelChange(); + + // Should need a fresh build — prefix was invalidated + const ctx = makeContext({ systemPrompt: ["After"] }); + const result = mgr.build(ctx); + expect(result.systemPrompt).toEqual(["After"]); + expect(result.messages).toHaveLength(0); + + // Re-sync should work cleanly + mgr.syncMessages([{ role: "user", content: "new turn" }]); + const r2 = mgr.build(ctx); + expect(r2.messages).toHaveLength(1); + expect(r2.messages[0]!.content).toBe("new turn"); + }); +}); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b98e0c032..ced6f1c2d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added DeepSeek to the built-in API-key login provider catalog so `omp login deepseek` stores a reusable `DEEPSEEK_API_KEY` credential for the bundled DeepSeek models. + ## [15.2.4] - 2026-05-22 ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 654e54f23..2d1a62168 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -29,6 +29,7 @@ import { kimiUsageProvider } from "./usage/kimi"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; import { zaiUsageProvider } from "./usage/zai"; import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth"; +import { loginDeepSeek } from "./utils/oauth/deepseek"; import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex"; import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types"; @@ -1378,6 +1379,11 @@ export class AuthStorage { await saveApiKeyCredential(apiKey); return; } + case "deepseek": { + const apiKey = await loginDeepSeek(ctrl); + await saveApiKeyCredential(apiKey); + return; + } case "fireworks": { const { loginFireworks } = await import("./utils/oauth/fireworks"); const apiKey = await loginFireworks(ctrl); diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index c5f4789f2..66c5c69db 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -109,6 +109,7 @@ Providers: kagi Kagi tavily Tavily zai Z.AI (GLM Coding Plan) + deepseek DeepSeek nanogpt NanoGPT minimax-code MiniMax Coding Plan (International) minimax-code-cn MiniMax Coding Plan (China) diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index 8782b75de..ccda9ca9c 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -52450,9 +52450,9 @@ "mimo-v2-flash": { "id": "mimo-v2-flash", "name": "MiMo-V2-Flash", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "xiaomi", - "baseUrl": "https://api.xiaomimimo.com/anthropic", + "baseUrl": "https://api.xiaomimimo.com/v1", "reasoning": true, "input": [ "text" @@ -52474,9 +52474,9 @@ "mimo-v2-omni": { "id": "mimo-v2-omni", "name": "MiMo-V2-Omni", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "xiaomi", - "baseUrl": "https://api.xiaomimimo.com/anthropic", + "baseUrl": "https://api.xiaomimimo.com/v1", "reasoning": true, "input": [ "text", @@ -52499,9 +52499,9 @@ "mimo-v2-pro": { "id": "mimo-v2-pro", "name": "MiMo-V2-Pro", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "xiaomi", - "baseUrl": "https://api.xiaomimimo.com/anthropic", + "baseUrl": "https://api.xiaomimimo.com/v1", "reasoning": true, "input": [ "text" @@ -52523,9 +52523,9 @@ "mimo-v2.5": { "id": "mimo-v2.5", "name": "MiMo-V2.5", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "xiaomi", - "baseUrl": "https://api.xiaomimimo.com/anthropic", + "baseUrl": "https://api.xiaomimimo.com/v1", "reasoning": true, "input": [ "text", @@ -52548,9 +52548,9 @@ "mimo-v2.5-pro": { "id": "mimo-v2.5-pro", "name": "MiMo-V2.5-Pro", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "xiaomi", - "baseUrl": "https://api.xiaomimimo.com/anthropic", + "baseUrl": "https://api.xiaomimimo.com/v1", "reasoning": true, "input": [ "text" diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index 7239e6dd5..bdb5c18cf 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1408,28 +1408,28 @@ export interface XiaomiModelManagerConfig { export function xiaomiModelManagerOptions( config?: XiaomiModelManagerConfig, -): ModelManagerOptions<"anthropic-messages"> { +): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; // Xiaomi splits API keys across two backends: standard `sk-` keys hit - // api.xiaomimimo.com; "token plan" `tp-` keys hit the EU token-plan host. - // Both expose the same Anthropic-compat layout under /anthropic/v1/*. + // api.xiaomimimo.com; "token plan" `tp-` keys hit either the SG or EU + // token-plan host. Try SGP first; if discovery fails, retry AMS. + const TOKEN_PLAN_SGP_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1"; + const TOKEN_PLAN_AMS_BASE_URL = "https://token-plan-ams.xiaomimimo.com/v1"; const defaultBaseUrl = apiKey?.startsWith("tp-") - ? "https://token-plan-ams.xiaomimimo.com/anthropic" - : "https://api.xiaomimimo.com/anthropic"; - const baseUrl = normalizeAnthropicBaseUrl(config?.baseUrl, defaultBaseUrl); - // Xiaomi hosts chat completions under /anthropic/* but exposes model - // discovery at the OpenAI-style /v1/models endpoint on the root host. - const discoveryRoot = baseUrl.endsWith("/anthropic") ? baseUrl.slice(0, -"/anthropic".length) : baseUrl; - const discoveryBaseUrl = toAnthropicDiscoveryBaseUrl(discoveryRoot); - const references = createBundledReferenceMap<"anthropic-messages">("xiaomi"); + ? TOKEN_PLAN_SGP_BASE_URL + : "https://api.xiaomimimo.com/v1"; + // Token-plan keys always use the TP baseUrl; config?.baseUrl (from catalog) + // would incorrectly pin to the standard endpoint (api.xiaomimimo.com). + const baseUrl = apiKey?.startsWith("tp-") ? defaultBaseUrl : (config?.baseUrl ?? defaultBaseUrl); + const references = createBundledReferenceMap<"openai-completions">("xiaomi"); return { providerId: "xiaomi", ...(apiKey && { - fetchDynamicModels: () => - fetchOpenAICompatibleModels({ - api: "anthropic-messages", + fetchDynamicModels: async () => { + const sgpResult = await fetchOpenAICompatibleModels({ + api: "openai-completions", provider: "xiaomi", - baseUrl: discoveryBaseUrl, + baseUrl, apiKey, filterModel: (_entry, model) => !model.id.includes("-tts"), mapModel: (entry, defaults) => { @@ -1438,10 +1438,29 @@ export function xiaomiModelManagerOptions( return { ...model, name: toModelName(entry.display_name, model.name), - baseUrl, }; }, - }), + }); + if (sgpResult || !apiKey?.startsWith("tp-")) { + return sgpResult; + } + // Token-plan discovery failed with SGP; retry with AMS + return fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "xiaomi", + baseUrl: TOKEN_PLAN_AMS_BASE_URL, + apiKey, + filterModel: (_entry, model) => !model.id.includes("-tts"), + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id); + const model = mapWithBundledReference(entry, defaults, reference); + return { + ...model, + name: toModelName(entry.display_name, model.name), + }; + }, + }); + }, }), }; } diff --git a/packages/ai/src/utils/oauth/deepseek.ts b/packages/ai/src/utils/oauth/deepseek.ts new file mode 100644 index 000000000..b98074283 --- /dev/null +++ b/packages/ai/src/utils/oauth/deepseek.ts @@ -0,0 +1,16 @@ +/** DeepSeek login flow (API key paste against https://api.deepseek.com). */ +import { createApiKeyLogin } from "./api-key-login"; + +export const loginDeepSeek = createApiKeyLogin({ + providerLabel: "DeepSeek", + authUrl: "https://platform.deepseek.com/api_keys", + instructions: "Create or copy your API key from the DeepSeek dashboard", + promptMessage: "Paste your DeepSeek API key", + placeholder: "sk-...", + validation: { + kind: "chat-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com/v1", + model: "deepseek-v4-pro", + }, +}); diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 6213514b1..f114cfa63 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -55,6 +55,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [ name: "Cerebras", available: true, }, + { + id: "deepseek", + name: "DeepSeek", + available: true, + }, { id: "fireworks", name: "Fireworks", diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index 3789b4188..2d8d0fe4d 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -14,6 +14,7 @@ export type OAuthProvider = | "cerebras" | "cloudflare-ai-gateway" | "cursor" + | "deepseek" | "fireworks" | "firepass" | "github-copilot" diff --git a/packages/ai/src/utils/oauth/xiaomi.ts b/packages/ai/src/utils/oauth/xiaomi.ts index f32796080..57d43fa11 100644 --- a/packages/ai/src/utils/oauth/xiaomi.ts +++ b/packages/ai/src/utils/oauth/xiaomi.ts @@ -1,8 +1,8 @@ /** * Xiaomi MiMo login flow. * - * Xiaomi MiMo provides Anthropic-compatible models via - * https://api.xiaomimimo.com/anthropic. + * Xiaomi MiMo provides OpenAI-compatible models via + * https://api.xiaomimimo.com/v1. * * This is not OAuth - it's a simple API key flow: * 1. Open browser to Xiaomi MiMo API key console @@ -15,8 +15,9 @@ import type { OAuthController } from "./types"; const PROVIDER_ID = "xiaomi"; const PROVIDER_NAME = "Xiaomi MiMo"; const STANDARD_AUTH_URL = "https://platform.xiaomimimo.com/#/console/api-keys"; -const STANDARD_API_BASE_URL = "https://api.xiaomimimo.com/anthropic"; -const TOKEN_PLAN_API_BASE_URL = "https://token-plan-ams.xiaomimimo.com/anthropic"; +const STANDARD_API_BASE_URL = "https://api.xiaomimimo.com/v1"; +const TOKEN_PLAN_SGP_API_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1"; +const TOKEN_PLAN_AMS_API_BASE_URL = "https://token-plan-ams.xiaomimimo.com/v1"; const TOKEN_PLAN_KEY_PREFIX = "tp-"; const STANDARD_VALIDATION_MODEL = "mimo-v2-flash"; const TOKEN_PLAN_VALIDATION_MODEL = "mimo-v2.5"; @@ -25,50 +26,82 @@ function isTokenPlanKey(apiKey: string): boolean { return apiKey.startsWith(TOKEN_PLAN_KEY_PREFIX); } -function resolveEndpoint(apiKey: string): { baseUrl: string; model: string } { - if (isTokenPlanKey(apiKey)) { - return { baseUrl: TOKEN_PLAN_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL }; - } - return { baseUrl: STANDARD_API_BASE_URL, model: STANDARD_VALIDATION_MODEL }; -} -const ANTHROPIC_VERSION = "2023-06-01"; const VALIDATION_TIMEOUT_MS = 15_000; async function validateXiaomiApiKey(apiKey: string, signal?: AbortSignal): Promise { const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS); const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; - const { baseUrl, model } = resolveEndpoint(apiKey); - const response = await fetch(`${baseUrl}/v1/messages`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - "anthropic-version": ANTHROPIC_VERSION, - }, - body: JSON.stringify({ - model, - max_tokens: 1, - messages: [{ role: "user", content: "ping" }], - }), - signal: requestSignal, - }); + // For token-plan keys try SGP first, then AMS as fallback. + // Standard sk- keys only hit the one endpoint. + const endpoints = isTokenPlanKey(apiKey) + ? [ + { baseUrl: TOKEN_PLAN_SGP_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL }, + { baseUrl: TOKEN_PLAN_AMS_API_BASE_URL, model: TOKEN_PLAN_VALIDATION_MODEL }, + ] + : [{ baseUrl: STANDARD_API_BASE_URL, model: STANDARD_VALIDATION_MODEL }]; - if (response.ok) { - return; + let lastError: Error | null = null; + + for (const ep of endpoints) { + try { + const response = await fetch(`${ep.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + model: ep.model, + max_tokens: 1, + messages: [{ role: "user", content: "ping" }], + }), + signal: requestSignal, + }); + + if (response.ok) { + return; + } + + // 401 means this endpoint didn't accept the key; try the next one + if (response.status === 401) { + let details = ""; + try { + details = (await response.text()).trim(); + } catch { + // ignore body parse errors, status is enough + } + lastError = new Error( + details + ? `${PROVIDER_NAME} API key validation failed (${response.status}): ${details}` + : `${PROVIDER_NAME} API key validation failed (${response.status})`, + ); + continue; + } + + // Non-auth errors are real failures + let details = ""; + try { + details = (await response.text()).trim(); + } catch { + // ignore body parse errors, status is enough + } + const message = details + ? `${PROVIDER_NAME} API key validation failed (${response.status}): ${details}` + : `${PROVIDER_NAME} API key validation failed (${response.status})`; + throw new Error(message); + } catch (e) { + // Only re-throw AbortError when the caller explicitly cancelled. + // Timeout aborts (from AbortSignal.timeout) should fall through to + // the next endpoint so SGP→AMS fallback works during regional outages. + if (e instanceof DOMException && e.name === "AbortError" && signal?.aborted) { + throw e; + } + lastError = e instanceof Error ? e : new Error(String(e)); + continue; + } } - - let details = ""; - try { - details = (await response.text()).trim(); - } catch { - // ignore body parse errors, status is enough - } - - const message = details - ? `${PROVIDER_NAME} API key validation failed (${response.status}): ${details}` - : `${PROVIDER_NAME} API key validation failed (${response.status})`; - throw new Error(message); + throw lastError ?? new Error(`${PROVIDER_NAME} API key validation failed`); } /** diff --git a/packages/ai/test/auth-storage-api-key-login.test.ts b/packages/ai/test/auth-storage-api-key-login.test.ts index 62e3a5a7d..16be149b6 100644 --- a/packages/ai/test/auth-storage-api-key-login.test.ts +++ b/packages/ai/test/auth-storage-api-key-login.test.ts @@ -5,6 +5,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage"; +import * as deepseekModule from "../src/utils/oauth/deepseek"; import * as kagiModule from "../src/utils/oauth/kagi"; import * as ollamaCloudModule from "../src/utils/oauth/ollama-cloud"; @@ -25,6 +26,7 @@ describe("AuthStorage api-key login replacement", () => { let dbPath = ""; let store: SqliteAuthCredentialStore | null = null; let authStorage: AuthStorage | null = null; + let loginDeepSeekSpy: Mock; let loginKagiSpy: Mock; let loginOllamaCloudSpy: Mock; @@ -33,6 +35,7 @@ describe("AuthStorage api-key login replacement", () => { dbPath = path.join(tempDir, "agent.db"); store = await SqliteAuthCredentialStore.open(dbPath); authStorage = new AuthStorage(store); + loginDeepSeekSpy = vi.spyOn(deepseekModule, "loginDeepSeek"); loginKagiSpy = vi.spyOn(kagiModule, "loginKagi"); loginOllamaCloudSpy = vi.spyOn(ollamaCloudModule, "loginOllamaCloud"); }); @@ -100,4 +103,30 @@ describe("AuthStorage api-key login replacement", () => { expect(store.getApiKey("ollama-cloud")).toBe("same-ollama-cloud-key"); expect(await authStorage.getApiKey("ollama-cloud", "session-ollama-cloud-relogin")).toBe("same-ollama-cloud-key"); }); + + it("stores DeepSeek login credentials as a reusable api-key credential", async () => { + if (!store || !authStorage || !dbPath) throw new Error("test setup failed"); + + loginDeepSeekSpy.mockResolvedValueOnce("same-deepseek-key").mockResolvedValueOnce("same-deepseek-key"); + + const controller = { + onAuth: () => {}, + onPrompt: async () => "", + }; + + await authStorage.login("deepseek", controller); + await authStorage.login("deepseek", controller); + + expect(countCredentialRows(dbPath, "deepseek")).toBe(1); + const credentials = store.listAuthCredentials("deepseek"); + expect(credentials).toHaveLength(1); + const [stored] = credentials; + expect(stored?.credential.type).toBe("api_key"); + if (!stored || stored.credential.type !== "api_key") { + throw new Error("expected stored api-key credential"); + } + expect(stored.credential.key).toBe("same-deepseek-key"); + expect(store.getApiKey("deepseek")).toBe("same-deepseek-key"); + expect(await authStorage.getApiKey("deepseek", "session-deepseek-relogin")).toBe("same-deepseek-key"); + }); }); diff --git a/packages/ai/test/issue-772-repro.test.ts b/packages/ai/test/issue-772-repro.test.ts index 5fa2646ba..346fc2e19 100644 --- a/packages/ai/test/issue-772-repro.test.ts +++ b/packages/ai/test/issue-772-repro.test.ts @@ -4,11 +4,12 @@ import { hookFetch } from "@oh-my-pi/pi-utils"; import { xiaomiModelManagerOptions } from "../src/provider-models/openai-compat"; import { loginXiaomi } from "../src/utils/oauth/xiaomi"; -const TOKEN_PLAN_HOST = "token-plan-ams.xiaomimimo.com"; +const TOKEN_PLAN_SGP_HOST = "token-plan-sgp.xiaomimimo.com"; +const TOKEN_PLAN_AMS_HOST = "token-plan-ams.xiaomimimo.com"; const STANDARD_HOST = "api.xiaomimimo.com"; describe("issue-772: Xiaomi MiMo token-plan (tp-) keys", () => { - it("loginXiaomi validates tp- keys against the token-plan host", async () => { + it("loginXiaomi validates tp- keys against the SGP token-plan host first", async () => { const seen: string[] = []; using _hook = hookFetch(input => { seen.push(String(input)); @@ -23,11 +24,11 @@ describe("issue-772: Xiaomi MiMo token-plan (tp-) keys", () => { expect(seen).toHaveLength(1); const url = seen[0]!; - expect(url).toContain(TOKEN_PLAN_HOST); - expect(url).not.toContain(`${STANDARD_HOST}/`); + expect(url).toContain(TOKEN_PLAN_SGP_HOST); + expect(url).toContain("/chat/completions"); }); - it("xiaomiModelManagerOptions discovers models from the token-plan host when given a tp- key", async () => { + it("xiaomiModelManagerOptions discovers models from the SGP token-plan host when given a tp- key", async () => { const seen: string[] = []; using _hook = hookFetch(input => { seen.push(String(input)); @@ -42,8 +43,8 @@ describe("issue-772: Xiaomi MiMo token-plan (tp-) keys", () => { expect(seen.length).toBeGreaterThan(0); const url = seen[0]!; - expect(url).toContain(TOKEN_PLAN_HOST); - expect(url).not.toContain(`${STANDARD_HOST}/`); + expect(url).toContain(TOKEN_PLAN_SGP_HOST); + expect(url).toContain("/v1/models"); }); it("xiaomiModelManagerOptions still uses the standard host for sk- keys", async () => { @@ -62,6 +63,6 @@ describe("issue-772: Xiaomi MiMo token-plan (tp-) keys", () => { expect(seen.length).toBeGreaterThan(0); const url = seen[0]!; expect(url).toContain(STANDARD_HOST); - expect(url).not.toContain(TOKEN_PLAN_HOST); + expect(url).not.toContain(TOKEN_PLAN_SGP_HOST); }); }); diff --git a/packages/ai/test/issue-830-repro.test.ts b/packages/ai/test/issue-830-repro.test.ts index 24d5deaab..b0f9e1d91 100644 --- a/packages/ai/test/issue-830-repro.test.ts +++ b/packages/ai/test/issue-830-repro.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provide import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "../src/provider-models/openai-compat"; import { getEnvApiKey } from "../src/stream"; import type { OpenAICompat } from "../src/types"; +import { getOAuthProviders } from "../src/utils/oauth"; describe("deepseek built-in provider (issue #830)", () => { test("registers built-in runtime descriptor with DEEPSEEK_API_KEY env discovery", () => { @@ -13,6 +14,12 @@ describe("deepseek built-in provider (issue #830)", () => { expect(DEFAULT_MODEL_PER_PROVIDER.deepseek).toBe("deepseek-v4-pro"); }); + test("registers DeepSeek as an API-key login provider", () => { + const provider = getOAuthProviders().find(item => item.id === "deepseek"); + expect(provider?.name).toBe("DeepSeek"); + expect(provider?.available).toBe(true); + }); + test("resolves DEEPSEEK_API_KEY via env", () => { const previous = Bun.env.DEEPSEEK_API_KEY; Bun.env.DEEPSEEK_API_KEY = "deepseek-test-key"; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e3aefb639..b564c57d0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,9 @@ ### Added - Added `OMP_NO_WEBP` environment variable to disable WebP encoding in image resize, fixing HTTP 400 errors when attaching browser snapshots to vision models running on local llama.cpp (which uses STB library that lacks WebP support) +- Fixed loop mode submitting the next prompt while a background async-job delivery turn (idle flush) was still pending, which could cause the job result to be silently dropped and make the session appear to keep firing while work was ongoing ([#1294](https://github.com/can1357/oh-my-pi/issues/1294)) +- Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280)) +- Fixed append-only context mode not being recomputed after model switches — the mode was frozen at session construction time using the initial model's provider, so `provider.appendOnlyContext=auto` left append-only enabled after switching away from DeepSeek (or disabled after switching to DeepSeek) for the rest of the session ### Fixed - Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280)) diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 4d17f9b48..4a890e6d1 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2612,6 +2612,22 @@ export const SETTINGS_SCHEMA = { description: "Use Parallel extract API for URL fetching when credentials are available", }, }, + "provider.appendOnlyContext": { + type: "enum", + values: ["auto", "on", "off"] as const, + default: "auto", + ui: { + tab: "providers", + label: "Append-Only Context", + description: + "Cache system prompt + tool specs and keep an append-only message log so provider prefix caches (DeepSeek, Anthropic) hit at maximum rate. Auto enables for DeepSeek.", + options: [ + { value: "auto", label: "Auto", description: "Enable for DeepSeek (recommended)" }, + { value: "on", label: "On", description: "Always enable append-only context" }, + { value: "off", label: "Off", description: "Disable append-only context" }, + ], + }, + }, // Exa "exa.enabled": { diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index fe4b32838..85142fcd5 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -856,7 +856,26 @@ const SETTING_HOOKS: Partial>> = { setDefaultTabWidth(value); } }, + "provider.appendOnlyContext": value => { + if (typeof value === "string") { + for (const cb of appendOnlyModeCallbacks) cb(value); + } + }, }; +/** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */ +const appendOnlyModeCallbacks = new Set<(value: string) => void>(); + +/** + * Subscribe to append-only mode setting changes. + * Returns an unsubscribe function. Multiple sessions (main + subagents) + * can register independently without overwriting each other. + */ +export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void { + appendOnlyModeCallbacks.add(cb); + return () => { + appendOnlyModeCallbacks.delete(cb); + }; +} // ═══════════════════════════════════════════════════════════════════════════ // Global Singleton diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index a6c3f9c04..d1957d038 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -395,6 +395,15 @@ export class CommandController { info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`; info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`; info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`; + // Append-only context + { + const setting = this.ctx.settings.get("provider.appendOnlyContext") ?? "auto"; + const provider = this.ctx.session.model?.provider; + const mode = setting === "on" ? true : setting === "off" ? false : provider === "deepseek"; + const activeLabel = mode ? theme.fg("success", "active") : theme.fg("dim", "inactive"); + const settingLabel = setting === "auto" ? `${setting} (${provider ?? "?"})` : setting; + info += `${theme.fg("dim", "Append-Only:")} ${activeLabel} (setting: ${settingLabel})\n`; + } info += `${theme.bold("Tokens")}\n`; info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 4805f7444..264fe8807 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -691,7 +691,7 @@ export class InteractiveMode implements InteractiveModeContext { } #isLoopAutoSubmitBlocked(): boolean { - return this.session.isStreaming || this.session.isCompacting; + return this.session.isStreaming || this.session.isCompacting || this.session.hasPostPromptWork; } #submitLoopPromptWhenReady(prompt: string): void { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index a06ea56f1..d7fab6c0f 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -4,6 +4,7 @@ import { type AgentMessage, type AgentTelemetryConfig, type AgentTool, + AppendOnlyContextManager, INTENT_FIELD, type ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; @@ -589,6 +590,24 @@ function registerPythonCleanup(): void { postmortem.register("python-cleanup", disposeAllKernelSessions); } +/** + * Resolve whether to enable append-only context mode based on the setting and provider. + * + * - `"on"` → always enable + * - `"off"` → never enable + * - `"auto"` → enable for DeepSeek (prefix-caching provider) + */ +function resolveAppendOnlyMode(setting: "auto" | "on" | "off" | undefined, provider: string): boolean { + switch (setting ?? "auto") { + case "on": + return true; + case "off": + return false; + default: + return provider === "deepseek"; + } +} + function customToolToDefinition(tool: CustomTool): ToolDefinition { const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = { name: tool.name, @@ -1897,6 +1916,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} intentTracing: !!intentField, getToolChoice: () => session?.nextToolChoice(), telemetry: options.telemetry, + appendOnlyContext: model + ? resolveAppendOnlyMode(settings.get("provider.appendOnlyContext"), model.provider) + ? new AppendOnlyContextManager() + : undefined + : undefined, }); cursorEventEmitter = event => agent.emitExternalEvent(event); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 571a84f81..e8e72d35a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -26,6 +26,7 @@ import { type AgentMessage, type AgentState, type AgentTool, + AppendOnlyContextManager, resolveTelemetry, ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; @@ -98,6 +99,7 @@ import { } from "../config/model-resolver"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; +import { onAppendOnlyModeChanged } from "../config/settings"; import { RawSseDebugBuffer } from "../debug/raw-sse-buffer"; import { loadCapability } from "../discovery"; import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit"; @@ -1138,6 +1140,8 @@ export class AgentSession { // Always subscribe to agent events for internal handling // (session persistence, hooks, auto-compaction, retry logic) this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent); + // Re-evaluate append-only context mode when the setting changes at runtime. + onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model)); } /** Model registry for API key resolution and model discovery */ @@ -3573,6 +3577,18 @@ export class AgentSession { return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined; } + /** + * Whether idle-flush tasks, auto-continuations, or other short-lived + * post-prompt work are pending. True in the brief window after + * `session.prompt()` returns but before a scheduled background delivery + * (e.g. an async-job result) has finished its own streaming turn. + * Loop-mode and similar auto-submit paths should treat this as a block + * to avoid racing against the delivery turn. + */ + get hasPostPromptWork(): boolean { + return this.#postPromptTasks.size > 0; + } + /** All messages including custom types like BashExecutionMessage */ get messages(): AgentMessage[] { return this.agent.state.messages; @@ -5947,6 +5963,9 @@ export class AgentSession { this.#closeProviderSessionsForModelSwitch(currentModel, model); } this.agent.setModel(model); + + // Re-evaluate append-only context mode — provider or setting may have changed + this.#syncAppendOnlyContext(model); } #closeCodexProviderSessionsForHistoryRewrite(): void { @@ -5955,6 +5974,24 @@ export class AgentSession { this.#closeProviderSessionsForModelSwitch(currentModel, currentModel); } + /** + * Re-evaluate append-only context mode, creating or destroying the + * manager as needed. Called on model switch AND setting change. + */ + #syncAppendOnlyContext(model: Model | null | undefined): void { + const setting = this.settings.get("provider.appendOnlyContext") ?? "auto"; + const enable = setting === "on" || (setting === "auto" && model?.provider === "deepseek"); + if (enable && !this.agent.appendOnlyContext) { + this.agent.setAppendOnlyContext(new AppendOnlyContextManager()); + } else if (enable && this.agent.appendOnlyContext) { + // Already active — invalidate prefix + log so the next turn + // rebuilds for the current model's normalization. + this.agent.appendOnlyContext.invalidateForModelChange(); + } else if (!enable && this.agent.appendOnlyContext) { + this.agent.setAppendOnlyContext(undefined); + } + } + #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void { const providerKeys = new Set(); if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 142ad87bd..9e1076e4a 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -18,6 +18,7 @@ import { getProjectDir, getSessionsDir, getTerminalSessionsDir, + hasFsCode, isEnoent, logger, parseJsonlLenient, @@ -2146,7 +2147,59 @@ export class SessionManager { { ignoreError: true }, ); } + // Windows can reject overwrite-style rename with EPERM even after our own writer is closed. + // Move the old session file aside first so a failed retry can roll back to the last good file. + async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise { + const dir = path.resolve(targetPath, ".."); + const backupPath = path.join(dir, `.${path.basename(targetPath)}.${Snowflake.next()}.bak`); + try { + await this.storage.rename(targetPath, backupPath); + } catch (err) { + if (isEnoent(err)) { + await this.storage.rename(tempPath, targetPath); + return; + } + throw toError(renameError); + } + + try { + await this.storage.rename(tempPath, targetPath); + } catch (err) { + const replaceError = toError(err); + try { + await this.storage.rename(backupPath, targetPath); + } catch (rollbackErr) { + const rollbackError = toError(rollbackErr); + throw new Error( + `Failed to replace session file after EPERM (${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`, + { cause: replaceError }, + ); + } + throw replaceError; + } + + try { + await this.storage.unlink(backupPath); + } catch (err) { + if (!isEnoent(err)) { + logger.warn("Failed to remove session rewrite backup", { + sessionFile: targetPath, + backupPath, + error: toError(err).message, + }); + } + } + } + + async #replaceSessionFile(tempPath: string, targetPath: string): Promise { + try { + await this.storage.rename(tempPath, targetPath); + } catch (err) { + if (!hasFsCode(err, "EPERM")) throw toError(err); + await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err); + } + } async #writeEntriesAtomically(entries: FileEntry[]): Promise { if (!this.#sessionFile) return; const dir = path.resolve(this.#sessionFile, ".."); @@ -2159,7 +2212,7 @@ export class SessionManager { await writer.flush(); await writer.fsync(); await writer.close(); - await this.storage.rename(tempPath, this.#sessionFile); + await this.#replaceSessionFile(tempPath, this.#sessionFile); } catch (err) { try { await writer.close(); diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 62d827fb1..1f9e4c9df 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -72,7 +72,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ inlineHint: "[prompt]", allowArgs: true, handleTui: async (command, runtime) => { + const hadArgs = !!command.args; await runtime.ctx.handlePlanModeCommand(command.args || undefined); + if (hadArgs && runtime.ctx.planModeEnabled) { + // plan was already active — preserve the typed command in input history + runtime.ctx.editor.addToHistory(command.text); + } runtime.ctx.editor.setText(""); }, }, @@ -90,7 +95,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ inlineHint: "[objective]", allowArgs: true, handleTui: async (command, runtime) => { + const hadArgs = !!command.args; await runtime.ctx.handleGoalModeCommand(command.args || undefined); + if (hadArgs && runtime.ctx.goalModeEnabled) { + // goal was already active — preserve the typed command in input history + runtime.ctx.editor.addToHistory(command.text); + } runtime.ctx.editor.setText(""); }, }, diff --git a/packages/coding-agent/test/interactive-mode-loop.test.ts b/packages/coding-agent/test/interactive-mode-loop.test.ts index 8271b502a..4608c074f 100644 --- a/packages/coding-agent/test/interactive-mode-loop.test.ts +++ b/packages/coding-agent/test/interactive-mode-loop.test.ts @@ -111,4 +111,30 @@ describe("InteractiveMode loop auto-submit", () => { expect(resolved).toHaveLength(1); expect(resolved[0].text).toBe("repeat after compact"); }); + + it("does not resolve the next loop prompt while post-prompt background work is pending", async () => { + vi.useFakeTimers(); + let hasPendingWork = true; + Object.defineProperty(session, "isCompacting", { configurable: true, get: () => false }); + Object.defineProperty(session, "isStreaming", { configurable: true, get: () => false }); + Object.defineProperty(session, "hasPostPromptWork", { configurable: true, get: () => hasPendingWork }); + + mode.loopModeEnabled = true; + mode.loopPrompt = "deliver this"; + const resolved: SubmittedUserInput[] = []; + void mode.getUserInput().then(input => resolved.push(input)); + + // Loop timer fires while an idle-flush / delivery turn is still pending. + vi.advanceTimersByTime(800); + await flushMicrotasks(); + expect(resolved).toHaveLength(0); + + // Background delivery completes; loop may now fire. + hasPendingWork = false; + vi.advanceTimersByTime(800); + await flushMicrotasks(); + + expect(resolved).toHaveLength(1); + expect(resolved[0].text).toBe("deliver this"); + }); }); diff --git a/packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts b/packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts new file mode 100644 index 000000000..99c24d4d5 --- /dev/null +++ b/packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "bun:test"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { MemorySessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; + +class FsCodeError extends Error { + code: string; + + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +class RenameEpermOnceStorage extends MemorySessionStorage { + failNextSessionReplace = false; + backupCleanupPath: string | undefined; + + rename(source: string, target: string): Promise { + if ( + this.failNextSessionReplace && + source.includes(".tmp") && + target.endsWith(".jsonl") && + this.existsSync(target) + ) { + this.failNextSessionReplace = false; + return Promise.reject( + new FsCodeError("EPERM", `EPERM: operation not permitted, rename '${source}' -> '${target}'`), + ); + } + return super.rename(source, target); + } + + unlink(target: string): Promise { + if (target.endsWith(".bak")) { + this.backupCleanupPath = target; + } + return super.unlink(target); + } +} + +describe("SessionManager rewrite EPERM replacement fallback", () => { + it("keeps the active session healthy when replacing an existing file hits EPERM", async () => { + const storage = new RenameEpermOnceStorage(); + const session = SessionManager.create("/cwd", "/sessions", storage); + await session.ensureOnDisk(); + const sessionFile = session.getSessionFile(); + if (!sessionFile) throw new Error("Expected session file"); + + storage.failNextSessionReplace = true; + await expect(session.setSessionName("renamed session", "user")).resolves.toBe(true); + + const rewritten = storage.readTextSync(sessionFile); + expect(rewritten).toContain('"title":"renamed session"'); + const backupPath = storage.backupCleanupPath; + if (!backupPath) throw new Error("Expected EPERM fallback to create a rollback backup"); + expect(storage.existsSync(backupPath)).toBe(false); + + session.appendMessage({ role: "user", content: "after rewrite", timestamp: Date.now() }); + await expect(session.flush()).resolves.toBeUndefined(); + }); +});