From e71d2ff49e2e7df8fec0d5f01dabd06175d87ea5 Mon Sep 17 00:00:00 2001 From: Brit Date: Sun, 24 May 2026 22:39:20 +0200 Subject: [PATCH] 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); }