Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import type {
HistoryRowStat,
RecentHistoryResult
} from "../provider";
import { COMPACTION_PREFIX } from "../../utils/compaction-helpers";
import {
COMPACTION_PREFIX,
isCompactionMessage
} from "../../utils/compaction-helpers";

export interface SqlProvider {
sql<T = Record<string, string | number | boolean | null>>(
Expand Down Expand Up @@ -529,6 +532,13 @@ export class AgentSessionProvider implements SessionProvider {
messages: SessionMessage[],
compactions: StoredCompaction[]
): SessionMessage[] {
// A pre-#1984 session may carry a synthetic overlay that was filed as a
// real row on an earlier turn. Drop those raw rows before overlaying — the
// overlay for the range is re-synthesized below — so the summary can't
// render twice. This is a no-op for healthy sessions (no such rows) and
// only touches the returned projection; the SQL parent-chain walk has
// already resolved children of the stray row.
messages = messages.filter((m) => !isCompactionMessage(m));
const ids = messages.map((m) => m.id);
const result: SessionMessage[] = [];
let i = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
StoredCompaction
} from "../provider";
import type { SessionMessage } from "../types";
import { isCompactionMessage } from "../../utils/compaction-helpers";
import {
toPostgresConnection,
type PostgresClient,
Expand Down Expand Up @@ -265,6 +266,10 @@ export class PostgresSessionProvider implements SessionProvider {
messages: SessionMessage[],
compactions: StoredCompaction[]
): SessionMessage[] {
// Drop any synthetic overlay that a pre-#1984 session filed as a real row
// so the summary can't render twice; the overlay is re-synthesized below.
// No-op for healthy sessions.
messages = messages.filter((m) => !isCompactionMessage(m));
const ids = messages.map((m) => m.id);
const result: SessionMessage[] = [];
let i = 0;
Expand Down
18 changes: 17 additions & 1 deletion packages/agents/src/experimental/memory/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
} from "./context";
import { AgentSessionProvider, type SqlProvider } from "./providers/agent";
import { AgentContextProvider } from "./providers/agent-context";
import type { CompactResult } from "../utils/compaction-helpers";
import {
isCompactionMessage,
type CompactResult
} from "../utils/compaction-helpers";
import { estimateMessageTokens, estimateStringTokens } from "../utils/tokens";
import { MessageType } from "../../../types";

Expand Down Expand Up @@ -618,6 +621,19 @@ export class Session {
): Promise<void> {
await this._ensureRestored();

// The `compaction_` prefix is reserved for synthetic overlays that
// `getHistory()` computes on read — they must never become real rows.
// A client transport round-trips the full transcript back on the next
// turn, so an overlay arrives here as an incoming message; persisting it
// would duplicate the summary in every later read (#1984). Reject it from
// any write path so the invariant can't be violated caller-side.
if (isCompactionMessage(message)) {
console.warn(
`[Session] Refusing to persist a message with the reserved compaction id "${message.id}" — synthetic compaction overlays are computed on read and are never stored (#1984).`
);
return;
}
Comment on lines +630 to +635

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary? Receiving a compaction message from the browser is expected, so we’re logging a warning under normal, happy-path conditions (potentially even on every single turn after a compaction?)


const existing = await this.storage.getMessage(message.id);
if (existing) {
await this._emitStatus("idle");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,69 @@ describe("AgentSessionProvider — tree-structured messages", () => {
expect(compactions).toHaveLength(1);
});

it("does not persist synthetic compaction overlays echoed back by a client (#1984)", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For completeness, it might be good to add a test for the postgres provider as well

const agent = await getAgent(name);
for (let i = 0; i < 6; i++) {
await agent.appendMessage({
id: `m${i}`,
role: i % 2 === 0 ? "user" : "assistant",
parts: [{ type: "text", text: `msg ${i}` }]
});
}

await agent.addCompaction("Summary of m1-m3", "m1", "m3");

// getHistory substitutes a synthetic compaction_<id> overlay on read.
const first = await agent.getHistory();
const overlay = first.find((m) => m.id.startsWith("compaction_"));
expect(overlay).toBeDefined();

// A browser transport echoes the full transcript back on the next turn,
// so the synthetic overlay arrives as an incoming message and the host
// tries to persist it. It must NOT become a real row.
await agent.appendMessage(overlay!, "m0");

// No compaction-prefixed row should exist in storage.
expect(await agent.getMessage(overlay!.id)).toBeNull();

// And the overlay must appear exactly once in the projection.
const after = await agent.getHistory();
const overlayCount = after.filter((m) =>
m.id.startsWith("compaction_")
).length;
expect(overlayCount).toBe(1);
expect(after.map((m) => m.id)).toEqual(["m0", overlay!.id, "m4", "m5"]);
});

it("filters a pre-filed compaction row out of getHistory (existing sessions, #1984)", async () => {
const agent = await getAgent(name);
for (let i = 0; i < 6; i++) {
await agent.appendMessage({
id: `m${i}`,
role: i % 2 === 0 ? "user" : "assistant",
parts: [{ type: "text", text: `msg ${i}` }]
});
}
await agent.addCompaction("Summary of m1-m3", "m1", "m3");

const overlay = (await agent.getHistory()).find((m) =>
m.id.startsWith("compaction_")
);
expect(overlay).toBeDefined();

// Simulate an already-corrupted session: a synthetic overlay was filed as
// a real row on a prior turn (before the intake guard existed), parented
// into the live chain via the raw insert seam.
await agent.rawInsertChildForTest("m5", overlay!.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For completeness, I might test that a child of the overlay message is restored and re-parented correctly


// The read projection must still show the overlay exactly once.
const history = await agent.getHistory();
const overlayCount = history.filter((m) =>
m.id.startsWith("compaction_")
).length;
expect(overlayCount).toBe(1);
});

it("iterative compaction — new overlay supersedes old one at same fromId", async () => {
const agent = await getAgent(name);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { env } from "cloudflare:workers";
import type { SessionMessage } from "../../../../experimental/memory/session/types";
import { describe, expect, it, beforeEach } from "vitest";
import { describe, expect, it, beforeEach, vi } from "vitest";
import { getAgentByName } from "../../../..";
import { Session } from "../../../../experimental/memory/session/session";
import {
Expand Down Expand Up @@ -686,6 +686,49 @@ function createCompactableSession(
};
}

describe("Session — reserved compaction id guard (#1984)", () => {
it("refuses to persist a message with the reserved compaction_ prefix", async () => {
const appended: SessionMessage[] = [];
const storage: SessionProvider = {
...stubProvider,
appendMessage: (msg) => {
appended.push(msg);
}
};
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const session = new Session(storage);

await session.appendMessage({
id: `${COMPACTION_PREFIX}abc`,
role: "assistant",
parts: [{ type: "text", text: "synthetic overlay" }]
});

expect(appended).toHaveLength(0);
expect(warn).toHaveBeenCalledOnce();
warn.mockRestore();
});

it("still persists ordinary messages", async () => {
const appended: SessionMessage[] = [];
const storage: SessionProvider = {
...stubProvider,
appendMessage: (msg) => {
appended.push(msg);
}
};
const session = new Session(storage);

await session.appendMessage({
id: "normal-1",
role: "user",
parts: [{ type: "text", text: "hi" }]
});

expect(appended.map((m) => m.id)).toEqual(["normal-1"]);
});
});

describe("Session.compact()", () => {
it("throws if no compaction function registered", async () => {
const session = new Session(stubProvider);
Expand Down
9 changes: 9 additions & 0 deletions packages/think/src/tests/agents/think-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1892,6 +1892,15 @@ export class ThinkTestAgent extends Think {
return (await this.session.getHistory()) as UIMessage[];
}

/**
* Probe a raw stored row by id (reads `assistant_messages` directly, without
* the read-time compaction overlay), so a test can prove a synthetic
* compaction message was never filed as a real row (#1984).
*/
async getSessionMessageForTest(id: string): Promise<UIMessage | null> {
return (await this.session.getMessage(id)) as UIMessage | null;
}

async deliverNoticeErrorForTest(
text: string,
channel?: string
Expand Down
25 changes: 25 additions & 0 deletions packages/think/src/tests/think-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,31 @@ describe("Think — Session integration", () => {
expect(JSON.stringify(publicMessages)).toContain("compacted-summary");
});

it("does not persist a synthetic compaction overlay echoed back at intake (#1984)", async () => {
const agent = await freshAgent("session-compaction-echo");
await agent.enableCompactionForTest();

// Drive a turn that compacts, so getHistory() now substitutes a synthetic
// compaction_<id> overlay on read.
await agent.testChat("Trigger compaction");

const history = (await agent.getSessionHistoryForTest()) as UIMessage[];
const overlay = history.find((m) => m.id.startsWith("compaction_"));
expect(overlay).toBeDefined();

// A browser transport echoes the whole transcript back on the next turn,
// so the overlay arrives as an incoming message. It must NOT be filed as
// a real row.
await agent.persistIncomingMessageForTest(overlay!);

// No raw row exists for the reserved id...
expect(await agent.getSessionMessageForTest(overlay!.id)).toBeNull();

// ...and the overlay still appears exactly once in the projection.
const after = (await agent.getSessionHistoryForTest()) as UIMessage[];
expect(after.filter((m) => m.id.startsWith("compaction_"))).toHaveLength(1);
});

it("returns a copy from getMessages", async () => {
const agent = await freshAgent("session-get-messages-copy");
await agent.testChat("Hello!");
Expand Down
Loading