Merge branch 'main' into feat/no-webp-llama-cpp

This commit is contained in:
Can Bölük
2026-05-25 13:00:46 +03:00
committed by GitHub
30 changed files with 1372 additions and 83 deletions
+4
View File
@@ -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
+13 -6
View File
@@ -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;
+17
View File
@@ -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<void>;
#onBeforeYield?: () => Promise<void> | 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,
+291
View File
@@ -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 ?? "<unbuilt>";
}
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);
}
+2
View File
@@ -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";
+10
View File
@@ -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.
@@ -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>): AgentContext {
return {
systemPrompt: ["You are a helpful assistant.", "Be concise."],
messages: [],
tools: [],
...overrides,
};
}
function makeTool(name: string, description?: string, parameters?: Record<string, unknown>): 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");
});
});
+4
View File
@@ -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
+6
View File
@@ -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);
+1
View File
@@ -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)
+10 -10
View File
@@ -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"
@@ -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),
};
},
});
},
}),
};
}
+16
View File
@@ -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",
},
});
+5
View File
@@ -55,6 +55,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
name: "Cerebras",
available: true,
},
{
id: "deepseek",
name: "DeepSeek",
available: true,
},
{
id: "fireworks",
name: "Fireworks",
+1
View File
@@ -14,6 +14,7 @@ export type OAuthProvider =
| "cerebras"
| "cloudflare-ai-gateway"
| "cursor"
| "deepseek"
| "fireworks"
| "firepass"
| "github-copilot"
+73 -40
View File
@@ -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<void> {
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`);
}
/**
@@ -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<typeof deepseekModule.loginDeepSeek>;
let loginKagiSpy: Mock<typeof kagiModule.loginKagi>;
let loginOllamaCloudSpy: Mock<typeof ollamaCloudModule.loginOllamaCloud>;
@@ -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");
});
});
+9 -8
View File
@@ -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);
});
});
+7
View File
@@ -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";
+3
View File
@@ -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))
@@ -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": {
@@ -856,7 +856,26 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
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
@@ -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`;
@@ -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 {
+24
View File
@@ -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);
@@ -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<string>();
if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
@@ -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<void> {
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<void> {
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<void> {
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();
@@ -72,7 +72,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
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<SlashCommandSpec> = [
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("");
},
},
@@ -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");
});
});
@@ -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<void> {
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<void> {
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();
});
});