mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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>
This commit is contained in:
committed by
GitHub
parent
11fe5abaa9
commit
a9ee6618fe
@@ -17,6 +17,8 @@ import {
|
|||||||
hashText,
|
hashText,
|
||||||
INVALID_PROJECT_ANNOTATION_KEY,
|
INVALID_PROJECT_ANNOTATION_KEY,
|
||||||
MEMORY_EMBEDDING_CACHE_TABLE,
|
MEMORY_EMBEDDING_CACHE_TABLE,
|
||||||
|
MEMORY_INDEX_CHUNK_PROVENANCE_TABLE,
|
||||||
|
MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE,
|
||||||
MEMORY_INDEX_FTS_TABLE,
|
MEMORY_INDEX_FTS_TABLE,
|
||||||
MEMORY_INDEX_VECTOR_TABLE,
|
MEMORY_INDEX_VECTOR_TABLE,
|
||||||
remapChunkLines,
|
remapChunkLines,
|
||||||
@@ -24,14 +26,13 @@ import {
|
|||||||
runWithConcurrency,
|
runWithConcurrency,
|
||||||
stripMemoryAnnotationCarriers,
|
stripMemoryAnnotationCarriers,
|
||||||
type MemoryChunk,
|
type MemoryChunk,
|
||||||
type MemorySource,
|
|
||||||
type MemoryEntryProvenance,
|
type MemoryEntryProvenance,
|
||||||
MEMORY_INDEX_CHUNK_PROVENANCE_TABLE,
|
type MemorySource,
|
||||||
MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE,
|
|
||||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||||
import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||||
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime";
|
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime";
|
||||||
|
import { readSessionResetRecallCutoffMetadata } from "../session-reset-recall-metadata.js";
|
||||||
import type { EmbeddingProvider } from "./embeddings.js";
|
import type { EmbeddingProvider } from "./embeddings.js";
|
||||||
import {
|
import {
|
||||||
MEMORY_BATCH_FAILURE_LIMIT,
|
MEMORY_BATCH_FAILURE_LIMIT,
|
||||||
@@ -59,6 +60,7 @@ import {
|
|||||||
resolveMemoryIndexProviderIdentities,
|
resolveMemoryIndexProviderIdentities,
|
||||||
type MemoryIndexProviderIdentity,
|
type MemoryIndexProviderIdentity,
|
||||||
} from "./manager-reindex-state.js";
|
} from "./manager-reindex-state.js";
|
||||||
|
import { chunkSessionContentAtResetBoundary } from "./manager-reset-chunk-boundary.js";
|
||||||
import {
|
import {
|
||||||
MemoryManagerSyncOps,
|
MemoryManagerSyncOps,
|
||||||
type MemoryIndexWorkItem,
|
type MemoryIndexWorkItem,
|
||||||
@@ -1121,11 +1123,19 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
|||||||
(normalizedEntryPath === "MEMORY.md" || normalizedEntryPath === "USER.md");
|
(normalizedEntryPath === "MEMORY.md" || normalizedEntryPath === "USER.md");
|
||||||
const indexingContent =
|
const indexingContent =
|
||||||
options.source === "memory" ? stripMemoryAnnotationCarriers(content) : content;
|
options.source === "memory" ? stripMemoryAnnotationCarriers(content) : content;
|
||||||
|
const chunkOptions = { ...this.settings.chunking, perEntry };
|
||||||
const baseChunks = filterNonEmptyMemoryChunks(
|
const baseChunks = filterNonEmptyMemoryChunks(
|
||||||
chunkMarkdown(indexingContent, {
|
options.source === "sessions"
|
||||||
...this.settings.chunking,
|
? chunkSessionContentAtResetBoundary({
|
||||||
perEntry,
|
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) {
|
for (const chunk of baseChunks) {
|
||||||
chunk.provenance = this.resolveChunkProvenance(
|
chunk.provenance = this.resolveChunkProvenance(
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PropertyKey, unknown>)[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;
|
||||||
|
}
|
||||||
@@ -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<string, TestSessionEntry> = {};
|
||||||
|
|
||||||
|
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<typeof import("openclaw/plugin-sdk/memory-core-host-engine-sessions")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
buildSessionEntry: vi.fn(async () => entryWithCutoff({ state: "absent" })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
|
||||||
|
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([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -282,10 +282,10 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const hit: MemorySearchResult = {
|
const hit: MemorySearchResult = {
|
||||||
path: "sessions/other-private.jsonl",
|
path: "sessions/main/current.jsonl.reset.2026-08-11T08-00-00.000Z",
|
||||||
source: "sessions",
|
source: "sessions",
|
||||||
score: 1,
|
score: 1,
|
||||||
snippet: "private context",
|
snippet: "prior private context",
|
||||||
startLine: 1,
|
startLine: 1,
|
||||||
endLine: 2,
|
endLine: 2,
|
||||||
};
|
};
|
||||||
@@ -431,45 +431,54 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
|
|||||||
expect(filtered).toStrictEqual([]);
|
expect(filtered).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("denies another agent's private transcript during trusted conversation recall", async () => {
|
it.each([
|
||||||
combinedSessionStore = {
|
{ name: "live", path: "sessions/peer-private.jsonl" },
|
||||||
"agent:main:telegram:direct:owner": {
|
{
|
||||||
sessionId: "current",
|
name: "archived",
|
||||||
updatedAt: 2,
|
path: "sessions/peer/peer-private.jsonl.reset.2026-08-11T08-00-00.000Z",
|
||||||
sessionFile: "/tmp/sessions/current.jsonl",
|
},
|
||||||
chatType: "direct",
|
])(
|
||||||
},
|
"denies another agent's $name private transcript during trusted conversation recall",
|
||||||
"agent:peer:telegram:direct:owner": {
|
async ({ path }) => {
|
||||||
sessionId: "peer-private",
|
combinedSessionStore = {
|
||||||
updatedAt: 1,
|
"agent:main:telegram:direct:owner": {
|
||||||
sessionFile: "/tmp/sessions/peer-private.jsonl",
|
sessionId: "current",
|
||||||
chatType: "direct",
|
updatedAt: 2,
|
||||||
},
|
sessionFile: "/tmp/sessions/current.jsonl",
|
||||||
};
|
chatType: "direct",
|
||||||
const hit: MemorySearchResult = {
|
},
|
||||||
path: "sessions/peer-private.jsonl",
|
"agent:peer:telegram:direct:owner": {
|
||||||
source: "sessions",
|
sessionId: "peer-private",
|
||||||
score: 1,
|
updatedAt: 1,
|
||||||
snippet: "other agent context",
|
sessionFile: "/tmp/sessions/peer-private.jsonl",
|
||||||
startLine: 1,
|
chatType: "direct",
|
||||||
endLine: 2,
|
},
|
||||||
};
|
};
|
||||||
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
|
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({
|
const filtered = await filterMemorySearchHitsBySessionVisibility({
|
||||||
cfg,
|
cfg,
|
||||||
requesterSessionKey: "agent:main:telegram:direct:owner",
|
requesterSessionKey: "agent:main:telegram:direct:owner",
|
||||||
sandboxed: false,
|
sandboxed: false,
|
||||||
hits: [hit],
|
hits: [hit],
|
||||||
conversationRecall: {
|
conversationRecall: {
|
||||||
anchorSessionKey: "agent:main:telegram:direct:owner",
|
anchorSessionKey: "agent:main:telegram:direct:owner",
|
||||||
scope: "same-agent-private",
|
scope: "same-agent-private",
|
||||||
corpus: "sessions",
|
corpus: "sessions",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(filtered).toStrictEqual([]);
|
expect(filtered).toStrictEqual([]);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("denies persisted Active Memory helper transcripts under explicit sessions", async () => {
|
it("denies persisted Active Memory helper transcripts under explicit sessions", async () => {
|
||||||
combinedSessionStore = {
|
combinedSessionStore = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Memory Core plugin module implements session search visibility behavior.
|
// 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 { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||||
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
|
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
|
||||||
@@ -14,6 +15,11 @@ import {
|
|||||||
createSessionVisibilityGuard,
|
createSessionVisibilityGuard,
|
||||||
resolveEffectiveSessionToolsVisibility,
|
resolveEffectiveSessionToolsVisibility,
|
||||||
} from "openclaw/plugin-sdk/session-visibility";
|
} from "openclaw/plugin-sdk/session-visibility";
|
||||||
|
import {
|
||||||
|
readSessionArchiveReasonFromHitPath,
|
||||||
|
readSessionResetRecallCutoffMetadata,
|
||||||
|
type SessionResetRecallCutoff,
|
||||||
|
} from "./session-reset-recall-metadata.js";
|
||||||
|
|
||||||
function normalizeAgentIdForCompare(value: string | undefined): string | undefined {
|
function normalizeAgentIdForCompare(value: string | undefined): string | undefined {
|
||||||
return value?.trim().toLowerCase() || undefined;
|
return value?.trim().toLowerCase() || undefined;
|
||||||
@@ -186,7 +192,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(
|
const { store: combinedSessionStore, storePath } = loadCombinedSessionStoreForGateway(
|
||||||
params.cfg,
|
params.cfg,
|
||||||
scopedAgentId ? { agentId: scopedAgentId } : {},
|
scopedAgentId ? { agentId: scopedAgentId } : {},
|
||||||
);
|
);
|
||||||
@@ -200,6 +206,26 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
|||||||
? resolveSessionAgentId({ sessionKey: anchorSessionKey, config: params.cfg })
|
? resolveSessionAgentId({ sessionKey: anchorSessionKey, config: params.cfg })
|
||||||
: undefined;
|
: undefined;
|
||||||
const anchorEntry = anchorSessionKey ? combinedSessionStore[anchorSessionKey] : undefined;
|
const anchorEntry = anchorSessionKey ? combinedSessionStore[anchorSessionKey] : undefined;
|
||||||
|
let anchorResetCutoffPromise: Promise<SessionResetRecallCutoff> | undefined;
|
||||||
|
const resolveAnchorResetCutoff = () => {
|
||||||
|
if (anchorResetCutoffPromise) {
|
||||||
|
return anchorResetCutoffPromise;
|
||||||
|
}
|
||||||
|
const sessionId = anchorEntry?.sessionId?.trim();
|
||||||
|
if (!recallAgentId || !sessionId || !anchorSessionKey) {
|
||||||
|
return Promise.resolve<SessionResetRecallCutoff>({ 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(
|
const recallAuthorized = Boolean(
|
||||||
conversationRecall &&
|
conversationRecall &&
|
||||||
!params.sandboxed &&
|
!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) {
|
if (!conversationRecall || !anchorSessionKey || !recallAgentId) {
|
||||||
// A bare global key is local to the selected agent store. Reattach that
|
// A bare global key is local to the selected agent store. Reattach that
|
||||||
// owner before applying visibility or non-default agents look cross-agent.
|
// owner before applying visibility or non-default agents look cross-agent.
|
||||||
@@ -242,8 +268,11 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
|||||||
}
|
}
|
||||||
const candidateEntry = combinedSessionStore[key];
|
const candidateEntry = combinedSessionStore[key];
|
||||||
// Canonical and legacy alias keys can identify one transcript. Exclude the
|
// Canonical and legacy alias keys can identify one transcript. Exclude the
|
||||||
// anchor by transcript identity so an alias cannot re-inject current context.
|
// live anchor, but let prior archived generations pass the privacy checks below.
|
||||||
if (key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry)) {
|
if (
|
||||||
|
!allowAnchorTranscript &&
|
||||||
|
(key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry))
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const candidateAgentId = resolveSessionAgentId({ sessionKey: key, config: params.cfg });
|
const candidateAgentId = resolveSessionAgentId({ sessionKey: key, config: params.cfg });
|
||||||
@@ -277,12 +306,12 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
|||||||
return [...expanded];
|
return [...expanded];
|
||||||
};
|
};
|
||||||
|
|
||||||
const areSessionKeysAllowed = (keys: string[]): boolean => {
|
const areSessionKeysAllowed = (keys: string[], allowAnchorTranscript = false): boolean => {
|
||||||
// Product recall fails closed when aliases disagree about privacy. Ordinary
|
// Product recall fails closed when aliases disagree about privacy. Ordinary
|
||||||
// session-tool visibility keeps its existing any-visible-alias behavior.
|
// session-tool visibility keeps its existing any-visible-alias behavior.
|
||||||
return conversationRecall
|
return conversationRecall
|
||||||
? expandRecallAliasKeys(keys).every(isSessionKeyAllowed)
|
? expandRecallAliasKeys(keys).every((key) => isSessionKeyAllowed(key, allowAnchorTranscript))
|
||||||
: keys.some(isSessionKeyAllowed);
|
: keys.some((key) => isSessionKeyAllowed(key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const next: MemorySearchResult[] = [];
|
const next: MemorySearchResult[] = [];
|
||||||
@@ -300,6 +329,10 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
|||||||
if (!identity) {
|
if (!identity) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const archiveReason = readSessionArchiveReasonFromHitPath(hit.path);
|
||||||
|
if (conversationRecall && archiveReason === "deleted") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId);
|
const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId);
|
||||||
const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId);
|
const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId);
|
||||||
if (
|
if (
|
||||||
@@ -342,7 +375,20 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
|
|||||||
}
|
}
|
||||||
continue;
|
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) {
|
if (!allowed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ describe("qa scenario catalog", () => {
|
|||||||
"matrix-restart-resume",
|
"matrix-restart-resume",
|
||||||
"qa-channel-reconnect-dedupe",
|
"qa-channel-reconnect-dedupe",
|
||||||
"remember-across-conversations",
|
"remember-across-conversations",
|
||||||
|
"remember-across-reset-private",
|
||||||
"slack-restart-resume",
|
"slack-restart-resume",
|
||||||
"subagent-stale-child-links",
|
"subagent-stale-child-links",
|
||||||
"telegram-repeated-command-authorization",
|
"telegram-repeated-command-authorization",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { setTimeout as sleep } from "node:timers/promises";
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||||
|
import { buildSessionEntry } from "openclaw/plugin-sdk/memory-core-host-engine-sessions";
|
||||||
import {
|
import {
|
||||||
listSessionEntries,
|
listSessionEntries,
|
||||||
loadTranscriptEventsSync,
|
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 SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;
|
||||||
const MAX_COMPACTION_SUMMARIES = 16;
|
const MAX_COMPACTION_SUMMARIES = 16;
|
||||||
const MAX_SUCCESSFUL_TOOL_CALL_EVENTS = 64;
|
const MAX_SUCCESSFUL_TOOL_CALL_EVENTS = 64;
|
||||||
|
const SESSION_RESET_RECALL_CUTOFF = Symbol.for("openclaw.memory.sessionResetRecallCutoff");
|
||||||
|
|
||||||
type QaSessionTranscriptSummary = {
|
type QaSessionTranscriptSummary = {
|
||||||
assistantMirrors?: Array<{ identity: string; text: string }>;
|
assistantMirrors?: Array<{ identity: string; text: string }>;
|
||||||
@@ -61,11 +63,14 @@ type QaSessionTranscriptSummary = {
|
|||||||
lastAssistantStopReason?: string;
|
lastAssistantStopReason?: string;
|
||||||
lastAssistantToolNames?: string[];
|
lastAssistantToolNames?: string[];
|
||||||
lastMessageRole?: string;
|
lastMessageRole?: string;
|
||||||
|
resetRecallCutoffLine?: number;
|
||||||
|
probeTextEndLine?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type QaSessionTranscriptSummaryOptions = {
|
type QaSessionTranscriptSummaryOptions = {
|
||||||
afterEventCursor?: number;
|
afterEventCursor?: number;
|
||||||
allowEmpty?: boolean;
|
allowEmpty?: boolean;
|
||||||
|
probeText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isSessionStoreFtsSettleRace(error: unknown) {
|
function isSessionStoreFtsSettleRace(error: unknown) {
|
||||||
@@ -427,7 +432,34 @@ async function readSessionTranscriptSummary(
|
|||||||
if (selectedEvents.length === 0 && options.allowEmpty === true) {
|
if (selectedEvents.length === 0 && options.allowEmpty === true) {
|
||||||
return emptySessionTranscriptSummary(events.length);
|
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<PropertyKey, unknown>)[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 {
|
export {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
stripInternalRuntimeContext,
|
stripInternalRuntimeContext,
|
||||||
} from "./openclaw-runtime-session.js";
|
} from "./openclaw-runtime-session.js";
|
||||||
import { retryTransientMemoryRead } from "./read-retry.js";
|
import { retryTransientMemoryRead } from "./read-retry.js";
|
||||||
|
import { resolveSessionResetRecallCutoff } from "./session-reset-recall.js";
|
||||||
import {
|
import {
|
||||||
listSessionTranscriptCorpusEntriesForAgent,
|
listSessionTranscriptCorpusEntriesForAgent,
|
||||||
listSessionTranscriptCorpusEntriesForAgentSync,
|
listSessionTranscriptCorpusEntriesForAgentSync,
|
||||||
@@ -775,11 +776,13 @@ export async function buildSessionEntry(
|
|||||||
const records = loadTranscriptEventsSync({
|
const records = loadTranscriptEventsSync({
|
||||||
...sqliteIdentity,
|
...sqliteIdentity,
|
||||||
});
|
});
|
||||||
|
const resetRecallCutoff = resolveSessionResetRecallCutoff(records);
|
||||||
const raw = serializeTranscriptEvents(records);
|
const raw = serializeTranscriptEvents(records);
|
||||||
return {
|
return {
|
||||||
mtimeMs: opts.updatedAtMs ?? stats.maxSeq,
|
mtimeMs: opts.updatedAtMs ?? stats.maxSeq,
|
||||||
path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId),
|
path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId),
|
||||||
raw,
|
raw,
|
||||||
|
resetRecallCutoff,
|
||||||
size: stats.sizeBytes,
|
size: stats.sizeBytes,
|
||||||
};
|
};
|
||||||
})()
|
})()
|
||||||
@@ -959,7 +962,7 @@ export async function buildSessionEntry(
|
|||||||
lineProvenance.push(...renderedLines.map(() => memoryProvenance));
|
lineProvenance.push(...renderedLines.map(() => memoryProvenance));
|
||||||
}
|
}
|
||||||
const content = collected.join("\n");
|
const content = collected.join("\n");
|
||||||
return {
|
const entry: SessionFileEntry = {
|
||||||
path: memoryPath,
|
path: memoryPath,
|
||||||
absPath,
|
absPath,
|
||||||
mtimeMs,
|
mtimeMs,
|
||||||
@@ -971,7 +974,9 @@ export async function buildSessionEntry(
|
|||||||
"\n" +
|
"\n" +
|
||||||
messageTimestampsMs.join(",") +
|
messageTimestampsMs.join(",") +
|
||||||
"\n" +
|
"\n" +
|
||||||
JSON.stringify(lineProvenance),
|
JSON.stringify(lineProvenance) +
|
||||||
|
"\n" +
|
||||||
|
JSON.stringify(rawSource?.resetRecallCutoff ?? { state: "absent" }),
|
||||||
),
|
),
|
||||||
content,
|
content,
|
||||||
lineMap,
|
lineMap,
|
||||||
@@ -981,6 +986,13 @@ export async function buildSessionEntry(
|
|||||||
...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}),
|
...(generatedByDreamingNarrative ? { generatedByDreamingNarrative: true } : {}),
|
||||||
...(generatedByCronRun ? { generatedByCronRun: 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) {
|
} catch (err) {
|
||||||
void logSessionFileReadFailure(absPath, err);
|
void logSessionFileReadFailure(absPath, err);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 })"
|
||||||
Reference in New Issue
Block a user