Skip to content

Commit 7bf6a4d

Browse files
authored
fix: dedupe identical session snapshots (#1)
## Summary - Adds a per-session in-memory cache that stores the last-seen snapshot content for each session ID. - Before ingesting a snapshot event, the handler compares the incoming content against the cached value; identical snapshots are silently skipped, eliminating redundant Graphiti ingestion calls. - Adds unit tests covering: first snapshot always ingested, duplicate snapshot skipped, distinct sessions tracked independently, and changed snapshot after duplicate is re-ingested. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk behavior change limited to `session.idle` snapshot ingestion, reducing redundant `addEpisode` calls. Main risk is unintentionally skipping snapshots if downstream relied on duplicates, but dedupe only applies to exact content matches and only after a successful save. > > **Overview** > Prevents redundant Graphiti ingestion on `session.idle` by caching the last *successfully saved* snapshot body per session and skipping `client.addEpisode` when the newly generated snapshot is identical. > > Adds unit tests covering first-snapshot behavior, skipping identical subsequent snapshots, re-saving when content changes, and ensuring failed `addEpisode` attempts don’t update the dedupe cache (so retries still occur). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a3ff743. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d048637 commit 7bf6a4d

2 files changed

Lines changed: 221 additions & 8 deletions

File tree

src/handlers/event.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,209 @@ describe("event handler integration", () => {
505505
// Should still flush despite error
506506
assertEquals(sessionManager.flushCalls.length, 1);
507507
});
508+
509+
it("snapshot dedup: first snapshot is always saved", async () => {
510+
const sessionManager = new MockSessionManager();
511+
const client = new MockGraphitiClient();
512+
const sdkClient = new MockSdkClient();
513+
514+
sessionManager.setParentId("session-1", null);
515+
sessionManager.setState("session-1", {
516+
groupId: "test:project",
517+
userGroupId: "test:user",
518+
injectedMemories: false,
519+
lastInjectionFactUuids: [],
520+
visibleFactUuids: [],
521+
messageCount: 1,
522+
pendingMessages: ["User: Hello there"],
523+
contextLimit: 200_000,
524+
isMain: true,
525+
});
526+
527+
const handler = createEventHandler({
528+
sessionManager: sessionManager as any,
529+
client: client as any,
530+
defaultGroupId: "test:project",
531+
sdkClient: sdkClient as any,
532+
directory: "/test/dir",
533+
groupIdPrefix: "test",
534+
});
535+
536+
await handler({
537+
event: {
538+
type: "session.idle",
539+
properties: { sessionID: "session-1" },
540+
} as any,
541+
});
542+
543+
assertEquals(client.addEpisodeCalls.length, 1);
544+
assertEquals(client.addEpisodeCalls[0].name, "Snapshot: session-1");
545+
});
546+
547+
it("snapshot dedup: identical subsequent snapshot is skipped", async () => {
548+
const sessionManager = new MockSessionManager();
549+
const client = new MockGraphitiClient();
550+
const sdkClient = new MockSdkClient();
551+
552+
sessionManager.setParentId("session-1", null);
553+
sessionManager.setState("session-1", {
554+
groupId: "test:project",
555+
userGroupId: "test:user",
556+
injectedMemories: false,
557+
lastInjectionFactUuids: [],
558+
visibleFactUuids: [],
559+
messageCount: 1,
560+
pendingMessages: ["User: Same content"],
561+
contextLimit: 200_000,
562+
isMain: true,
563+
});
564+
565+
const handler = createEventHandler({
566+
sessionManager: sessionManager as any,
567+
client: client as any,
568+
defaultGroupId: "test:project",
569+
sdkClient: sdkClient as any,
570+
directory: "/test/dir",
571+
groupIdPrefix: "test",
572+
});
573+
574+
// First idle — saved
575+
await handler({
576+
event: {
577+
type: "session.idle",
578+
properties: { sessionID: "session-1" },
579+
} as any,
580+
});
581+
assertEquals(client.addEpisodeCalls.length, 1);
582+
583+
// Second idle with identical pendingMessages — skipped
584+
await handler({
585+
event: {
586+
type: "session.idle",
587+
properties: { sessionID: "session-1" },
588+
} as any,
589+
});
590+
assertEquals(client.addEpisodeCalls.length, 1);
591+
});
592+
593+
it("snapshot dedup: changed snapshot content is saved again", async () => {
594+
const sessionManager = new MockSessionManager();
595+
const client = new MockGraphitiClient();
596+
const sdkClient = new MockSdkClient();
597+
598+
sessionManager.setParentId("session-1", null);
599+
sessionManager.setState("session-1", {
600+
groupId: "test:project",
601+
userGroupId: "test:user",
602+
injectedMemories: false,
603+
lastInjectionFactUuids: [],
604+
visibleFactUuids: [],
605+
messageCount: 1,
606+
pendingMessages: ["User: First message"],
607+
contextLimit: 200_000,
608+
isMain: true,
609+
});
610+
611+
const handler = createEventHandler({
612+
sessionManager: sessionManager as any,
613+
client: client as any,
614+
defaultGroupId: "test:project",
615+
sdkClient: sdkClient as any,
616+
directory: "/test/dir",
617+
groupIdPrefix: "test",
618+
});
619+
620+
// First idle — saved
621+
await handler({
622+
event: {
623+
type: "session.idle",
624+
properties: { sessionID: "session-1" },
625+
} as any,
626+
});
627+
assertEquals(client.addEpisodeCalls.length, 1);
628+
629+
// Change the session messages
630+
sessionManager.setState("session-1", {
631+
groupId: "test:project",
632+
userGroupId: "test:user",
633+
injectedMemories: false,
634+
lastInjectionFactUuids: [],
635+
visibleFactUuids: [],
636+
messageCount: 2,
637+
pendingMessages: [
638+
"User: First message",
639+
"Assistant: Here is my answer.",
640+
"User: Follow-up question",
641+
],
642+
contextLimit: 200_000,
643+
isMain: true,
644+
});
645+
646+
// Second idle with different content — saved again
647+
await handler({
648+
event: {
649+
type: "session.idle",
650+
properties: { sessionID: "session-1" },
651+
} as any,
652+
});
653+
assertEquals(client.addEpisodeCalls.length, 2);
654+
});
655+
656+
it("snapshot dedup: failed addEpisode does not poison dedupe state", async () => {
657+
const sessionManager = new MockSessionManager();
658+
const client = new MockGraphitiClient();
659+
const sdkClient = new MockSdkClient();
660+
661+
sessionManager.setParentId("session-1", null);
662+
sessionManager.setState("session-1", {
663+
groupId: "test:project",
664+
userGroupId: "test:user",
665+
injectedMemories: false,
666+
lastInjectionFactUuids: [],
667+
visibleFactUuids: [],
668+
messageCount: 1,
669+
pendingMessages: ["User: Retry me"],
670+
contextLimit: 200_000,
671+
isMain: true,
672+
});
673+
674+
const handler = createEventHandler({
675+
sessionManager: sessionManager as any,
676+
client: client as any,
677+
defaultGroupId: "test:project",
678+
sdkClient: sdkClient as any,
679+
directory: "/test/dir",
680+
groupIdPrefix: "test",
681+
});
682+
683+
// First idle — addEpisode throws
684+
client.addEpisode = async () => {
685+
throw new Error("Transient failure");
686+
};
687+
688+
await handler({
689+
event: {
690+
type: "session.idle",
691+
properties: { sessionID: "session-1" },
692+
} as any,
693+
});
694+
695+
// Second idle with same content — should retry (not skipped)
696+
let savedBody = "";
697+
client.addEpisode = async (params) => {
698+
savedBody = params.episodeBody;
699+
};
700+
701+
await handler({
702+
event: {
703+
type: "session.idle",
704+
properties: { sessionID: "session-1" },
705+
} as any,
706+
});
707+
708+
// The retry succeeded — body was written
709+
assertStrictEquals(savedBody.includes("Retry me"), true);
710+
});
508711
});
509712

510713
describe("session.compacted", () => {

src/handlers/event.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export function createEventHandler(deps: EventHandlerDeps) {
3232
} = deps;
3333
const defaultUserGroupId = makeUserGroupId(groupIdPrefix);
3434

35+
/** Stores the last successfully saved snapshot body per session ID. */
36+
const lastSnapshotBody = new Map<string, string>();
37+
3538
const buildSessionSnapshot = (
3639
sessionId: string,
3740
messages: string[],
@@ -161,14 +164,21 @@ export function createEventHandler(deps: EventHandlerDeps) {
161164
state.pendingMessages,
162165
);
163166
if (snapshotContent.trim()) {
164-
await client.addEpisode({
165-
name: `Snapshot: ${sessionId}`,
166-
episodeBody: snapshotContent,
167-
groupId: state.groupId,
168-
source: "text",
169-
sourceDescription: "session-snapshot",
170-
});
171-
logger.info("Saved session snapshot", { sessionId });
167+
if (lastSnapshotBody.get(sessionId) === snapshotContent) {
168+
logger.debug("Skipping duplicate session snapshot", {
169+
sessionId,
170+
});
171+
} else {
172+
await client.addEpisode({
173+
name: `Snapshot: ${sessionId}`,
174+
episodeBody: snapshotContent,
175+
groupId: state.groupId,
176+
source: "text",
177+
sourceDescription: "session-snapshot",
178+
});
179+
lastSnapshotBody.set(sessionId, snapshotContent);
180+
logger.info("Saved session snapshot", { sessionId });
181+
}
172182
}
173183
} catch (err) {
174184
logger.error("Failed to save session snapshot", { sessionId, err });

0 commit comments

Comments
 (0)