fix: detect content rewrites in syncMessages, reset append-only cache on model switch

This commit is contained in:
Brit
2026-05-24 22:39:20 +02:00
parent 648bbdc163
commit e71d2ff49e
5 changed files with 127 additions and 12 deletions
+4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
### Fixed
- Fixed `transformContext` receiving the loop config object as the `signal` argument instead of the actual `AbortSignal`, so hooks that check `signal.aborted` or call `signal.addEventListener` now work correctly under abort/timeout conditions
- Fixed `appendOnlyContext` not being re-evaluated after `setModel()` — the mode was decided once at session construction based on the initial model's provider, so switching from/to DeepSeek (or changing `provider.appendOnlyContext`) mid-session produced incorrect mode behavior
## [15.2.3] - 2026-05-22
### Added
+43 -12
View File
@@ -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;
}
}
// ---------------------------------------------------------------------------
@@ -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");
});
});
+1
View File
@@ -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
@@ -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);
}