From 5573270e5e719d6a7112c0937694c336fd539998 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 22 May 2026 13:41:17 +0000 Subject: [PATCH 01/15] fix(loop-mode): block auto-submit while post-prompt background work is pending When session.prompt() returns, idle-flush tasks for async-job result deliveries are scheduled via #schedulePostPromptTask (1ms delay) and added to #postPromptTasks immediately. The 800ms loop timer could fire in that window before isStreaming became true, causing the loop to submit the next prompt while the delivery turn was still pending. The delivery then hit AgentBusyError and the job result was silently dropped. Add AgentSession.hasPostPromptWork (= #postPromptTasks.size > 0) and include it in #isLoopAutoSubmitBlocked() alongside isStreaming and isCompacting. Add a regression test that verifies the loop defers when hasPostPromptWork is true and fires once it becomes false. Fixes #1294 --- packages/coding-agent/CHANGELOG.md | 4 +++ .../src/modes/interactive-mode.ts | 2 +- .../coding-agent/src/session/agent-session.ts | 12 +++++++++ .../test/interactive-mode-loop.test.ts | 26 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cfe55b1f1..3bb8c352e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- 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)) + ## [15.2.4] - 2026-05-22 ### Breaking Changes 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/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 571a84f81..706c01e98 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -3573,6 +3573,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; 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"); + }); }); From ec5accff11b63ee130d9b640d31b8a429da585be Mon Sep 17 00:00:00 2001 From: Yahya Saqban Date: Fri, 22 May 2026 23:39:02 +0300 Subject: [PATCH 02/15] fix: preserve slash command text in editor history when plan/goal mode is already active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When /plan or /goal set is typed while plan/goal mode is already active, the handler shows a warning and clears the editor, but the typed text was silently discarded — the user could not recover it with Up Arrow. Fix: save the text to editor history before clearing, so it remains recoverable via input history. Fixes #1287 --- .../src/slash-commands/builtin-registry.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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(""); }, }, From 21c3999e9d8aa7a6983c6d7b313f74f5c8efbd1c Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Mon, 18 May 2026 22:12:57 -0600 Subject: [PATCH 03/15] Add DeepSeek API provider login --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/src/auth-storage.ts | 6 ++++ packages/ai/src/cli.ts | 1 + packages/ai/src/utils/oauth/deepseek.ts | 16 ++++++++++ packages/ai/src/utils/oauth/index.ts | 5 ++++ packages/ai/src/utils/oauth/types.ts | 1 + .../test/auth-storage-api-key-login.test.ts | 29 +++++++++++++++++++ packages/ai/test/issue-830-repro.test.ts | 7 +++++ 8 files changed, 69 insertions(+) create mode 100644 packages/ai/src/utils/oauth/deepseek.ts 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/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/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-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"; From 1a2dffd021e7f14765b4b8cc90c84b1e80cf904c Mon Sep 17 00:00:00 2001 From: Hanbin Lee Date: Mon, 25 May 2026 00:47:18 +0900 Subject: [PATCH 04/15] fix(xiaomi): use token-plan base URL for tp- API keys Root cause: getProviderBaseUrl('xiaomi') returned the standard endpoint baseUrl from the bundled catalog (api.xiaomimimo.com/anthropic), overriding the token-plan URL (token-plan-ams.xiaomimimo.com/anthropic) that should be used for tp- prefixed keys. Fix: - xiaomiModelManagerOptions: for tp- keys, compute baseUrl from the key prefix alone, ignoring config?.baseUrl from the bundled catalog. - Add usesApiKeyAuth flag to AnthropicCompat / AnthropicHeaderOptions. - Xiaomi models set usesApiKeyAuth: true so the anthropic provider sends X-Api-Key header (matching what the Xiaomi validation code expects) instead of Authorization: Bearer. --- packages/ai/src/provider-models/openai-compat.ts | 11 +++++++---- packages/ai/src/providers/anthropic.ts | 5 ++++- packages/ai/src/types.ts | 2 ++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index 7239e6dd5..c20ecd0fd 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1410,13 +1410,15 @@ export function xiaomiModelManagerOptions( config?: XiaomiModelManagerConfig, ): ModelManagerOptions<"anthropic-messages"> { 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/*. const defaultBaseUrl = apiKey?.startsWith("tp-") ? "https://token-plan-ams.xiaomimimo.com/anthropic" : "https://api.xiaomimimo.com/anthropic"; - const baseUrl = normalizeAnthropicBaseUrl(config?.baseUrl, defaultBaseUrl); + // Token-plan keys always use the TP baseUrl; config?.baseUrl (from catalog) + // would incorrectly pin to the standard endpoint (api.xiaomimimo.com). + const baseUrl = normalizeAnthropicBaseUrl( + apiKey?.startsWith("tp-") ? undefined : 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; @@ -1439,6 +1441,7 @@ export function xiaomiModelManagerOptions( ...model, name: toModelName(entry.display_name, model.name), baseUrl, + compat: { ...model.compat, usesApiKeyAuth: true }, }; }, }), diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9cfd7d310..ea5ecf7dd 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -80,6 +80,8 @@ export type AnthropicHeaderOptions = { stream?: boolean; modelHeaders?: Record; isCloudflareAiGateway?: boolean; + /** Use X-Api-Key header instead of Authorization: Bearer. */ + usesApiKeyAuth?: boolean; }; export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined { @@ -182,7 +184,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record Date: Mon, 25 May 2026 01:38:17 +0900 Subject: [PATCH 05/15] refactor(xiaomi): switch from Anthropic to OpenAI Chat Completions API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xiaomi MiMo supports /v1/chat/completions in addition to the previously used /anthropic/v1/messages. The Anthropic protocol's thinking-signature mechanism, which Xiaomi does not implement, causes unsigned reasoning blocks to be converted to visible text — leaking the model's internal thinking into the conversation history and triggering repeated reasoning loops. OpenAI Chat Completions avoids this entirely: - No signature-based thinking chain verification - Standard reasoning_effort parameter instead of thinking blocks - Already used by Kilo, NanoGPT, OpenRouter, and ZenMux for Xiaomi Changes: - xiaomiModelManagerOptions: anthropic-messages → openai-completions - xiaomi.ts validation: /v1/messages → /v1/chat/completions - tp- keys: try SGP first, fall back to AMS - models.json: all 5 Xiaomi models updated to openai-completions + /v1 - Revert AnthropicHeaderOptions.usesApiKeyAuth (no longer needed) - Remove AnthropicCompat.usesApiKeyAuth from types - Update test expectations for SGP-first routing and OpenAI endpoints --- packages/ai/src/models.json | 20 ++-- .../ai/src/provider-models/openai-compat.ts | 54 ++++++--- packages/ai/src/providers/anthropic.ts | 5 +- packages/ai/src/types.ts | 2 - packages/ai/src/utils/oauth/xiaomi.ts | 109 +++++++++++------- packages/ai/test/issue-772-repro.test.ts | 17 +-- 6 files changed, 125 insertions(+), 82 deletions(-) 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 c20ecd0fd..bdb5c18cf 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1408,30 +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 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"; + ? 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 = normalizeAnthropicBaseUrl( - apiKey?.startsWith("tp-") ? undefined : 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"); + 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) => { @@ -1440,11 +1438,29 @@ export function xiaomiModelManagerOptions( return { ...model, name: toModelName(entry.display_name, model.name), - baseUrl, - compat: { ...model.compat, usesApiKeyAuth: true }, }; }, - }), + }); + 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/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ea5ecf7dd..9cfd7d310 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -80,8 +80,6 @@ export type AnthropicHeaderOptions = { stream?: boolean; modelHeaders?: Record; isCloudflareAiGateway?: boolean; - /** Use X-Api-Key header instead of Authorization: Bearer. */ - usesApiKeyAuth?: boolean; }; export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined { @@ -184,7 +182,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record { 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) { + if (e instanceof DOMException && e.name === "AbortError") { + 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/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); }); }); From 82f62c2e70f81bd7295d2b8db45052bbb6826009 Mon Sep 17 00:00:00 2001 From: Hanbin Lee Date: Mon, 25 May 2026 01:56:59 +0900 Subject: [PATCH 06/15] fix(xiaomi): let timeout aborts fall through to AMS fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any AbortError (including the 15s timeout from AbortSignal.timeout) was immediately rethrown, which prevented the SGP→AMS fallback loop from reaching AMS during regional SGP outages. Now only re-throws AbortError when the caller explicitly cancelled (signal?.aborted), letting timeout-induced aborts continue to the next endpoint via the existing lastError/continue path. --- packages/ai/src/utils/oauth/xiaomi.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/utils/oauth/xiaomi.ts b/packages/ai/src/utils/oauth/xiaomi.ts index 2f2cc50b1..57d43fa11 100644 --- a/packages/ai/src/utils/oauth/xiaomi.ts +++ b/packages/ai/src/utils/oauth/xiaomi.ts @@ -91,14 +91,16 @@ async function validateXiaomiApiKey(apiKey: string, signal?: AbortSignal): Promi : `${PROVIDER_NAME} API key validation failed (${response.status})`; throw new Error(message); } catch (e) { - if (e instanceof DOMException && e.name === "AbortError") { + // 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; } } - throw lastError ?? new Error(`${PROVIDER_NAME} API key validation failed`); } From 84eb837b00b7e5ebfa62cdb05822b2e678aa8f9b Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 24 May 2026 18:00:42 +0000 Subject: [PATCH 07/15] fix(session): handled eperm during session rewrite Replaced overwrite-style session rewrites with an EPERM fallback that moves the old session file aside before retrying and restores it if the retry fails. Added regression coverage for active-session rewrite recovery so the session remains writable after the fallback. Fixes #1337 --- .../src/session/session-manager.ts | 55 ++++++++++++++++- .../rewrite-rename-eperm.test.ts | 61 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts 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/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(); + }); +}); From 2a86049f0f18f81272e9837dfdb267dadcfc0068 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 22:08:53 +0200 Subject: [PATCH 08/15] feat(agent): add append-only context mode for DeepSeek prefix-cache stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImmutablePrefix caches system prompt + tool specs after first build() so subsequent turns reuse identical byte sequences. AppendOnlyLog converts messages once via syncMessages() and only appends deltas on further turns — prior-turn bytes stay stable. - New module: packages/agent/src/append-only-context.ts StablePrefix, AppendOnlyLog, AppendOnlyContextManager - AppendOnlyContextManager added to AgentLoopConfig - Wired into streamAssistantResponse in agent-loop.ts - Toggleable via provider.appendOnlyContext setting (auto/on/off) - Default auto enables for deepseek provider - 38 tests covering prefix, log, sync, compaction handling - /session info surfaces current active state --- packages/agent/src/agent-loop.ts | 21 +- packages/agent/src/agent.ts | 9 + packages/agent/src/append-only-context.ts | 257 +++++++++ packages/agent/src/index.ts | 2 + packages/agent/src/types.ts | 10 + .../agent/test/append-only-context.test.ts | 496 ++++++++++++++++++ .../src/config/settings-schema.ts | 16 + .../modes/controllers/command-controller.ts | 9 + packages/coding-agent/src/sdk.ts | 24 + 9 files changed, 837 insertions(+), 7 deletions(-) create mode 100644 packages/agent/src/append-only-context.ts create mode 100644 packages/agent/test/append-only-context.test.ts diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 4a2626874..4963b735f 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -640,19 +640,26 @@ async function streamAssistantResponse( // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; if (config.transformContext) { - messages = await config.transformContext(messages, signal); + messages = await config.transformContext(messages, config as any); } // Convert to LLM-compatible messages (AgentMessage[] → Message[]) 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..914f6dd7f 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; } /** @@ -925,6 +933,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..becb2e29f --- /dev/null +++ b/packages/agent/src/append-only-context.ts @@ -0,0 +1,257 @@ +/** + * 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: Message): void { + this.#entries.push(message); + } + + extend(messages: Message[]): void { + for (const m of messages) this.#entries.push(m); + } + + /** Replace the last entry — only legal for compaction. */ + replaceTail(replacement: Message): 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; + + 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. + * + * On the first call, all messages are appended. On subsequent calls, + * only the delta since the last sync is appended (the prior messages + * are already in the log with stable bytes). + * + * Call this **before** `build()` each turn so the log is up to date + * and `build()` returns the correct messages from the log, not from + * a freshly-converted array. + * + * When the message array shrinks (compaction), the log is reset and + * re-synced from scratch. + */ + syncMessages(normalizedMessages: Message[]): void { + // Compaction or full reset — message root changed + 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; + } + + /** Reset the sync cursor AND clear the log (call after retry or explicit reset). */ + resetSyncCursor(): void { + this.log.clear(); + this.#lastSyncCount = 0; + } + + appendMessage(message: Message): void { + this.log.append(message); + } + + replaceTailMessage(message: Message): void { + this.log.replaceTail(message); + } + + invalidate(): void { + this.prefix.invalidate(); + } + + reset(context: AgentContext): void { + this.prefix.invalidate(); + this.log.clear(); + this.#lastSyncCount = 0; + this.prefix.build(context); + } +} + +// --------------------------------------------------------------------------- +// 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.join("\n"), + t: tools.map(t => ({ + n: t.name, + d: t.description, + p: t.parameters, + })), + }); + 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..57386bc81 --- /dev/null +++ b/packages/agent/test/append-only-context.test.ts @@ -0,0 +1,496 @@ +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"; + +/** Minimal test message — cast to any to avoid strict Message type requirements. */ +function msg(role: string, content: string): any { + return { role, content, timestamp: Date.now() }; +} + +// --------------------------------------------------------------------------- +// 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" as const, 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" as const, content: "old" }); + log.replaceTail({ role: "user" as const, 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" as const, content: "nope" }); + expect(log.length).toBe(0); + }); + + it("extend appends multiple messages", () => { + const log = new AppendOnlyLog(); + log.extend([ + { role: "user" as const, content: "a" }, + { role: "assistant" as const, content: "b" }, + ]); + expect(log.length).toBe(2); + }); + + it("clear resets the log", () => { + const log = new AppendOnlyLog(); + log.append({ role: "user" as const, content: "x" }); + log.clear(); + expect(log.length).toBe(0); + }); + + it("entries readonly access returns internal array", () => { + const log = new AppendOnlyLog(); + log.append({ role: "user" as const, 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" as const, content: "q1" }); + const r1 = mgr.build(makeContext()); + expect(r1.messages).toHaveLength(1); + + mgr.appendMessage({ role: "assistant" as const, 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" as const, 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" as const, content: "old" }); + mgr.replaceTailMessage({ role: "user" as const, 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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" as const, content: "q1" }]); + const r1 = mgr.build(makeContext()); + expect(r1.messages).toHaveLength(1); + + mgr.syncMessages([ + { role: "user" as const, content: "q1" }, + { role: "assistant" as const, 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" as const, content: "q1" }]); + + const before = mgr.log.length; + + // Same array length → nothing new to append + mgr.syncMessages([{ role: "user" as const, 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" as const, content: "q1" }, + { role: "assistant" as const, content: "a1" }, + { role: "user" as const, content: "q2" }, + ]); + expect(mgr.log.length).toBe(3); + + // Simulate compaction — array shrinks + mgr.syncMessages([{ role: "user" as const, 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" as const, 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" as const, content: "turn1" }, + { role: "assistant" as const, 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" as const, content: "old" }]); + + mgr.resetSyncCursor(); + mgr.syncMessages([{ role: "user" as const, content: "fresh" }]); + + const result = mgr.build(makeContext()); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.content).toBe("fresh"); + }); +}); 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/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/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); From ee04dd8e21d34dc4889cc5100dd572ef171c4896 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 22:11:02 +0200 Subject: [PATCH 09/15] chore: bun check --- packages/agent/src/append-only-context.ts | 12 ++-- .../agent/test/append-only-context.test.ts | 59 +++++++++---------- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts index becb2e29f..f484b76c3 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -97,16 +97,16 @@ export class AppendOnlyLog { return this.#entries.length; } - append(message: Message): void { + append(message: any): void { this.#entries.push(message); } - extend(messages: Message[]): void { + extend(messages: any[]): void { for (const m of messages) this.#entries.push(m); } /** Replace the last entry — only legal for compaction. */ - replaceTail(replacement: Message): void { + replaceTail(replacement: any): void { const idx = this.#entries.length - 1; if (idx >= 0) this.#entries[idx] = replacement; } @@ -172,7 +172,7 @@ export class AppendOnlyContextManager { * When the message array shrinks (compaction), the log is reset and * re-synced from scratch. */ - syncMessages(normalizedMessages: Message[]): void { + syncMessages(normalizedMessages: any[]): void { // Compaction or full reset — message root changed if (normalizedMessages.length < this.#lastSyncCount) { this.log.clear(); @@ -193,11 +193,11 @@ export class AppendOnlyContextManager { this.#lastSyncCount = 0; } - appendMessage(message: Message): void { + appendMessage(message: any): void { this.log.append(message); } - replaceTailMessage(message: Message): void { + replaceTailMessage(message: any): void { this.log.replaceTail(message); } diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 57386bc81..4d5d376be 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -3,11 +3,6 @@ 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"; -/** Minimal test message — cast to any to avoid strict Message type requirements. */ -function msg(role: string, content: string): any { - return { role, content, timestamp: Date.now() }; -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -164,7 +159,7 @@ describe("AppendOnlyLog", () => { it("toMessages returns a copy of the array", () => { const log = new AppendOnlyLog(); - const msg = { role: "user" as const, content: "test" }; + const msg = { role: "user", content: "test" }; log.append(msg); const msgs = log.toMessages(); // Array is a copy — mutating it doesn't affect the log @@ -174,37 +169,37 @@ describe("AppendOnlyLog", () => { it("replaceTail replaces last entry", () => { const log = new AppendOnlyLog(); - log.append({ role: "user" as const, content: "old" }); - log.replaceTail({ role: "user" as const, content: "new" }); + 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" as const, content: "nope" }); + log.replaceTail({ role: "user", content: "nope" }); expect(log.length).toBe(0); }); it("extend appends multiple messages", () => { const log = new AppendOnlyLog(); log.extend([ - { role: "user" as const, content: "a" }, - { role: "assistant" as const, content: "b" }, + { 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" as const, content: "x" }); + 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" as const, content: "test" }); + log.append({ role: "user", content: "test" }); expect(log.entries()).toHaveLength(1); }); }); @@ -280,11 +275,11 @@ describe("AppendOnlyContextManager", () => { const mgr = new AppendOnlyContextManager(); mgr.build(makeContext()); - mgr.appendMessage({ role: "user" as const, content: "q1" }); + mgr.appendMessage({ role: "user", content: "q1" }); const r1 = mgr.build(makeContext()); expect(r1.messages).toHaveLength(1); - mgr.appendMessage({ role: "assistant" as const, content: "a1" }); + mgr.appendMessage({ role: "assistant", content: "a1" }); const r2 = mgr.build(makeContext()); expect(r2.messages).toHaveLength(2); expect(r2.messages[1]!.content).toBe("a1"); @@ -302,7 +297,7 @@ describe("AppendOnlyContextManager", () => { it("reset clears log and prefix", () => { const mgr = new AppendOnlyContextManager(); mgr.build(makeContext({ systemPrompt: ["Original"] })); - mgr.appendMessage({ role: "user" as const, content: "hello" }); + mgr.appendMessage({ role: "user", content: "hello" }); const freshCtx = makeContext({ systemPrompt: ["Fresh start"] }); mgr.reset(freshCtx); @@ -315,8 +310,8 @@ describe("AppendOnlyContextManager", () => { it("replaceTailMessage updates last log entry", () => { const mgr = new AppendOnlyContextManager(); mgr.build(makeContext()); - mgr.appendMessage({ role: "user" as const, content: "old" }); - mgr.replaceTailMessage({ role: "user" as const, content: "new" }); + mgr.appendMessage({ role: "user", content: "old" }); + mgr.replaceTailMessage({ role: "user", content: "new" }); const result = mgr.build(makeContext()); expect(result.messages).toHaveLength(1); @@ -419,13 +414,13 @@ describe("message sync", () => { const mgr = new AppendOnlyContextManager(); mgr.build(makeContext()); - mgr.syncMessages([{ role: "user" as const, content: "q1" }]); + mgr.syncMessages([{ role: "user", content: "q1" }]); const r1 = mgr.build(makeContext()); expect(r1.messages).toHaveLength(1); mgr.syncMessages([ - { role: "user" as const, content: "q1" }, - { role: "assistant" as const, content: "a1" }, + { role: "user", content: "q1" }, + { role: "assistant", content: "a1" }, ]); const r2 = mgr.build(makeContext()); expect(r2.messages).toHaveLength(2); @@ -435,12 +430,12 @@ describe("message sync", () => { 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" as const, content: "q1" }]); + mgr.syncMessages([{ role: "user", content: "q1" }]); const before = mgr.log.length; // Same array length → nothing new to append - mgr.syncMessages([{ role: "user" as const, content: "q1" }]); + mgr.syncMessages([{ role: "user", content: "q1" }]); expect(mgr.log.length).toBe(before); }); @@ -449,14 +444,14 @@ describe("message sync", () => { mgr.build(makeContext()); mgr.syncMessages([ - { role: "user" as const, content: "q1" }, - { role: "assistant" as const, content: "a1" }, - { role: "user" as const, content: "q2" }, + { 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" as const, content: "q2" }]); + mgr.syncMessages([{ role: "user", content: "q2" }]); expect(mgr.log.length).toBe(1); expect(mgr.log.toMessages()[0]!.content).toBe("q2"); }); @@ -466,15 +461,15 @@ describe("message sync", () => { // First turn: build with empty context, sync first message mgr.build(makeContext()); - mgr.syncMessages([{ role: "user" as const, content: "turn1" }]); + 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" as const, content: "turn1" }, - { role: "assistant" as const, content: "resp1" }, + { role: "user", content: "turn1" }, + { role: "assistant", content: "resp1" }, ]); const r2 = mgr.build(makeContext()); expect(r2.messages).toHaveLength(2); @@ -484,10 +479,10 @@ describe("message sync", () => { it("resetSyncCursor forces full re-sync on next call", () => { const mgr = new AppendOnlyContextManager(); mgr.build(makeContext()); - mgr.syncMessages([{ role: "user" as const, content: "old" }]); + mgr.syncMessages([{ role: "user", content: "old" }]); mgr.resetSyncCursor(); - mgr.syncMessages([{ role: "user" as const, content: "fresh" }]); + mgr.syncMessages([{ role: "user", content: "fresh" }]); const result = mgr.build(makeContext()); expect(result.messages).toHaveLength(1); From 648bbdc163145baf618140f87f42a726d29f6925 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 22:28:38 +0200 Subject: [PATCH 10/15] fix(agent): passed AbortSignal to transformContext and re-evaluated append-only on model switch --- packages/agent/src/agent-loop.ts | 2 +- packages/agent/src/agent.ts | 8 ++++++++ packages/coding-agent/src/session/agent-session.ts | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 4963b735f..0771535a6 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -640,7 +640,7 @@ async function streamAssistantResponse( // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; if (config.transformContext) { - messages = await config.transformContext(messages, config as any); + messages = await config.transformContext(messages, signal); } // Convert to LLM-compatible messages (AgentMessage[] → Message[]) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 914f6dd7f..5a027ad28 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -549,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); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 571a84f81..9ada1cfbb 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"; @@ -5947,6 +5948,15 @@ export class AgentSession { this.#closeProviderSessionsForModelSwitch(currentModel, model); } this.agent.setModel(model); + + // Re-evaluate append-only context mode — provider may have changed + const appendOnlySetting = this.settings.get("provider.appendOnlyContext") ?? "auto"; + const enable = appendOnlySetting === "on" || (appendOnlySetting === "auto" && model.provider === "deepseek"); + if (enable && !this.agent.appendOnlyContext) { + this.agent.setAppendOnlyContext(new AppendOnlyContextManager()); + } else if (!enable && this.agent.appendOnlyContext) { + this.agent.setAppendOnlyContext(undefined); + } } #closeCodexProviderSessionsForHistoryRewrite(): void { From e71d2ff49e2e7df8fec0d5f01dabd06175d87ea5 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 22:39:20 +0200 Subject: [PATCH 11/15] fix: detect content rewrites in syncMessages, reset append-only cache on model switch --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/append-only-context.ts | 55 +++++++++++--- .../agent/test/append-only-context.test.ts | 75 +++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/session/agent-session.ts | 4 + 5 files changed, 127 insertions(+), 12 deletions(-) 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/append-only-context.ts b/packages/agent/src/append-only-context.ts index f484b76c3..02f003273 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -151,6 +151,8 @@ export class AppendOnlyContextManager { 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); @@ -161,19 +163,21 @@ export class AppendOnlyContextManager { /** * Sync normalized (provider-level) messages into the append-only log. * - * On the first call, all messages are appended. On subsequent calls, - * only the delta since the last sync is appended (the prior messages - * are already in the log with stable bytes). - * - * Call this **before** `build()` each turn so the log is up to date - * and `build()` returns the correct messages from the log, not from - * a freshly-converted array. - * - * When the message array shrinks (compaction), the log is reset and - * re-synced from scratch. + * Detects both compaction (shorter array) and in-place rewrites + * (same length, changed content via a rolling digest). */ syncMessages(normalizedMessages: any[]): void { - // Compaction or full reset — message root changed + // 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; @@ -185,12 +189,22 @@ export class AppendOnlyContextManager { } this.#lastSyncCount = normalizedMessages.length; + this.#syncedDigest = this.#computeDigest(normalizedMessages); } - /** Reset the sync cursor AND clear the log (call after retry or explicit reset). */ + /** 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 { @@ -209,8 +223,25 @@ export class AppendOnlyContextManager { 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; + } } // --------------------------------------------------------------------------- diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 4d5d376be..31726da5e 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -488,4 +488,79 @@ describe("message sync", () => { 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/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2b6d919a1..b5a71b3fb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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)) +- 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 ## [15.2.4] - 2026-05-22 ### Breaking Changes diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 9ada1cfbb..0b234c423 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -5954,6 +5954,10 @@ export class AgentSession { const enable = appendOnlySetting === "on" || (appendOnlySetting === "auto" && model.provider === "deepseek"); if (enable && !this.agent.appendOnlyContext) { this.agent.setAppendOnlyContext(new AppendOnlyContextManager()); + } else if (enable && this.agent.appendOnlyContext) { + // Model changed but mode stays enabled — reset cached prefix + log + // so the next turn rebuilds for the new model's normalization. + this.agent.appendOnlyContext.invalidateForModelChange(); } else if (!enable && this.agent.appendOnlyContext) { this.agent.setAppendOnlyContext(undefined); } From c93126ee3f46914d79310dcce0f5f78e18440ab1 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 22:48:12 +0200 Subject: [PATCH 12/15] fix: re-evaluate append-only mode on setting changes (not just model switch) --- packages/coding-agent/src/config/settings.ts | 15 ++++++++ .../coding-agent/src/session/agent-session.ts | 35 ++++++++++++------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index fe4b32838..8171f1497 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -856,7 +856,22 @@ const SETTING_HOOKS: Partial>> = { setDefaultTabWidth(value); } }, + "provider.appendOnlyContext": value => { + if (typeof value === "string") { + appendOnlyModeChangeCallback?.(value); + } + }, }; +/** Callback invoked when `provider.appendOnlyContext` changes at runtime. */ +let appendOnlyModeChangeCallback: ((value: string) => void) | null = null; + +/** + * Register a callback for append-only mode setting changes. + * The session calls this on startup to sync runtime state with config. + */ +export function onAppendOnlyModeChanged(cb: (value: string) => void): void { + appendOnlyModeChangeCallback = cb; +} // ═══════════════════════════════════════════════════════════════════════════ // Global Singleton diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 0b234c423..b695455f5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -99,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"; @@ -1139,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 */ @@ -5949,18 +5952,8 @@ export class AgentSession { } this.agent.setModel(model); - // Re-evaluate append-only context mode — provider may have changed - const appendOnlySetting = this.settings.get("provider.appendOnlyContext") ?? "auto"; - const enable = appendOnlySetting === "on" || (appendOnlySetting === "auto" && model.provider === "deepseek"); - if (enable && !this.agent.appendOnlyContext) { - this.agent.setAppendOnlyContext(new AppendOnlyContextManager()); - } else if (enable && this.agent.appendOnlyContext) { - // Model changed but mode stays enabled — reset cached prefix + log - // so the next turn rebuilds for the new model's normalization. - this.agent.appendOnlyContext.invalidateForModelChange(); - } else if (!enable && this.agent.appendOnlyContext) { - this.agent.setAppendOnlyContext(undefined); - } + // Re-evaluate append-only context mode — provider or setting may have changed + this.#syncAppendOnlyContext(model); } #closeCodexProviderSessionsForHistoryRewrite(): void { @@ -5969,6 +5962,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") { From 667350b552c80f5d61c3a921d528bd94d94b1ac7 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 22:55:39 +0200 Subject: [PATCH 13/15] fix: support multiple append-only setting subscribers --- packages/coding-agent/src/config/settings.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 8171f1497..85142fcd5 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -858,19 +858,23 @@ const SETTING_HOOKS: Partial>> = { }, "provider.appendOnlyContext": value => { if (typeof value === "string") { - appendOnlyModeChangeCallback?.(value); + for (const cb of appendOnlyModeCallbacks) cb(value); } }, }; -/** Callback invoked when `provider.appendOnlyContext` changes at runtime. */ -let appendOnlyModeChangeCallback: ((value: string) => void) | null = null; +/** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */ +const appendOnlyModeCallbacks = new Set<(value: string) => void>(); /** - * Register a callback for append-only mode setting changes. - * The session calls this on startup to sync runtime state with config. + * 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 { - appendOnlyModeChangeCallback = cb; +export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void { + appendOnlyModeCallbacks.add(cb); + return () => { + appendOnlyModeCallbacks.delete(cb); + }; } // ═══════════════════════════════════════════════════════════════════════════ From 79973d69deb75fd7bf7f60b5435a61dbef28d248 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 23:01:41 +0200 Subject: [PATCH 14/15] fix: preserve system prompt array structure in fingerprint --- packages/agent/src/append-only-context.ts | 2 +- packages/agent/test/append-only-context.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts index 02f003273..782f34521 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -273,7 +273,7 @@ function takeSnapshot(context: AgentContext): StablePrefixSnapshot { function computeFingerprint(systemPrompt: string[], tools: Tool[]): string { const payload = JSON.stringify({ - s: systemPrompt.join("\n"), + s: systemPrompt, t: tools.map(t => ({ n: t.name, d: t.description, diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index 31726da5e..efe8b9949 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -387,6 +387,18 @@ describe("fingerprint determinism", () => { 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); + }); }); // --------------------------------------------------------------------------- From 05232eaaeea796bf9172ad754a1438968763de7f Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 23:06:55 +0200 Subject: [PATCH 15/15] fix: include strict/customFormat/customWireName in tool fingerprint --- packages/agent/src/append-only-context.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts index 782f34521..49ba0e30d 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -278,6 +278,9 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[]): string { n: t.name, d: t.description, p: t.parameters, + s: t.strict, + cf: t.customFormat, + cw: t.customWireName, })), }); let hash = 0;