Files
openclaw/qa/scenarios/memory/remember-across-reset-private.yaml
T
Vitor Cepeda Lopes a9ee6618fe 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>
2026-08-12 12:38:13 -07:00

558 lines
24 KiB
YAML

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 })"