From a9ee6618fe64a8fec7f603efbe47dc5c69a4349f Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Wed, 12 Aug 2026 20:38:13 +0100 Subject: [PATCH] fix(memory): recall prior conversation after session reset (#122051) * fix(memory): recall archived session generations after reset * test(memory): prove private recall across reset * fix(memory): keep deleted transcripts outside reset recall * fix(memory): reject deleted archives from reset recall * fix(memory): isolate recall across sqlite resets * test(memory): split reset recall coverage * fix(memory): keep reset recall metadata private * fix(memory): keep reset authority scoped --------- Co-authored-by: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> --- .../src/memory/manager-embedding-ops.ts | 24 +- .../manager-reset-chunk-boundary.test.ts | 18 + .../memory/manager-reset-chunk-boundary.ts | 32 + .../src/session-reset-recall-metadata.test.ts | 17 + .../src/session-reset-recall-metadata.ts | 35 ++ ...ion-search-reset-recall-visibility.test.ts | 255 ++++++++ .../src/session-search-visibility.test.ts | 87 +-- .../src/session-search-visibility.ts | 62 +- .../qa-lab/src/scenario-catalog.test.ts | 1 + .../qa-lab/src/suite-runtime-agent-session.ts | 34 +- .../host/session-files-reset-revision.test.ts | 107 ++++ .../memory-host-sdk/src/host/session-files.ts | 16 +- .../src/host/session-reset-recall.test.ts | 40 ++ .../src/host/session-reset-recall.ts | 39 ++ .../memory/remember-across-reset-private.yaml | 557 ++++++++++++++++++ 15 files changed, 1267 insertions(+), 57 deletions(-) create mode 100644 extensions/memory-core/src/memory/manager-reset-chunk-boundary.test.ts create mode 100644 extensions/memory-core/src/memory/manager-reset-chunk-boundary.ts create mode 100644 extensions/memory-core/src/session-reset-recall-metadata.test.ts create mode 100644 extensions/memory-core/src/session-reset-recall-metadata.ts create mode 100644 extensions/memory-core/src/session-search-reset-recall-visibility.test.ts create mode 100644 packages/memory-host-sdk/src/host/session-files-reset-revision.test.ts create mode 100644 packages/memory-host-sdk/src/host/session-reset-recall.test.ts create mode 100644 packages/memory-host-sdk/src/host/session-reset-recall.ts create mode 100644 qa/scenarios/memory/remember-across-reset-private.yaml diff --git a/extensions/memory-core/src/memory/manager-embedding-ops.ts b/extensions/memory-core/src/memory/manager-embedding-ops.ts index b88a319a724d..e04f6f14db34 100644 --- a/extensions/memory-core/src/memory/manager-embedding-ops.ts +++ b/extensions/memory-core/src/memory/manager-embedding-ops.ts @@ -17,6 +17,8 @@ import { hashText, INVALID_PROJECT_ANNOTATION_KEY, MEMORY_EMBEDDING_CACHE_TABLE, + MEMORY_INDEX_CHUNK_PROVENANCE_TABLE, + MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE, MEMORY_INDEX_FTS_TABLE, MEMORY_INDEX_VECTOR_TABLE, remapChunkLines, @@ -24,14 +26,13 @@ import { runWithConcurrency, stripMemoryAnnotationCarriers, type MemoryChunk, - type MemorySource, type MemoryEntryProvenance, - MEMORY_INDEX_CHUNK_PROVENANCE_TABLE, - MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE, + type MemorySource, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime"; +import { readSessionResetRecallCutoffMetadata } from "../session-reset-recall-metadata.js"; import type { EmbeddingProvider } from "./embeddings.js"; import { MEMORY_BATCH_FAILURE_LIMIT, @@ -59,6 +60,7 @@ import { resolveMemoryIndexProviderIdentities, type MemoryIndexProviderIdentity, } from "./manager-reindex-state.js"; +import { chunkSessionContentAtResetBoundary } from "./manager-reset-chunk-boundary.js"; import { MemoryManagerSyncOps, type MemoryIndexWorkItem, @@ -1121,11 +1123,19 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { (normalizedEntryPath === "MEMORY.md" || normalizedEntryPath === "USER.md"); const indexingContent = options.source === "memory" ? stripMemoryAnnotationCarriers(content) : content; + const chunkOptions = { ...this.settings.chunking, perEntry }; const baseChunks = filterNonEmptyMemoryChunks( - chunkMarkdown(indexingContent, { - ...this.settings.chunking, - perEntry, - }), + options.source === "sessions" + ? chunkSessionContentAtResetBoundary({ + content: indexingContent, + cutoffLine: (() => { + const cutoff = readSessionResetRecallCutoffMetadata(entry); + return cutoff.state === "valid" ? cutoff.cutoffLine : undefined; + })(), + lineMap: entry.lineMap, + chunking: chunkOptions, + }) + : chunkMarkdown(indexingContent, chunkOptions), ); for (const chunk of baseChunks) { chunk.provenance = this.resolveChunkProvenance( diff --git a/extensions/memory-core/src/memory/manager-reset-chunk-boundary.test.ts b/extensions/memory-core/src/memory/manager-reset-chunk-boundary.test.ts new file mode 100644 index 000000000000..fa6fdb391a38 --- /dev/null +++ b/extensions/memory-core/src/memory/manager-reset-chunk-boundary.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { chunkSessionContentAtResetBoundary } from "./manager-reset-chunk-boundary.js"; + +describe("chunkSessionContentAtResetBoundary", () => { + it("never overlaps a pre-reset chunk into the current generation", () => { + const chunks = chunkSessionContentAtResetBoundary({ + content: "old one\nold two\nkept live\ncurrent turn", + cutoffLine: 7, + lineMap: [2, 4, 7, 9], + chunking: { tokens: 100, overlap: 50 }, + }); + + expect(chunks.map((chunk) => [chunk.startLine, chunk.endLine, chunk.text])).toEqual([ + [1, 2, "old one\nold two"], + [3, 4, "kept live\ncurrent turn"], + ]); + }); +}); diff --git a/extensions/memory-core/src/memory/manager-reset-chunk-boundary.ts b/extensions/memory-core/src/memory/manager-reset-chunk-boundary.ts new file mode 100644 index 000000000000..a13d47756234 --- /dev/null +++ b/extensions/memory-core/src/memory/manager-reset-chunk-boundary.ts @@ -0,0 +1,32 @@ +import { + chunkMarkdown, + type MemoryChunk, +} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; + +export function chunkSessionContentAtResetBoundary(params: { + content: string; + cutoffLine?: number; + lineMap?: readonly number[]; + chunking: { tokens: number; overlap: number; perEntry?: boolean }; +}): MemoryChunk[] { + const cutoffIndex = + params.cutoffLine !== undefined && params.lineMap + ? params.lineMap.findIndex((line) => line >= params.cutoffLine!) + : -1; + if (cutoffIndex <= 0) { + return chunkMarkdown(params.content, params.chunking); + } + const lines = params.content.split("\n"); + const chunkPartition = (content: string, lineOffset: number) => { + const chunks = chunkMarkdown(content, params.chunking); + for (const chunk of chunks) { + chunk.startLine += lineOffset; + chunk.endLine += lineOffset; + } + return chunks; + }; + return [ + ...chunkPartition(lines.slice(0, cutoffIndex).join("\n"), 0), + ...chunkPartition(lines.slice(cutoffIndex).join("\n"), cutoffIndex), + ]; +} diff --git a/extensions/memory-core/src/session-reset-recall-metadata.test.ts b/extensions/memory-core/src/session-reset-recall-metadata.test.ts new file mode 100644 index 000000000000..3136915cf212 --- /dev/null +++ b/extensions/memory-core/src/session-reset-recall-metadata.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { readSessionArchiveReasonFromHitPath } from "./session-reset-recall-metadata.js"; + +describe("readSessionArchiveReasonFromHitPath", () => { + it.each([ + ["sessions/a.jsonl.reset.2026-08-11T08-00-00Z", "reset"], + ["sessions/a.jsonl.reset.2026-08-11T08-00-00.000Z.zst", "reset"], + ["sessions/a.jsonl.deleted.2026-08-11T08-00-00Z", "deleted"], + ["sessions\\a.jsonl.deleted.2026-08-11T08-00-00.000Z.zst", "deleted"], + ["sessions/a.jsonl.reset.2026-08-11T08:00:00Z", undefined], + ["sessions/a.jsonl.reset.2026-08-11T08-00-00Z.gz", undefined], + ["sessions/a.jsonl.deleted.2026-08-11T08-00-00Z.extra", undefined], + ["sessions/a.jsonl.RESET.2026-08-11T08-00-00Z", undefined], + ])("classifies %s", (path, expected) => { + expect(readSessionArchiveReasonFromHitPath(path)).toBe(expected); + }); +}); diff --git a/extensions/memory-core/src/session-reset-recall-metadata.ts b/extensions/memory-core/src/session-reset-recall-metadata.ts new file mode 100644 index 000000000000..87ebb5c9bf35 --- /dev/null +++ b/extensions/memory-core/src/session-reset-recall-metadata.ts @@ -0,0 +1,35 @@ +export type SessionResetRecallCutoff = + | { state: "absent" } + | { state: "invalid" } + | { cutoffLine: number; state: "valid" }; + +const RESET_RECALL_CUTOFF = Symbol.for("openclaw.memory.sessionResetRecallCutoff"); + +export function readSessionResetRecallCutoffMetadata(value: unknown): SessionResetRecallCutoff { + if (!value || typeof value !== "object") { + return { state: "invalid" }; + } + const cutoff = (value as Record)[RESET_RECALL_CUTOFF]; + if (!cutoff || typeof cutoff !== "object") { + return { state: "invalid" }; + } + const state = (cutoff as { state?: unknown }).state; + if (state === "absent" || state === "invalid") { + return { state }; + } + const cutoffLine = (cutoff as { cutoffLine?: unknown }).cutoffLine; + return state === "valid" && typeof cutoffLine === "number" && Number.isInteger(cutoffLine) + ? { state, cutoffLine } + : { state: "invalid" }; +} + +export function readSessionArchiveReasonFromHitPath( + hitPath: string, +): "reset" | "deleted" | undefined { + const match = + /\.jsonl\.(reset|deleted)\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z(?:\.zst)?$/.exec( + hitPath, + ); + const reason = match?.[1]; + return reason === "reset" || reason === "deleted" ? reason : undefined; +} diff --git a/extensions/memory-core/src/session-search-reset-recall-visibility.test.ts b/extensions/memory-core/src/session-search-reset-recall-visibility.test.ts new file mode 100644 index 000000000000..8fc584e31ce7 --- /dev/null +++ b/extensions/memory-core/src/session-search-reset-recall-visibility.test.ts @@ -0,0 +1,255 @@ +import * as engineSessions from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; +import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; +import { asOpenClawConfig } from "./tools.test-helpers.js"; + +type TestSessionEntry = { + sessionId: string; + updatedAt: number; + sessionFile: string; + chatType?: "direct" | "group" | "channel"; +}; + +let combinedSessionStore: Record = {}; + +function entryWithCutoff(cutoff: unknown) { + const entry = {}; + Object.defineProperty(entry, Symbol.for("openclaw.memory.sessionResetRecallCutoff"), { + enumerable: false, + value: cutoff, + }); + return entry; +} + +vi.mock("openclaw/plugin-sdk/memory-core-host-engine-sessions", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + buildSessionEntry: vi.fn(async () => entryWithCutoff({ state: "absent" })), + }; +}); + +vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: vi.fn(() => ({ + storePath: "(test)", + store: combinedSessionStore, + })), + }; +}); + +describe("reset-generation session search visibility", () => { + afterEach(() => { + vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear(); + vi.mocked(engineSessions.buildSessionEntry).mockReset(); + vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue( + entryWithCutoff({ state: "absent" }) as never, + ); + combinedSessionStore = {}; + }); + + it.each([ + { name: "pre-reset", range: [2, 3], cutoff: { state: "valid", cutoffLine: 4 }, kept: true }, + { name: "crossing", range: [3, 4], cutoff: { state: "valid", cutoffLine: 4 }, kept: false }, + { name: "current", range: [4, 5], cutoff: { state: "valid", cutoffLine: 4 }, kept: false }, + { name: "missing", range: [1, 2], cutoff: { state: "absent" }, kept: false }, + { name: "missing-contract", range: [1, 2], cutoff: undefined, kept: false }, + { name: "malformed", range: [1, 2], cutoff: { state: "invalid" }, kept: false }, + ] as const)( + "handles a $name live SQLite reset-generation hit", + async ({ range, cutoff, kept }) => { + const anchorSessionKey = "agent:main:telegram:direct:owner"; + combinedSessionStore = { + [anchorSessionKey]: { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + }; + vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue( + (cutoff === undefined ? {} : entryWithCutoff(cutoff)) as never, + ); + const hit: MemorySearchResult = { + path: "sessions/main/current.jsonl", + source: "sessions", + score: 1, + snippet: "short fact", + startLine: range[0], + endLine: range[1], + }; + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }), + agentId: "main", + requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`, + sandboxed: false, + hits: [hit], + conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" }, + }); + + expect(filtered).toEqual(kept ? [hit] : []); + }, + ); + + it("resolves the live anchor reset cutoff once per filter pass", async () => { + const anchorSessionKey = "agent:main:telegram:direct:owner"; + combinedSessionStore = { + [anchorSessionKey]: { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + }; + vi.mocked(engineSessions.buildSessionEntry).mockResolvedValue( + entryWithCutoff({ state: "valid", cutoffLine: 5 }) as never, + ); + const hits: MemorySearchResult[] = [ + { + path: "sessions/main/current.jsonl", + source: "sessions", + score: 1, + snippet: "first pre-reset chunk", + startLine: 1, + endLine: 2, + }, + { + path: "sessions/main/current.jsonl", + source: "sessions", + score: 0.9, + snippet: "second pre-reset chunk", + startLine: 3, + endLine: 4, + }, + ]; + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }), + agentId: "main", + requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`, + sandboxed: false, + hits, + conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" }, + }); + + expect(filtered).toEqual(hits); + expect(engineSessions.buildSessionEntry).toHaveBeenCalledTimes(1); + expect(engineSessions.buildSessionEntry).toHaveBeenCalledWith("current.jsonl", { + agentId: "main", + sessionId: "current", + sessionKey: anchorSessionKey, + storePath: "(test)", + updatedAtMs: 2, + }); + }); + + it.each(["", ".zst"])( + "allows an archived reset generation of the private anchor conversation%s", + async (compressionSuffix) => { + const anchorSessionKey = "agent:main:telegram:direct:owner"; + combinedSessionStore = { + [anchorSessionKey]: { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: `sessions/main/current.jsonl.reset.2026-08-11T08-00-00.000Z${compressionSuffix}`, + source: "sessions", + score: 1, + snippet: "prior conversation context", + startLine: 1, + endLine: 2, + }; + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }), + agentId: "main", + requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`, + sandboxed: false, + hits: [hit], + conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" }, + }); + + expect(filtered).toEqual([hit]); + }, + ); + + it.each([ + { + name: "the private anchor conversation", + path: "sessions/main/current.jsonl.deleted.2026-08-11T08-00-00.000Z", + snippet: "explicitly deleted private context", + includeDeletedSource: false, + }, + { + name: "the compressed private anchor conversation", + path: "sessions/main/current.jsonl.deleted.2026-08-11T08-00-00.000Z.zst", + snippet: "explicitly deleted compressed private context", + includeDeletedSource: false, + }, + { + name: "another private conversation", + path: "sessions/main/deleted-source.jsonl.deleted.2026-08-11T08-00-00.000Z", + snippet: "intentionally deleted private context", + includeDeletedSource: true, + }, + { + name: "another compressed private conversation", + path: "sessions/main/deleted-source.jsonl.deleted.2026-08-11T08-00-00.000Z.zst", + snippet: "intentionally deleted compressed private context", + includeDeletedSource: true, + }, + ])( + "denies an archived deleted generation from $name", + async ({ path, snippet, includeDeletedSource }) => { + const anchorSessionKey = "agent:main:telegram:direct:owner"; + combinedSessionStore = { + [anchorSessionKey]: { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + ...(includeDeletedSource + ? { + "agent:main:telegram:direct:deleted-source": { + sessionId: "deleted-source", + updatedAt: 1, + sessionFile: "/tmp/sessions/deleted-source.jsonl", + chatType: "direct" as const, + }, + } + : {}), + }; + const hit: MemorySearchResult = { + path, + source: "sessions", + score: 1, + snippet, + startLine: 1, + endLine: 2, + }; + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }), + agentId: "main", + requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`, + sandboxed: false, + hits: [hit], + conversationRecall: { anchorSessionKey, scope: "same-agent-private", corpus: "sessions" }, + }); + + expect(filtered).toEqual([]); + }, + ); +}); diff --git a/extensions/memory-core/src/session-search-visibility.test.ts b/extensions/memory-core/src/session-search-visibility.test.ts index fe4c8d4388d1..9544f533f71d 100644 --- a/extensions/memory-core/src/session-search-visibility.test.ts +++ b/extensions/memory-core/src/session-search-visibility.test.ts @@ -282,10 +282,10 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { }, }; const hit: MemorySearchResult = { - path: "sessions/other-private.jsonl", + path: "sessions/main/current.jsonl.reset.2026-08-11T08-00-00.000Z", source: "sessions", score: 1, - snippet: "private context", + snippet: "prior private context", startLine: 1, endLine: 2, }; @@ -431,45 +431,54 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { expect(filtered).toStrictEqual([]); }); - it("denies another agent's private transcript during trusted conversation recall", async () => { - combinedSessionStore = { - "agent:main:telegram:direct:owner": { - sessionId: "current", - updatedAt: 2, - sessionFile: "/tmp/sessions/current.jsonl", - chatType: "direct", - }, - "agent:peer:telegram:direct:owner": { - sessionId: "peer-private", - updatedAt: 1, - sessionFile: "/tmp/sessions/peer-private.jsonl", - chatType: "direct", - }, - }; - const hit: MemorySearchResult = { - path: "sessions/peer-private.jsonl", - source: "sessions", - score: 1, - snippet: "other agent context", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); + it.each([ + { name: "live", path: "sessions/peer-private.jsonl" }, + { + name: "archived", + path: "sessions/peer/peer-private.jsonl.reset.2026-08-11T08-00-00.000Z", + }, + ])( + "denies another agent's $name private transcript during trusted conversation recall", + async ({ path }) => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:peer:telegram:direct:owner": { + sessionId: "peer-private", + updatedAt: 1, + sessionFile: "/tmp/sessions/peer-private.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path, + source: "sessions", + score: 1, + snippet: "other agent context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:telegram:direct:owner", - sandboxed: false, - hits: [hit], - conversationRecall: { - anchorSessionKey: "agent:main:telegram:direct:owner", - scope: "same-agent-private", - corpus: "sessions", - }, - }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); - expect(filtered).toStrictEqual([]); - }); + expect(filtered).toStrictEqual([]); + }, + ); it("denies persisted Active Memory helper transcripts under explicit sessions", async () => { combinedSessionStore = { diff --git a/extensions/memory-core/src/session-search-visibility.ts b/extensions/memory-core/src/session-search-visibility.ts index 0ab0cd88709b..88dbfc22135b 100644 --- a/extensions/memory-core/src/session-search-visibility.ts +++ b/extensions/memory-core/src/session-search-visibility.ts @@ -1,4 +1,5 @@ // Memory Core plugin module implements session search visibility behavior. +import { buildSessionEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; @@ -14,6 +15,11 @@ import { createSessionVisibilityGuard, resolveEffectiveSessionToolsVisibility, } from "openclaw/plugin-sdk/session-visibility"; +import { + readSessionArchiveReasonFromHitPath, + readSessionResetRecallCutoffMetadata, + type SessionResetRecallCutoff, +} from "./session-reset-recall-metadata.js"; function normalizeAgentIdForCompare(value: string | undefined): string | undefined { return value?.trim().toLowerCase() || undefined; @@ -186,7 +192,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { }) : null; - const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway( + const { store: combinedSessionStore, storePath } = loadCombinedSessionStoreForGateway( params.cfg, scopedAgentId ? { agentId: scopedAgentId } : {}, ); @@ -200,6 +206,26 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { ? resolveSessionAgentId({ sessionKey: anchorSessionKey, config: params.cfg }) : undefined; const anchorEntry = anchorSessionKey ? combinedSessionStore[anchorSessionKey] : undefined; + let anchorResetCutoffPromise: Promise | undefined; + const resolveAnchorResetCutoff = () => { + if (anchorResetCutoffPromise) { + return anchorResetCutoffPromise; + } + const sessionId = anchorEntry?.sessionId?.trim(); + if (!recallAgentId || !sessionId || !anchorSessionKey) { + return Promise.resolve({ state: "invalid" }); + } + anchorResetCutoffPromise = buildSessionEntry(`${sessionId}.jsonl`, { + agentId: recallAgentId, + sessionId, + sessionKey: anchorSessionKey, + storePath, + updatedAtMs: anchorEntry?.updatedAt, + }) + .then(readSessionResetRecallCutoffMetadata) + .catch(() => ({ state: "invalid" })); + return anchorResetCutoffPromise; + }; const recallAuthorized = Boolean( conversationRecall && !params.sandboxed && @@ -230,7 +256,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { : []; } - const isSessionKeyAllowed = (key: string): boolean => { + const isSessionKeyAllowed = (key: string, allowAnchorTranscript = false): boolean => { if (!conversationRecall || !anchorSessionKey || !recallAgentId) { // A bare global key is local to the selected agent store. Reattach that // owner before applying visibility or non-default agents look cross-agent. @@ -242,8 +268,11 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { } const candidateEntry = combinedSessionStore[key]; // Canonical and legacy alias keys can identify one transcript. Exclude the - // anchor by transcript identity so an alias cannot re-inject current context. - if (key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry)) { + // live anchor, but let prior archived generations pass the privacy checks below. + if ( + !allowAnchorTranscript && + (key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry)) + ) { return false; } const candidateAgentId = resolveSessionAgentId({ sessionKey: key, config: params.cfg }); @@ -277,12 +306,12 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { return [...expanded]; }; - const areSessionKeysAllowed = (keys: string[]): boolean => { + const areSessionKeysAllowed = (keys: string[], allowAnchorTranscript = false): boolean => { // Product recall fails closed when aliases disagree about privacy. Ordinary // session-tool visibility keeps its existing any-visible-alias behavior. return conversationRecall - ? expandRecallAliasKeys(keys).every(isSessionKeyAllowed) - : keys.some(isSessionKeyAllowed); + ? expandRecallAliasKeys(keys).every((key) => isSessionKeyAllowed(key, allowAnchorTranscript)) + : keys.some((key) => isSessionKeyAllowed(key)); }; const next: MemorySearchResult[] = []; @@ -300,6 +329,10 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { if (!identity) { continue; } + const archiveReason = readSessionArchiveReasonFromHitPath(hit.path); + if (conversationRecall && archiveReason === "deleted") { + continue; + } const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId); const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId); if ( @@ -342,7 +375,20 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { } continue; } - const allowed = areSessionKeysAllowed(keys); + let allowResetAnchor = false; + const anchorSessionId = anchorEntry?.sessionId?.trim(); + if ( + conversationRecall && + !identity.archived && + recallAgentId && + anchorSessionId && + identity.stem === anchorSessionId && + normalizedOwnerAgentId === normalizeAgentIdForCompare(recallAgentId) + ) { + const cutoff = await resolveAnchorResetCutoff(); + allowResetAnchor = cutoff?.state === "valid" && hit.endLine < cutoff.cutoffLine; + } + const allowed = areSessionKeysAllowed(keys, archiveReason === "reset" || allowResetAnchor); if (!allowed) { continue; } diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 2dc9d43fb12a..9cbe8d8df834 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -246,6 +246,7 @@ describe("qa scenario catalog", () => { "matrix-restart-resume", "qa-channel-reconnect-dedupe", "remember-across-conversations", + "remember-across-reset-private", "slack-restart-resume", "subagent-stale-child-links", "telegram-repeated-command-authorization", diff --git a/extensions/qa-lab/src/suite-runtime-agent-session.ts b/extensions/qa-lab/src/suite-runtime-agent-session.ts index dcb1b3919219..cb665d88c61e 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-session.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-session.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { buildSessionEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions"; import { listSessionEntries, loadTranscriptEventsSync, @@ -44,6 +45,7 @@ type QaSessionTranscriptSeedParams = { const SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const MAX_COMPACTION_SUMMARIES = 16; const MAX_SUCCESSFUL_TOOL_CALL_EVENTS = 64; +const SESSION_RESET_RECALL_CUTOFF = Symbol.for("openclaw.memory.sessionResetRecallCutoff"); type QaSessionTranscriptSummary = { assistantMirrors?: Array<{ identity: string; text: string }>; @@ -61,11 +63,14 @@ type QaSessionTranscriptSummary = { lastAssistantStopReason?: string; lastAssistantToolNames?: string[]; lastMessageRole?: string; + resetRecallCutoffLine?: number; + probeTextEndLine?: number; }; type QaSessionTranscriptSummaryOptions = { afterEventCursor?: number; allowEmpty?: boolean; + probeText?: string; }; function isSessionStoreFtsSettleRace(error: unknown) { @@ -427,7 +432,34 @@ async function readSessionTranscriptSummary( if (selectedEvents.length === 0 && options.allowEmpty === true) { return emptySessionTranscriptSummary(events.length); } - return summarizeSessionTranscriptEvents(selectedEvents, normalizedSessionKey, events.length); + const summary = summarizeSessionTranscriptEvents( + selectedEvents, + normalizedSessionKey, + events.length, + ); + const probeText = options.probeText?.trim(); + let cutoff: unknown; + if (probeText) { + const runtimeEnv = qaSessionRuntimeEnv(env.gateway.tempRoot); + const storePath = resolveStorePath(undefined, { agentId: "qa", env: runtimeEnv }); + const transcriptEntry = await buildSessionEntry( + path.join(env.gateway.tempRoot, "state", "agents", "qa", "sessions", `${sessionId}.jsonl`), + { agentId: "qa", sessionId, sessionKey: normalizedSessionKey, storePath }, + ); + cutoff = transcriptEntry + ? (transcriptEntry as unknown as Record)[SESSION_RESET_RECALL_CUTOFF] + : undefined; + } + const probeTextEndLine = probeText + ? events.findLastIndex((event) => JSON.stringify(event).includes(probeText)) + 1 + : 0; + return { + ...summary, + ...(isRecord(cutoff) && cutoff.state === "valid" && typeof cutoff.cutoffLine === "number" + ? { resetRecallCutoffLine: cutoff.cutoffLine } + : {}), + ...(probeTextEndLine > 0 ? { probeTextEndLine } : {}), + }; } export { diff --git a/packages/memory-host-sdk/src/host/session-files-reset-revision.test.ts b/packages/memory-host-sdk/src/host/session-files-reset-revision.test.ts new file mode 100644 index 000000000000..dd503b2bd7b8 --- /dev/null +++ b/packages/memory-host-sdk/src/host/session-files-reset-revision.test.ts @@ -0,0 +1,107 @@ +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, +} from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + persistSessionTranscriptTurn, + resetSessionEntryLifecycle, + upsertSessionEntryCore, +} from "../../../../src/config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../../../src/state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../../../../src/state/openclaw-state-db.js"; +import { buildSessionEntry, type SessionFileEntry } from "./session-files.js"; + +function requireSessionEntry(entry: SessionFileEntry | null): SessionFileEntry { + if (!entry) { + throw new Error("expected session entry"); + } + return entry; +} + +let tmpDir: string; +let previousStateDir: string | undefined; +let previousConfigPath: string | undefined; + +beforeEach(() => { + tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "session-reset-revision-test-")); + previousStateDir = process.env.OPENCLAW_STATE_DIR; + previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + Reflect.set(process.env, "OPENCLAW_STATE_DIR", tmpDir); + clearRuntimeConfigSnapshot(); + clearConfigCache(); +}); + +afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + if (previousStateDir === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_STATE_DIR"); + } else { + Reflect.set(process.env, "OPENCLAW_STATE_DIR", previousStateDir); + } + if (previousConfigPath === undefined) { + Reflect.deleteProperty(process.env, "OPENCLAW_CONFIG_PATH"); + } else { + Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", previousConfigPath); + } + clearRuntimeConfigSnapshot(); + clearConfigCache(); + fsSync.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("SQLite session reset content revision", () => { + it("invalidates a session hash when a reset boundary changes its generation", async () => { + const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); + const storePath = path.join(sessionsDir, "sessions.json"); + const sessionKey = "agent:main:chat:reset-revision"; + const sessionId = "reset-revision"; + fsSync.mkdirSync(sessionsDir, { recursive: true }); + await upsertSessionEntryCore( + { agentId: "main", sessionKey, storePath }, + { sessionId, updatedAt: 1 }, + ); + await persistSessionTranscriptTurn( + { agentId: "main", sessionId, sessionKey, storePath }, + { + messages: [{ message: { role: "user", content: "unchanged exported text" } }], + touchSessionEntry: true, + updateMode: "none", + }, + ); + const buildOptions = { + agentId: "main", + sessionId, + sessionKey, + storePath, + updatedAtMs: 1, + }; + const before = requireSessionEntry(await buildSessionEntry(sessionKey, buildOptions)); + + await resetSessionEntryLifecycle({ + agentId: "main", + buildNextEntry: ({ currentEntry }) => ({ + ...currentEntry, + sessionId, + updatedAt: 2, + }), + resetBoundaryReason: "reset", + storePath, + target: { canonicalKey: sessionKey, storeKeys: [sessionKey] }, + }); + + const after = requireSessionEntry(await buildSessionEntry(sessionKey, buildOptions)); + expect(after.content).toBe(before.content); + expect(after.lineMap).toEqual(before.lineMap); + const cutoffSymbol = Symbol.for("openclaw.memory.sessionResetRecallCutoff"); + expect(Object.getOwnPropertyDescriptor(after, cutoffSymbol)).toMatchObject({ + enumerable: false, + value: { state: "valid", cutoffLine: expect.any(Number) }, + }); + expect(Object.keys(after)).not.toContain(cutoffSymbol.description); + expect(after.hash).not.toBe(before.hash); + }); +}); diff --git a/packages/memory-host-sdk/src/host/session-files.ts b/packages/memory-host-sdk/src/host/session-files.ts index 25171f5a01cc..dd3f025148ea 100644 --- a/packages/memory-host-sdk/src/host/session-files.ts +++ b/packages/memory-host-sdk/src/host/session-files.ts @@ -31,6 +31,7 @@ import { stripInternalRuntimeContext, } from "./openclaw-runtime-session.js"; import { retryTransientMemoryRead } from "./read-retry.js"; +import { resolveSessionResetRecallCutoff } from "./session-reset-recall.js"; import { listSessionTranscriptCorpusEntriesForAgent, listSessionTranscriptCorpusEntriesForAgentSync, @@ -775,11 +776,13 @@ export async function buildSessionEntry( const records = loadTranscriptEventsSync({ ...sqliteIdentity, }); + const resetRecallCutoff = resolveSessionResetRecallCutoff(records); const raw = serializeTranscriptEvents(records); return { mtimeMs: opts.updatedAtMs ?? stats.maxSeq, path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId), raw, + resetRecallCutoff, size: stats.sizeBytes, }; })() @@ -959,7 +962,7 @@ export async function buildSessionEntry( lineProvenance.push(...renderedLines.map(() => memoryProvenance)); } const content = collected.join("\n"); - return { + const entry: SessionFileEntry = { path: memoryPath, absPath, mtimeMs, @@ -971,7 +974,9 @@ export async function buildSessionEntry( "\n" + messageTimestampsMs.join(",") + "\n" + - JSON.stringify(lineProvenance), + JSON.stringify(lineProvenance) + + "\n" + + JSON.stringify(rawSource?.resetRecallCutoff ?? { state: "absent" }), ), content, lineMap, @@ -981,6 +986,13 @@ export async function buildSessionEntry( ...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}), ...(generatedByCronRun ? { generatedByCronRun: true } : {}), }; + Object.defineProperty(entry, Symbol.for("openclaw.memory.sessionResetRecallCutoff"), { + configurable: false, + enumerable: false, + value: rawSource?.resetRecallCutoff ?? { state: "absent" }, + writable: false, + }); + return entry; } catch (err) { void logSessionFileReadFailure(absPath, err); return null; diff --git a/packages/memory-host-sdk/src/host/session-reset-recall.test.ts b/packages/memory-host-sdk/src/host/session-reset-recall.test.ts new file mode 100644 index 000000000000..966dfebf8826 --- /dev/null +++ b/packages/memory-host-sdk/src/host/session-reset-recall.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { resolveSessionResetRecallCutoff } from "./session-reset-recall.js"; + +describe("resolveSessionResetRecallCutoff", () => { + it("uses the first kept entry before the latest reset as the live cutoff", () => { + expect( + resolveSessionResetRecallCutoff([ + { type: "message", id: "old" }, + { type: "reset", id: "first" }, + { type: "message", id: "kept" }, + { type: "message", id: "newer" }, + { type: "reset", id: "latest", firstKeptEntryId: "kept" }, + ]), + ).toEqual({ state: "valid", cutoffLine: 3 }); + }); + + it("uses the latest reset line when it keeps no earlier entries", () => { + expect( + resolveSessionResetRecallCutoff([ + { type: "message", id: "old" }, + { type: "reset", id: "latest" }, + { type: "message", id: "current" }, + ]), + ).toEqual({ state: "valid", cutoffLine: 2 }); + }); + + it.each([ + [[{ type: "message", id: "only" }]], + [[{ type: "reset", id: "latest", firstKeptEntryId: "missing" }]], + [[{ type: "reset", id: "latest", firstKeptEntryId: 42 }]], + [ + [ + { type: "reset", id: "latest", firstKeptEntryId: "after" }, + { type: "message", id: "after" }, + ], + ], + ])("fails closed for absent or invalid reset lineage", (events) => { + expect(resolveSessionResetRecallCutoff(events).state).not.toBe("valid"); + }); +}); diff --git a/packages/memory-host-sdk/src/host/session-reset-recall.ts b/packages/memory-host-sdk/src/host/session-reset-recall.ts new file mode 100644 index 000000000000..ef40e37a46a3 --- /dev/null +++ b/packages/memory-host-sdk/src/host/session-reset-recall.ts @@ -0,0 +1,39 @@ +type SessionResetRecallCutoff = + | { state: "absent" } + | { state: "invalid" } + | { cutoffLine: number; state: "valid" }; + +function eventId(event: unknown): string | undefined { + if (!event || typeof event !== "object" || Array.isArray(event)) { + return undefined; + } + const id = (event as { id?: unknown }).id; + return typeof id === "string" && id.trim() ? id : undefined; +} + +/** Resolves the first raw transcript line owned by the current reset generation. */ +export function resolveSessionResetRecallCutoff( + events: readonly unknown[], +): SessionResetRecallCutoff { + const resetIndex = events.findLastIndex( + (event) => + event !== null && + typeof event === "object" && + !Array.isArray(event) && + (event as { type?: unknown }).type === "reset", + ); + if (resetIndex < 0) { + return { state: "absent" }; + } + const reset = events[resetIndex] as { firstKeptEntryId?: unknown }; + if (reset.firstKeptEntryId === undefined) { + return { state: "valid", cutoffLine: resetIndex + 1 }; + } + if (typeof reset.firstKeptEntryId !== "string" || !reset.firstKeptEntryId.trim()) { + return { state: "invalid" }; + } + const keptIndex = events.findIndex( + (event, index) => index < resetIndex && eventId(event) === reset.firstKeptEntryId, + ); + return keptIndex < 0 ? { state: "invalid" } : { state: "valid", cutoffLine: keptIndex + 1 }; +} diff --git a/qa/scenarios/memory/remember-across-reset-private.yaml b/qa/scenarios/memory/remember-across-reset-private.yaml new file mode 100644 index 000000000000..87ddd8453c66 --- /dev/null +++ b/qa/scenarios/memory/remember-across-reset-private.yaml @@ -0,0 +1,557 @@ +title: Remember across a private session reset + +scenario: + id: remember-across-reset-private + surface: session-memory + risk: high + coverage: + primary: + - session-memory.active-memory-active-recall + - session-memory.active-memory-recall + secondary: + - session-memory.active-memory-qa-channel + - channels.channel-native-commands + objective: Verify SQLite reset continuity and pre-reset generation recall without admitting deleted, shared, or other-agent transcripts. + plugins: + - active-memory + gatewayConfigPatch: + session: + dmScope: per-channel-peer + memory: + search: + rememberAcrossConversations: true + agents: + entries: + peer: + identity: + name: QA Peer + plugins: + entries: + active-memory: + enabled: true + config: + enabled: true + mode: always + agents: [] + toolsAllow: + - memory_search + logging: true + persistTranscripts: true + transcriptDir: qa-remember-across-reset-private + queryMode: message + maxSummaryChars: 220 + successCriteria: + - A real Gateway session reset changes the private lifecycle revision while retaining its durable session id. + - The pre-reset fact is indexed under the canonical SQLite session identity before the current generation cutoff. + - Active Memory recalls the pre-reset private fact in the same conversation after the current reset. + - An indexed deleted private marker, shared marker, and independently indexed peer-agent marker are absent from accepted recall evidence. + docsRefs: + - docs/concepts/active-memory.md + - docs/reference/memory-config.md + - docs/reference/session-management-compaction.md + codeRefs: + - extensions/active-memory/index.ts + - extensions/memory-core/src/session-search-visibility.ts + - src/auto-reply/reply/session-reset-command.ts + - extensions/qa-lab/src/suite-runtime-flow.ts + - extensions/qa-lab/src/providers/mock-openai/server.ts + execution: + kind: flow + channel: qa-channel + providerMode: mock-openai + retryCount: 0 + suiteIsolation: isolated + isolationReason: Resets and indexes isolated QA and peer-agent transcripts while running an ephemeral Gateway child. + summary: Reset one private QA conversation, index its canonical SQLite transcript generations, and prove private-only Active Memory recall in that same conversation. + config: + requiredProviderMode: mock-openai + conversationId: remember-reset-private + deletedConversationId: remember-reset-deleted + groupConversationId: remember-reset-group + peerConversationId: remember-reset-peer + privateFact: lemon pepper wings with blue cheese + deletedFact: DELETED-RESET-ONLY cinnamon popcorn with chili salt + groupFact: GROUP-RESET-ONLY loaded nachos with black olives + peerFact: PEER-RESET-ONLY smoked tofu skewers + seedMarker: QA-REMEMBER-RESET-SOURCE-SEEDED + recallPrompt: "Remember across conversations QA check: what snack do I usually want for QA movie night? Reply in one short sentence." + expectedNeedle: lemon pepper wings with blue cheese + transcriptDir: qa-remember-across-reset-private + +flow: + steps: + - name: recalls the pre-reset private generation without crossing ownership boundaries + actions: + - assert: + expr: "env.providerMode === config.requiredProviderMode" + message: this deterministic reset-and-recall proof requires mock-openai + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + - resetTransport: true + - call: fs.rm + args: + - expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')" + - force: true + - call: fs.rm + args: + - expr: "path.join(env.gateway.workspaceDir, 'memory', `${formatMemoryDreamingDay(Date.now())}.md`)" + - force: true + - set: sourceDelivery + value: + expr: "transport.buildAgentDelivery({ target: `dm:${config.conversationId}` })" + - set: groupDelivery + value: + expr: "transport.buildAgentDelivery({ target: `channel:${config.groupConversationId}` })" + - set: deletedDelivery + value: + expr: "transport.buildAgentDelivery({ target: `dm:${config.deletedConversationId}` })" + - set: peerDelivery + value: + expr: "transport.buildAgentDelivery({ target: `dm:${config.peerConversationId}` })" + - set: sourceSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'qa', channel: sourceDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: sourceDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })" + - set: groupSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'qa', channel: groupDelivery.channel, accountId: transport.accountId, peer: { kind: 'channel', id: groupDelivery.replyTo } })" + - set: deletedSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'qa', channel: deletedDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: deletedDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })" + - set: peerSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'peer', channel: peerDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: peerDelivery.replyTo }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })" + - set: transcriptRoot + value: + expr: "path.join(env.gateway.tempRoot, 'state', 'plugins', 'active-memory', 'transcripts', 'agents', 'qa', config.transcriptDir)" + - call: fs.rm + args: + - ref: transcriptRoot + - recursive: true + force: true + - sendInbound: + conversation: + id: + ref: config.deletedConversationId + kind: direct + senderId: + ref: config.deletedConversationId + senderName: Remember Reset Deleted Source + text: + expr: "`Stable QA movie night usual favorite snack preference: ${config.deletedFact}. This source will be deleted. Acknowledge briefly.`" + - waitForOutbound: + conversation: + id: + ref: config.deletedConversationId + kind: direct + timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - sendInbound: + conversation: + id: + ref: config.conversationId + kind: direct + senderId: + ref: config.conversationId + senderName: Remember Reset Source + text: + expr: "`Stable QA movie night usual favorite snack preference: ${config.privateFact}. Reply exactly: ${config.seedMarker}.`" + - waitForOutbound: + conversation: + id: + ref: config.conversationId + kind: direct + textIncludes: + ref: config.seedMarker + timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - sendInbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + senderId: { ref: config.conversationId } + senderName: Remember Reset Source + text: "Filler turn one after the saved fact. Reply briefly." + - waitForOutbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" } + - sendInbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + senderId: { ref: config.conversationId } + senderName: Remember Reset Source + text: "Filler turn two after the saved fact. Reply briefly." + - waitForOutbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" } + - sendInbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + senderId: { ref: config.conversationId } + senderName: Remember Reset Source + text: "Filler turn three after the saved fact. Reply briefly." + - waitForOutbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" } + - sendInbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + senderId: { ref: config.conversationId } + senderName: Remember Reset Source + text: "Filler turn four after the saved fact. Reply briefly." + - waitForOutbound: + conversation: { id: { ref: config.conversationId }, kind: direct } + timeoutMs: { expr: "liveTurnTimeoutMs(env, 60000)" } + - sendInbound: + conversation: + id: + ref: config.groupConversationId + kind: channel + title: Remember Reset Group + senderId: remember-reset-group-member + senderName: Remember Reset Group Member + text: + expr: "`@openclaw Stable QA movie night usual favorite snack preference: ${config.groupFact}. This applies only inside this group. Acknowledge briefly.`" + - waitForOutbound: + conversation: + id: + ref: config.groupConversationId + kind: channel + timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - call: env.gateway.call + saveAs: peerStarted + args: + - agent + - agentId: peer + sessionKey: + ref: peerSessionKey + idempotencyKey: + expr: randomUUID() + message: + expr: "`Stable QA movie night usual favorite snack preference: ${config.peerFact}. This belongs only to the peer agent. Acknowledge briefly.`" + deliver: false + channel: + expr: peerDelivery.channel + to: + expr: "peerDelivery.to ?? `dm:${config.peerConversationId}`" + - timeoutMs: + expr: liveTurnTimeoutMs(env, 180000) + - assert: + expr: "Boolean(peerStarted?.runId)" + message: + expr: "`peer agent run did not start: ${JSON.stringify(peerStarted)}`" + - call: env.gateway.call + saveAs: peerWaited + args: + - agent.wait + - runId: + expr: peerStarted.runId + timeoutMs: + expr: liveTurnTimeoutMs(env, 180000) + - timeoutMs: + expr: liveTurnTimeoutMs(env, 185000) + - assert: + expr: "['ok', 'completed', 'succeeded'].includes(peerWaited?.status) || (peerWaited?.status === 'error' && String(peerWaited?.error ?? '').trim().toLowerCase() === 'completed')" + message: + expr: "`peer agent run did not complete: ${JSON.stringify(peerWaited)}`" + - call: readRawQaSessionStore + saveAs: seededStore + args: + - ref: env + - call: readRawQaSessionStore + saveAs: peerStore + args: + - ref: env + - agentId: peer + - set: seedSession + value: + expr: seededStore[sourceSessionKey] + - set: groupSession + value: + expr: seededStore[groupSessionKey] + - set: deletedSession + value: + expr: seededStore[deletedSessionKey] + - set: peerSession + value: + expr: peerStore[peerSessionKey] + - assert: + expr: "Boolean(seedSession?.sessionId) && Boolean(deletedSession?.sessionId) && Boolean(groupSession?.sessionId) && Boolean(peerSession?.sessionId)" + message: + expr: "`seeded transcript identities missing: ${JSON.stringify({ source: seedSession, group: groupSession, peer: peerSession })}`" + - call: env.gateway.call + saveAs: resetResult + args: + - sessions.reset + - key: + ref: sourceSessionKey + reason: reset + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - set: resetSession + value: + expr: resetResult.entry + - assert: + expr: "resetResult?.ok === true && Boolean(resetSession?.lifecycleRevision) && resetSession.lifecycleRevision !== seedSession.lifecycleRevision" + message: + expr: "`gateway session reset did not advance lifecycle: ${JSON.stringify({ result: resetResult, before: seedSession?.lifecycleRevision })}`" + - assert: + expr: resetSession?.sessionId === seedSession.sessionId + message: + expr: "`durable session id changed across reset: ${JSON.stringify({ before: seedSession?.sessionId, after: resetSession?.sessionId })}`" + - call: readSessionTranscriptSummary + saveAs: resetTranscript + args: + - ref: env + - ref: sourceSessionKey + - probeText: + ref: config.privateFact + - assert: + expr: "Number.isInteger(resetTranscript.probeTextEndLine) && Number.isInteger(resetTranscript.resetRecallCutoffLine) && resetTranscript.probeTextEndLine < resetTranscript.resetRecallCutoffLine" + message: + expr: "`seed fact was not strictly before the effective SQLite reset cutoff: ${JSON.stringify(resetTranscript)}`" + - set: canonicalSessionPath + value: + expr: "`sessions/qa/${seedSession.sessionId}.jsonl`" + - call: env.gateway.call + saveAs: deletedResult + args: + - sessions.delete + - key: + ref: deletedSessionKey + expectedSessionId: + expr: deletedSession.sessionId + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - assert: + expr: "deletedResult?.deleted === true && deletedResult.archived?.some((entry) => String(entry).includes(`${deletedSession.sessionId}.jsonl.deleted.`))" + message: + expr: "`same-agent private deletion did not produce its archive: ${JSON.stringify(deletedResult)}`" + - set: deletedArchiveName + value: + expr: "path.basename(deletedResult.archived.find((entry) => String(entry).includes(`${deletedSession.sessionId}.jsonl.deleted.`)))" + - call: readConfigSnapshot + saveAs: preconditionConfig + args: + - ref: env + - set: originalMemorySearch + value: + expr: "preconditionConfig.config.memory && typeof preconditionConfig.config.memory === 'object' ? structuredClone(preconditionConfig.config.memory.search) : undefined" + - call: patchConfig + args: + - env: + ref: env + patch: + memory: + search: + expr: "{ ...structuredClone(originalMemorySearch ?? {}), sources: ['memory', 'sessions'] }" + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + - call: runQaCli + args: + - ref: env + - - memory + - index + - --agent + - peer + - --force + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - call: runQaCli + saveAs: peerSearch + args: + - ref: env + - - memory + - search + - --agent + - peer + - --json + - --query + - expr: config.peerFact + - --max-results + - "50" + - --min-score + - "0" + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + json: true + - assert: + expr: "JSON.stringify(peerSearch).includes(config.peerFact) && JSON.stringify(peerSearch).includes(peerSession.sessionId)" + message: + expr: "`peer agent index did not contain its marker: ${JSON.stringify(peerSearch)}`" + - call: runQaCli + args: + - ref: env + - - memory + - index + - --agent + - qa + - --force + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - call: runQaCli + saveAs: qaSearch + args: + - ref: env + - - memory + - search + - --agent + - qa + - --json + - --query + - expr: config.privateFact + - --max-results + - "50" + - --min-score + - "0" + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + json: true + - set: qaSearchText + value: + expr: JSON.stringify(qaSearch) + - call: runQaCli + saveAs: groupSearch + args: + - ref: env + - - memory + - search + - --agent + - qa + - --json + - --query + - expr: config.groupFact + - --max-results + - "50" + - --min-score + - "0" + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + json: true + - call: runQaCli + saveAs: deletedSearch + args: + - ref: env + - - memory + - search + - --agent + - qa + - --json + - --query + - expr: config.deletedFact + - --max-results + - "50" + - --min-score + - "0" + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + json: true + - set: deletedSearchText + value: + expr: JSON.stringify(deletedSearch) + - set: groupSearchText + value: + expr: JSON.stringify(groupSearch) + - assert: + expr: "groupSearchText.includes(config.groupFact) && groupSearchText.includes(groupSession.sessionId)" + message: + expr: "`QA index did not contain the seeded group transcript: ${groupSearchText}`" + - assert: + expr: "deletedSearchText.includes(config.deletedFact) && deletedSearchText.includes(deletedArchiveName)" + message: + expr: "`QA index did not contain the deleted private control: ${deletedSearchText}`" + - assert: + expr: "qaSearchText.includes(config.privateFact) && qaSearchText.includes(canonicalSessionPath)" + message: + expr: "`QA index did not return the pre-reset private fact at its canonical SQLite path: ${qaSearchText}`" + - call: patchConfig + args: + - env: + ref: env + patch: + memory: + search: + expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)" + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + - call: env.gateway.restartAfterStateMutation + args: + - lambda: + async: true + expr: await Promise.resolve() + - call: fs.rm + args: + - ref: transcriptRoot + - recursive: true + force: true + - set: requestCursorBeforeRecall + value: + expr: "(await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor" + - set: recallOutboundIndex + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length" + - sendInbound: + conversation: + id: + ref: config.conversationId + kind: direct + senderId: + ref: config.conversationId + senderName: Remember Reset Source + text: + ref: config.recallPrompt + - call: waitForOutboundMessage + saveAs: recallOutbound + args: + - ref: state + - lambda: + params: [candidate] + expr: "candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId" + - expr: liveTurnTimeoutMs(env, 60000) + - sinceIndex: + ref: recallOutboundIndex + - call: waitForCondition + saveAs: helperTranscriptPath + args: + - lambda: + async: true + expr: "await (async () => { const entries = (await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).toSorted(); return entries.length > 0 ? path.join(transcriptRoot, entries.at(-1)) : undefined; })()" + - expr: liveTurnTimeoutMs(env, 30000) + - 250 + - call: fs.readFile + saveAs: helperTranscriptText + args: + - ref: helperTranscriptPath + - utf8 + - set: recallRequests + value: + expr: "await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBeforeRecall}`)" + - assert: + expr: "normalizeLowercaseStringOrEmpty(recallOutbound.text).includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle))" + message: + expr: "`same-conversation post-reset reply missed the pre-reset private fact: ${recallOutbound.text}`" + - assert: + expr: "helperTranscriptText.includes('memory_search') && helperTranscriptText.includes(config.privateFact) && helperTranscriptText.includes(seedSession.sessionId)" + message: + expr: "`Active Memory helper did not accept the canonical pre-reset private fact: ${helperTranscriptText}`" + - assert: + expr: "!helperTranscriptText.includes(config.deletedFact) && !helperTranscriptText.includes(deletedArchiveName) && !helperTranscriptText.includes(config.groupFact) && !helperTranscriptText.includes(groupSession.sessionId) && !helperTranscriptText.includes(config.peerFact) && !helperTranscriptText.includes(peerSession.sessionId)" + message: + expr: "`Active Memory helper crossed a shared or peer-agent boundary: ${helperTranscriptText}`" + - assert: + expr: "recallRequests.some((request) => String(request.allInputText ?? '').includes('Remember across conversations QA check') && request.plannedToolName === 'memory_search')" + message: deterministic post-reset recall did not issue memory_search + detailsExpr: "JSON.stringify({ verdict: 'PASS', scenario: 'remember-across-reset-private', channel: 'qa-channel', provider: env.providerMode, gateway: 'ephemeral-child', lifecycleReset: resetSession.lifecycleRevision !== seedSession.lifecycleRevision, durableSessionRetained: resetSession.sessionId === seedSession.sessionId, canonicalResetBoundary: true, preResetHitIndexed: qaSearchText.includes(canonicalSessionPath), preResetHitAccepted: helperTranscriptText.includes(seedSession.sessionId), deletedTranscriptIndexed: deletedSearchText.includes(deletedArchiveName), deletedTranscriptExcluded: !helperTranscriptText.includes(deletedArchiveName), groupTranscriptIndexed: groupSearchText.includes(groupSession.sessionId), peerTranscriptIndexed: JSON.stringify(peerSearch).includes(peerSession.sessionId), privateRecallDelivered: normalizeLowercaseStringOrEmpty(recallOutbound.text).includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle)), sharedExcluded: !helperTranscriptText.includes(groupSession.sessionId), peerAgentExcluded: !helperTranscriptText.includes(peerSession.sessionId), reply: recallOutbound.text })"