diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 5e181c9ba5c..d9dc9359c7c 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -257,7 +257,7 @@ The remaining cleanup is mostly consolidation and deletion: old `resolve...ForPath` helper and unused `transcriptPath` write options are gone from runtime callers. - Runtime session resolution now uses `{agentId, sessionId}` and must not derive - `sqlite-transcript:///` handles for external boundaries. + `sqlite-transcript:///` strings for external boundaries. Legacy absolute JSONL paths are doctor migration inputs only. - `runEmbeddedPiAgent(...)` no longer has a transcript-locator parameter. Prepared worker descriptors also omit transcript locators. Runtime session @@ -522,11 +522,10 @@ sessionId}` and session key context. SQLite reads. Its helper no longer accepts or derives transcript locators, legacy file reads, or file-rewrite options. - Codex app-server conversation bindings now key SQLite plugin state by - OpenClaw session key when available, with transcript-path lookups kept only as - a legacy fallback for existing bindings. -- Codex app-server mirrored-history reads now prefer the SQLite transcript scope - registered for the transcript path, falling back to `{agentId, sessionId}` - only when the path has not been imported or mapped yet. + OpenClaw session key or explicit `{agentId, sessionId}` scope. They must not + preserve transcript-path fallback bindings. +- Codex app-server mirrored-history reads use the SQLite transcript scope only; + they must not recover identity from transcript file paths. - Role-ordering and compaction reset paths no longer unlink old transcript files; reset only rotates the SQLite session row and transcript identity. - Gateway reset and checkpoint responses return clean session rows plus session @@ -534,8 +533,8 @@ sessionId}` and session key context. - Memory-core dreaming no longer prunes session rows by probing for missing JSONL files. Subagent cleanup goes through the session runtime API instead of filesystem existence checks. Its transcript-ingestion tests seed SQLite rows - through neutral test locators instead of creating `agents//sessions` - fixtures. + directly instead of creating `agents//sessions` fixtures or locator + placeholders. - Gateway doctor memory status reads short-term recall and phase-signal counts from SQLite plugin-state rows instead of `memory/.dreams/*.json`; CLI and doctor output now label that storage as a SQLite store, not a path. diff --git a/extensions/clickclack/src/inbound.ts b/extensions/clickclack/src/inbound.ts index ab2ff28038a..9a8c2b37ea7 100644 --- a/extensions/clickclack/src/inbound.ts +++ b/extensions/clickclack/src/inbound.ts @@ -108,9 +108,7 @@ export async function handleClickClackInbound(params: { } const senderName = message.author?.display_name || message.author_id; const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({ - storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, { - agentId: route.agentId, - }), + agentId: route.agentId, sessionKey: route.sessionKey, }); const body = runtime.channel.reply.formatAgentEnvelope({ @@ -121,9 +119,6 @@ export async function handleClickClackInbound(params: { envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig), body: message.body, }); - const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, { - agentId: route.agentId, - }); const ctxPayload = runtime.channel.reply.finalizeInboundContext({ Body: body, BodyForAgent: message.body, @@ -161,8 +156,8 @@ export async function handleClickClackInbound(params: { await runtime.channel.turn.runPrepared({ channel: CHANNEL_ID, accountId: params.account.accountId, + agentId: route.agentId, routeSessionKey: route.sessionKey, - storePath, ctxPayload, recordInboundSession: runtime.channel.session.recordInboundSession, runDispatch: async () => diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 4705f2d11b2..27e8b560736 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { buildSessionTranscriptEntry, listSessionTranscriptsForAgent, - sessionPathForTranscript, + sessionSourceKeyForTranscript, } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { @@ -628,17 +628,17 @@ function areStringArraysEqual(a: string[], b: string[]): boolean { return true; } -function buildSessionStateKey(agentId: string, sessionPath: string): string { - return `${agentId}:${sessionPath}`; +function buildSessionStateKey(agentId: string, sessionSourceKey: string): string { + return `${agentId}:${sessionSourceKey}`; } function buildSessionRenderedLine(params: { agentId: string; - sessionPath: string; + sessionSourceKey: string; lineNumber: number; snippet: string; }): string { - const source = `${params.agentId}/${params.sessionPath}#L${params.lineNumber}`; + const source = `${params.agentId}/${params.sessionSourceKey}#L${params.lineNumber}`; return `[${source}] ${params.snippet}`.slice(0, SESSION_INGESTION_MAX_SNIPPET_CHARS + 64); } @@ -727,7 +727,7 @@ async function collectSessionIngestionBatches(params: { const sessionTranscripts: Array<{ agentId: string; scope: { agentId: string; sessionId: string }; - sessionPath: string; + sessionSourceKey: string; }> = []; for (const agentId of agentIds) { const scopes = await listSessionTranscriptsForAgent(agentId); @@ -735,7 +735,7 @@ async function collectSessionIngestionBatches(params: { sessionTranscripts.push({ agentId, scope, - sessionPath: sessionPathForTranscript(scope), + sessionSourceKey: sessionSourceKeyForTranscript(scope), }); } } @@ -744,7 +744,7 @@ async function collectSessionIngestionBatches(params: { if (a.agentId !== b.agentId) { return a.agentId.localeCompare(b.agentId); } - return a.sessionPath.localeCompare(b.sessionPath); + return a.sessionSourceKey.localeCompare(b.sessionSourceKey); }); const totalCap = SESSION_INGESTION_MAX_MESSAGES_PER_SWEEP; @@ -761,7 +761,7 @@ async function collectSessionIngestionBatches(params: { if (remaining <= 0) { break; } - const stateKey = buildSessionStateKey(file.agentId, file.sessionPath); + const stateKey = buildSessionStateKey(file.agentId, file.sessionSourceKey); const previous = params.state.files[stateKey]; const entry = await buildSessionTranscriptEntry(file.scope); if (!entry) { @@ -819,7 +819,7 @@ async function collectSessionIngestionBatches(params: { continue; } - const sessionScope = buildSessionScopeKey(file.agentId, file.sessionPath); + const sessionScope = buildSessionScopeKey(file.agentId, file.sessionSourceKey); const previousSeen = nextSeenMessages[sessionScope] ?? []; let seenSet = new Set(previousSeen); const newSeenHashes: string[] = []; @@ -865,7 +865,7 @@ async function collectSessionIngestionBatches(params: { } const rendered = buildSessionRenderedLine({ agentId: file.agentId, - sessionPath: file.sessionPath, + sessionSourceKey: file.sessionSourceKey, lineNumber, snippet, }); diff --git a/extensions/memory-core/src/memory/manager-session-sync-state.test.ts b/extensions/memory-core/src/memory/manager-session-sync-state.test.ts index 0fa69391758..6820ff39037 100644 --- a/extensions/memory-core/src/memory/manager-session-sync-state.test.ts +++ b/extensions/memory-core/src/memory/manager-session-sync-state.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveMemorySessionSyncPlan } from "./manager-session-sync-state.js"; describe("memory session sync state", () => { - it("tracks active paths and bulk hashes for full scans", () => { + it("tracks active source keys and bulk hashes for full scans", () => { const plan = resolveMemorySessionSyncPlan({ needsFullReindex: false, files: [ @@ -12,22 +12,22 @@ describe("memory session sync state", () => { targetSessionTranscriptKeys: null, dirtySessionTranscripts: new Set(), existingRows: [ - { path: "sessions/a.jsonl", hash: "hash-a" }, - { path: "sessions/b.jsonl", hash: "hash-b" }, + { path: "sessions/main/a", hash: "hash-a" }, + { path: "sessions/main/b", hash: "hash-b" }, ], - sessionPathForTranscript: (scope) => `sessions/${scope.sessionId}.jsonl`, + sessionSourceKeyForTranscript: (scope) => `sessions/${scope.agentId}/${scope.sessionId}`, }); expect(plan.indexAll).toBe(true); - expect(plan.activePaths).toEqual(new Set(["sessions/a.jsonl", "sessions/b.jsonl"])); + expect(plan.activePaths).toEqual(new Set(["sessions/main/a", "sessions/main/b"])); expect(plan.existingRows).toEqual([ - { path: "sessions/a.jsonl", hash: "hash-a" }, - { path: "sessions/b.jsonl", hash: "hash-b" }, + { path: "sessions/main/a", hash: "hash-a" }, + { path: "sessions/main/b", hash: "hash-b" }, ]); expect(plan.existingHashes).toEqual( new Map([ - ["sessions/a.jsonl", "hash-a"], - ["sessions/b.jsonl", "hash-b"], + ["sessions/main/a", "hash-a"], + ["sessions/main/b", "hash-b"], ]), ); }); @@ -39,10 +39,10 @@ describe("memory session sync state", () => { targetSessionTranscriptKeys: new Set(["main\0targeted-first"]), dirtySessionTranscripts: new Set(["main\0targeted-first"]), existingRows: [ - { path: "sessions/targeted-first.jsonl", hash: "hash-first" }, - { path: "sessions/targeted-second.jsonl", hash: "hash-second" }, + { path: "sessions/main/targeted-first", hash: "hash-first" }, + { path: "sessions/main/targeted-second", hash: "hash-second" }, ], - sessionPathForTranscript: (scope) => `sessions/${scope.sessionId}.jsonl`, + sessionSourceKeyForTranscript: (scope) => `sessions/${scope.agentId}/${scope.sessionId}`, }); expect(plan.indexAll).toBe(true); @@ -58,10 +58,10 @@ describe("memory session sync state", () => { targetSessionTranscriptKeys: null, dirtySessionTranscripts: new Set(["main\0incremental"]), existingRows: [], - sessionPathForTranscript: (scope) => `sessions/${scope.sessionId}.jsonl`, + sessionSourceKeyForTranscript: (scope) => `sessions/${scope.agentId}/${scope.sessionId}`, }); expect(plan.indexAll).toBe(false); - expect(plan.activePaths).toEqual(new Set(["sessions/incremental.jsonl"])); + expect(plan.activePaths).toEqual(new Set(["sessions/main/incremental"])); }); }); diff --git a/extensions/memory-core/src/memory/manager-session-sync-state.ts b/extensions/memory-core/src/memory/manager-session-sync-state.ts index aeb4e4c8929..7104f59d823 100644 --- a/extensions/memory-core/src/memory/manager-session-sync-state.ts +++ b/extensions/memory-core/src/memory/manager-session-sync-state.ts @@ -11,7 +11,7 @@ export function resolveMemorySessionSyncPlan(params: { targetSessionTranscriptKeys: Set | null; dirtySessionTranscripts: Set; existingRows?: MemorySourceFileStateRow[] | null; - sessionPathForTranscript: (scope: MemorySessionSyncScope) => string; + sessionSourceKeyForTranscript: (scope: MemorySessionSyncScope) => string; }): { activePaths: Set | null; existingRows: MemorySourceFileStateRow[] | null; @@ -20,7 +20,7 @@ export function resolveMemorySessionSyncPlan(params: { } { const activePaths = params.targetSessionTranscriptKeys ? null - : new Set(params.files.map((file) => params.sessionPathForTranscript(file))); + : new Set(params.files.map((file) => params.sessionSourceKeyForTranscript(file))); const existingRows = activePaths === null ? null : (params.existingRows ?? []); return { activePaths, diff --git a/extensions/memory-core/src/memory/manager-source-state.test.ts b/extensions/memory-core/src/memory/manager-source-state.test.ts index 9740ef637b6..2fb0183b4a4 100644 --- a/extensions/memory-core/src/memory/manager-source-state.test.ts +++ b/extensions/memory-core/src/memory/manager-source-state.test.ts @@ -51,8 +51,8 @@ describe("memory source state", () => { }), }, source: "sessions", - path: "sessions/thread.jsonl", - existingHashes: new Map([["sessions/thread.jsonl", "hash-from-snapshot"]]), + path: "sessions/main/thread", + existingHashes: new Map([["sessions/main/thread", "hash-from-snapshot"]]), }); expect(hash).toBe("hash-from-snapshot"); @@ -72,7 +72,7 @@ describe("memory source state", () => { }), }, source: "sessions", - path: "sessions/thread.jsonl", + path: "sessions/main/thread", existingHashes: null, }); @@ -80,7 +80,7 @@ describe("memory source state", () => { expect(calls).toEqual([ { sql: MEMORY_SOURCE_FILE_HASH_SQL, - args: ["sessions/thread.jsonl", "sessions"], + args: ["sessions/main/thread", "sessions"], }, ]); }); diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index a09055cafb6..d896faf8edc 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -17,7 +17,7 @@ import { buildSessionTranscriptEntry, listSessionTranscriptsForAgent, readSessionTranscriptDeltaStats, - sessionPathForTranscript, + sessionSourceKeyForTranscript, type SessionTranscriptScope, } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; import { @@ -804,7 +804,7 @@ export abstract class MemoryManagerSyncOps { db: this.db, source: "sessions", }).rows, - sessionPathForTranscript, + sessionSourceKeyForTranscript, }); const { activePaths, existingRows, existingHashes, indexAll } = sessionPlan; log.debug("memory sync: indexing session transcripts", { diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 571ab493fd2..1a3365ee5db 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -200,7 +200,7 @@ describe("QmdMemoryManager", () => { return { manager: requireValue(manager, "manager missing"), resolved }; } - function seedSessionTranscript(params?: { sessionId?: string; content?: string }): string { + function seedSessionTranscript(params?: { sessionId?: string; content?: string }): void { const sessionId = params?.sessionId ?? "session-1"; replaceSqliteSessionTranscriptEvents({ agentId, @@ -212,7 +212,6 @@ describe("QmdMemoryManager", () => { }, ], }); - return `sqlite-transcript://${encodeURIComponent(agentId)}/${encodeURIComponent(sessionId)}.jsonl`; } beforeAll(async () => { diff --git a/extensions/memory-core/src/session-search-visibility.test.ts b/extensions/memory-core/src/session-search-visibility.test.ts index 3629e0b138a..96514881e42 100644 --- a/extensions/memory-core/src/session-search-visibility.test.ts +++ b/extensions/memory-core/src/session-search-visibility.test.ts @@ -8,7 +8,6 @@ const crossAgentStore = { "agent:peer:only": { sessionId: "w1", updatedAt: 1, - sessionTranscript: "/tmp/sessions/w1.jsonl", }, }; let combinedSessionEntries: typeof crossAgentStore | Record = crossAgentStore; @@ -35,7 +34,7 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); const hits: MemorySearchResult[] = [ { - path: "sessions/u1.jsonl", + path: "sessions/main/u1", source: "sessions", score: 1, snippet: "x", @@ -77,7 +76,7 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); const hits: MemorySearchResult[] = [ { - path: "sessions/w1.jsonl", + path: "sessions/peer/w1", source: "sessions", score: 1, snippet: "a", @@ -85,7 +84,7 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { endLine: 2, }, { - path: "sessions/w1.jsonl", + path: "sessions/peer/w1", source: "sessions", score: 0.9, snippet: "b", @@ -105,7 +104,7 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { it("allows cross-agent session hits when visibility=all and agent-to-agent is enabled", async () => { const hit: MemorySearchResult = { - path: "sessions/w1.jsonl", + path: "sessions/peer/w1", source: "sessions", score: 1, snippet: "x", @@ -129,7 +128,7 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { it("denies cross-agent session hits when agent-to-agent is disabled", async () => { const hit: MemorySearchResult = { - path: "sessions/w1.jsonl", + path: "sessions/peer/w1", source: "sessions", score: 1, snippet: "x", diff --git a/packages/memory-host-sdk/src/engine-qmd.ts b/packages/memory-host-sdk/src/engine-qmd.ts index c789f35da41..111b36b0b39 100644 --- a/packages/memory-host-sdk/src/engine-qmd.ts +++ b/packages/memory-host-sdk/src/engine-qmd.ts @@ -5,7 +5,7 @@ export { buildSessionTranscriptEntry, listSessionTranscriptsForAgent, readSessionTranscriptDeltaStats, - sessionPathForTranscript, + sessionSourceKeyForTranscript, type BuildSessionTranscriptEntryOptions, type SessionTranscriptEntry, type SessionTranscriptDeltaStats, diff --git a/packages/memory-host-sdk/src/host/session-transcripts.test.ts b/packages/memory-host-sdk/src/host/session-transcripts.test.ts index 70800a90d49..703c23030aa 100644 --- a/packages/memory-host-sdk/src/host/session-transcripts.test.ts +++ b/packages/memory-host-sdk/src/host/session-transcripts.test.ts @@ -10,7 +10,7 @@ import { buildSessionTranscriptEntry, listSessionTranscriptsForAgent, readSessionTranscriptDeltaStats, - sessionPathForTranscript, + sessionSourceKeyForTranscript, type SessionTranscriptEntry, type SessionTranscriptScope, } from "./session-transcripts.js"; @@ -103,7 +103,7 @@ describe("listSessionTranscriptsForAgent", () => { expect(scopes).toEqual([scope]); const entry = await buildSessionTranscriptEntry(scope); expect(entry?.content).toBe("User: Stored only in SQLite"); - expect(entry?.path).toBe("sessions/main/sqlite-only.jsonl"); + expect(entry?.path).toBe("sessions/main/sqlite-only"); }); it("ignores remembered legacy transcript paths when listing active SQLite transcripts", async () => { @@ -120,10 +120,10 @@ describe("listSessionTranscriptsForAgent", () => { }); }); -describe("sessionPathForTranscript", () => { - it("formats SQLite scopes as stable session export paths", () => { - expect(sessionPathForTranscript({ agentId: "main", sessionId: "active-session" })).toBe( - "sessions/main/active-session.jsonl", +describe("sessionSourceKeyForTranscript", () => { + it("formats SQLite scopes as stable memory source keys", () => { + expect(sessionSourceKeyForTranscript({ agentId: "main", sessionId: "active-session" })).toBe( + "sessions/main/active-session", ); }); }); diff --git a/packages/memory-host-sdk/src/host/session-transcripts.ts b/packages/memory-host-sdk/src/host/session-transcripts.ts index d43188db9b4..2495e9b96fc 100644 --- a/packages/memory-host-sdk/src/host/session-transcripts.ts +++ b/packages/memory-host-sdk/src/host/session-transcripts.ts @@ -153,8 +153,8 @@ export async function listSessionTranscriptsForAgent( })); } -export function sessionPathForTranscript(scope: SessionTranscriptScope): string { - return `sessions/${scope.agentId}/${scope.sessionId}.jsonl`; +export function sessionSourceKeyForTranscript(scope: SessionTranscriptScope): string { + return `sessions/${scope.agentId}/${scope.sessionId}`; } export function readSessionTranscriptDeltaStats( @@ -459,7 +459,7 @@ export async function buildSessionTranscriptEntry( const content = collected.join("\n"); return { scope, - path: sessionPathForTranscript(scope), + path: sessionSourceKeyForTranscript(scope), mtimeMs, size, messageCount, diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 5815f04277d..282c503d623 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -187,7 +187,8 @@ vi.mock("../config/sessions.js", () => ({ vi.mock("../config/sessions/transcript-resolve.runtime.js", () => ({ resolveSessionTranscriptTarget: async () => ({ - sessionFile: "sqlite-transcript://default/session-1.jsonl", + agentId: "default", + sessionId: "session-1", sessionEntry: { sessionId: "session-1", updatedAt: Date.now() }, }), })); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 3ca0c0a077c..1e71cf0da5b 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -1051,7 +1051,9 @@ async function agentCommandInternal( agentId: sessionAgentId, sessionId, })), - suppressPromptPersistenceOnRetry: isFallbackRetry && currentTurnUserMessagePersisted, + suppressPromptPersistenceOnRetry: + opts.suppressPromptPersistence === true || + (isFallbackRetry && currentTurnUserMessagePersisted), onUserMessagePersisted: () => { currentTurnUserMessagePersisted = true; }, diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts index 72fab60ad0a..8a2e5a48d77 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -61,7 +61,6 @@ const baseRunParams = { sessionId: "test-session", sessionKey: "test-session-key", agentId: "main", - sessionFile: "sqlite-transcript://main/test-session.jsonl", workspaceDir: "/tmp/test-workspace", prompt: "__openclaw_memory_core_short_term_promotion_dream__", provider: "codex-cli", diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts index df216857aec..cd2a22bf998 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts @@ -246,7 +246,7 @@ describe("overflow compaction in run loop", () => { expect.objectContaining({ contextWindowTokens: 200000 }), ); expect(mockedTruncateOversizedToolResultsInSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionFile: "sqlite-transcript://main/test-session.jsonl" }), + expect.objectContaining({ agentId: "main", sessionId: "test-session" }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expect(mockedLog.info).toHaveBeenCalledWith( @@ -302,7 +302,7 @@ describe("overflow compaction in run loop", () => { }), ); expect(mockedTruncateOversizedToolResultsInSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionFile: "sqlite-transcript://main/test-session.jsonl" }), + expect.objectContaining({ agentId: "main", sessionId: "test-session" }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expect(mockedLog.info).toHaveBeenCalledWith( @@ -472,7 +472,7 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedTruncateOversizedToolResultsInSession).toHaveBeenCalledWith( - expect.objectContaining({ sessionFile: "sqlite-transcript://main/test-session.jsonl" }), + expect.objectContaining({ agentId: "main", sessionId: "test-session" }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expect(mockedLog.info).toHaveBeenCalledWith( diff --git a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts index d5d678ad83d..4400c89f9d0 100644 --- a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts @@ -75,7 +75,7 @@ describe("timeout-triggered compaction", () => { expect(mockedCompactDirect).toHaveBeenCalledWith( expect.objectContaining({ sessionId: "test-session", - transcriptLocator: "sqlite-transcript://main/test-session", + transcriptScope: { agentId: "main", sessionId: "test-session" }, tokenBudget: 200000, force: true, compactionTarget: "budget", diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 85ea9e3e597..8ba9202fa8a 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -2550,6 +2550,8 @@ export async function runEmbeddedPiAgent( // partial assistant fragment. Emit an explicit timeout error instead. if ( timedOutDuringPrompt && + !attempt.didSendViaMessagingTool && + !attempt.didSendDeterministicApprovalPrompt && (!payloadsWithToolMedia?.length || hasPartialAssistantTextAfterPromptTimeout) ) { const timeoutText = idleTimedOut diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index 1e4fb8842ee..66aefc8024d 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -164,7 +164,9 @@ export async function loadSubagentSpawnModuleForTest(params: { buildSubagentSystemPrompt: () => "system-prompt", forkSessionFromParent: params.forkSessionFromParentMock ?? - (async () => ({ sessionId: "forked-session-id", sessionFile: "/tmp/forked-session.jsonl" })), + (async () => ({ + sessionId: "forked-session-id", + })), getGlobalHookRunner: () => params.hookRunner ?? { hasHooks: () => false }, emitSessionLifecycleEvent: (...args: unknown[]) => params.emitSessionLifecycleEventMock?.(...args), diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts index 8b0ca1a6ceb..686ae5fd1ab 100644 --- a/src/agents/tools/embedded-gateway-stub.test.ts +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -7,7 +7,7 @@ const runtime = vi.hoisted(() => ({ resolveSessionAgentId: vi.fn(() => "main"), loadSessionEntry: vi.fn(() => ({ cfg: {}, - entry: { sessionId: "sess-main", sessionFile: "sqlite-transcript://main/sess-main.jsonl" }, + entry: { sessionId: "sess-main" }, })), resolveSessionModelRef: vi.fn(() => ({ provider: "openai" })), readSessionMessagesAsync: vi.fn(async (): Promise => []), @@ -91,8 +91,10 @@ describe("embedded gateway stub", () => { maxMessages: 200, }); expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith( - "sess-main", - "sqlite-transcript://main/sess-main.jsonl", + { + agentId: "main", + sessionId: "sess-main", + }, { mode: "recent", maxMessages: 200, @@ -120,8 +122,10 @@ describe("embedded gateway stub", () => { maxMessages: 1, }); expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith( - "sess-main", - "sqlite-transcript://main/sess-main.jsonl", + { + agentId: "main", + sessionId: "sess-main", + }, { mode: "recent", maxMessages: 1, diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index d140080da2c..b895567687e 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -9,10 +9,6 @@ import type { HandleCommandsParams } from "./commands-types.js"; vi.mock("./commands-compact.runtime.js", () => ({ abortEmbeddedPiRun: vi.fn(), compactEmbeddedPiSession: vi.fn(), - createSqliteSessionTranscriptLocator: vi.fn( - ({ agentId, sessionId }: { agentId: string; sessionId: string }) => - `sqlite-transcript://${agentId}/${sessionId}`, - ), enqueueSystemEvent: vi.fn(), formatContextUsageShort: vi.fn(() => "Context 12.1k"), formatTokenCount: vi.fn((value: number) => `${value}`), diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index d2a76e208cc..34506186452 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -24,13 +24,6 @@ vi.mock("../../config/sessions/group.js", () => ({ resolveGroupSessionKey: vi.fn().mockReturnValue(undefined), })); -vi.mock("../../config/sessions/test-helpers/transcript-locator.js", () => ({ - createSqliteSessionTranscriptLocator: vi.fn( - ({ agentId, sessionId }: { agentId?: string; sessionId: string }) => - `sqlite-transcript://${agentId ?? "main"}/${sessionId}`, - ), -})); - const storeRuntimeLoads = vi.hoisted(() => vi.fn()); const upsertSessionEntry = vi.hoisted(() => vi.fn()); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 0c65d5663e2..3eaebeaac87 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -19,7 +19,7 @@ import { type SessionFreshness, } from "../../config/sessions/reset.js"; import { resolveSessionKey } from "../../config/sessions/session-key.js"; -import { resolveAndPersistSessionTranscriptScope } from "../../config/sessions/session-locator.js"; +import { resolveAndPersistSessionTranscriptScope } from "../../config/sessions/session-scope.js"; import { getSessionEntry, listSessionEntries, diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 79ab08b192b..792786ae35d 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -10,7 +10,7 @@ export * from "./sessions/session-key.js"; export * from "./sessions/store.js"; export * from "./sessions/types.js"; export * from "./sessions/transcript.js"; -export * from "./sessions/session-locator.js"; +export * from "./sessions/session-scope.js"; export * from "./sessions/delivery-info.js"; export * from "./sessions/targets.js"; export * from "./sessions/agent-purge.js"; diff --git a/src/config/sessions/session-scope.ts b/src/config/sessions/session-scope.ts new file mode 100644 index 00000000000..ed31779efd4 --- /dev/null +++ b/src/config/sessions/session-scope.ts @@ -0,0 +1,39 @@ +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { getSessionEntry, upsertSessionEntry } from "./store.js"; +import type { SessionEntry } from "./types.js"; + +export async function resolveAndPersistSessionTranscriptScope(params: { + sessionId: string; + sessionKey: string; + sessionEntry?: SessionEntry; + agentId?: string; + topicId?: string | number; +}): Promise<{ agentId: string; sessionId: string; sessionEntry: SessionEntry }> { + const { sessionId, sessionKey } = params; + const now = Date.now(); + const agentId = params.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + if (!agentId) { + throw new Error(`Session stores are SQLite-only; cannot resolve agent for ${sessionKey}`); + } + const baseEntry = params.sessionEntry ?? + getSessionEntry({ agentId, sessionKey }) ?? { + sessionId, + updatedAt: now, + sessionStartedAt: now, + }; + const persistedEntry: SessionEntry = { + ...baseEntry, + sessionId, + updatedAt: now, + sessionStartedAt: baseEntry.sessionId === sessionId ? (baseEntry.sessionStartedAt ?? now) : now, + }; + if (baseEntry.sessionId !== sessionId) { + upsertSessionEntry({ + agentId, + sessionKey, + entry: persistedEntry, + }); + return { agentId, sessionId, sessionEntry: persistedEntry }; + } + return { agentId, sessionId, sessionEntry: persistedEntry }; +} diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index f7541ce1bb5..74b0aa0a52b 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -8,7 +8,7 @@ import type { SessionConfig } from "../types.base.js"; import { resolveSessionLifecycleTimestamps } from "./lifecycle.js"; import { validateSessionId } from "./paths.js"; import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js"; -import { resolveAndPersistSessionTranscriptScope } from "./session-locator.js"; +import { resolveAndPersistSessionTranscriptScope } from "./session-scope.js"; import { getSessionEntry, listSessionEntries, @@ -16,10 +16,6 @@ import { upsertSessionEntry, } from "./store.js"; import { useTempSessionsFixture } from "./test-helpers.js"; -import { - createSqliteSessionTranscriptLocator, - resolveSessionTranscriptLocator, -} from "./test-helpers/transcript-locator.js"; import { replaceSqliteSessionTranscriptEvents } from "./transcript-store.sqlite.js"; import { mergeSessionEntry, mergeSessionEntryWithPolicy, type SessionEntry } from "./types.js"; @@ -36,17 +32,6 @@ describe("session path safety", () => { expect(() => validateSessionId(sessionId), sessionId).toThrow(/Invalid session ID/); } }); - - it("ignores invalid transcript locators", () => { - const resolved = resolveSessionTranscriptLocator("sess-1"); - expect(resolved).toBe(createSqliteSessionTranscriptLocator({ sessionId: "sess-1" })); - }); - - it("uses extensionless SQLite transcript locators by default", () => { - expect(resolveSessionTranscriptLocator("sess-1")).toBe( - createSqliteSessionTranscriptLocator({ sessionId: "sess-1" }), - ); - }); }); describe("resolveSessionResetPolicy", () => { @@ -188,10 +173,6 @@ describe("session lifecycle timestamps", () => { const previousStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = dir; try { - const transcriptLocator = createSqliteSessionTranscriptLocator({ - agentId: "main", - sessionId: "lifecycle-session", - }); const headerTimestamp = "2026-04-20T04:30:00.000Z"; replaceSqliteSessionTranscriptEvents({ agentId: "main", @@ -474,7 +455,7 @@ describe("SQLite session store patch retries", () => { }); describe("resolveAndPersistSessionTranscriptScope", () => { - const fixture = useTempSessionsFixture("session-locator-test-"); + const fixture = useTempSessionsFixture("session-scope-test-"); function readFixtureSessionEntries(): Record { return Object.fromEntries( diff --git a/src/config/sessions/transcript-store.sqlite.test.ts b/src/config/sessions/transcript-store.sqlite.test.ts index 268f764ac55..6c0596da746 100644 --- a/src/config/sessions/transcript-store.sqlite.test.ts +++ b/src/config/sessions/transcript-store.sqlite.test.ts @@ -168,11 +168,9 @@ describe("SQLite session transcript store", () => { ).toEqual([{ type: "message", id: "main" }]); }); - it("lists SQLite transcripts with canonical transcript locators", () => { + it("lists SQLite transcript scopes", () => { const stateDir = createTempDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; - const olderPath = path.join(stateDir, "session-old.jsonl"); - const newerPath = path.join(stateDir, "session-new.jsonl"); appendSqliteSessionTranscriptEvent({ env, @@ -202,7 +200,6 @@ describe("SQLite session transcript store", () => { it("deletes transcript snapshots with the transcript", () => { const stateDir = createTempDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; - const transcriptPath = path.join(stateDir, "session.jsonl"); appendSqliteSessionTranscriptEvent({ env, @@ -231,7 +228,6 @@ describe("SQLite session transcript store", () => { it("renders JSONL from SQLite for explicit transcript export", () => { const stateDir = createTempDir(); - const sourcePath = path.join(stateDir, "source.jsonl"); replaceSqliteSessionTranscriptEvents({ env: { OPENCLAW_STATE_DIR: stateDir }, diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index 56ff8f89d80..5150e769ab2 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -7,7 +7,7 @@ import { } from "../../routing/session-key.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { extractAssistantVisibleText } from "../../shared/chat-message-content.js"; -import { resolveAndPersistSessionTranscriptScope } from "./session-locator.js"; +import { resolveAndPersistSessionTranscriptScope } from "./session-scope.js"; import { getSessionEntry, normalizeSessionRowKey } from "./store.js"; import { parseSessionThreadInfo } from "./thread-info.js"; import { appendSessionTranscriptMessage } from "./transcript-append.js"; diff --git a/src/gateway/session-transcript-readers.test.ts b/src/gateway/session-transcript-readers.test.ts index fe279fbb36f..21fe584bf77 100644 --- a/src/gateway/session-transcript-readers.test.ts +++ b/src/gateway/session-transcript-readers.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { createSqliteSessionTranscriptLocator } from "../config/sessions/test-helpers/transcript-locator.js"; import { replaceSqliteSessionTranscriptEvents } from "../config/sessions/transcript-store.sqlite.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createToolSummaryPreviewTranscriptLines } from "./session-preview.test-helpers.js"; @@ -52,26 +51,19 @@ function setupState(prefix = "openclaw-session-utils-sqlite-") { process.env.OPENCLAW_STATE_DIR = stateDir; } -function transcriptPath(sessionId: string, agentId = "main"): string { - return createSqliteSessionTranscriptLocator({ agentId, sessionId }); -} - function seedTranscript(params: { sessionId: string; agentId?: string; events: TranscriptEvent[]; - filePath?: string; }) { setupStateIfNeeded(); const agentId = params.agentId ?? "main"; - const filePath = params.filePath ?? transcriptPath(params.sessionId, agentId); replaceSqliteSessionTranscriptEvents({ agentId, sessionId: params.sessionId, events: params.events, now: () => 1_778_100_000_000, }); - return filePath; } function setupStateIfNeeded() { @@ -309,15 +301,12 @@ describe("SQLite transcript readers", () => { test("requires explicit SQLite transcript scope", () => { setupState(); const sessionId = "cross-agent"; - const filePath = transcriptPath(sessionId, "ops"); seedTranscript({ agentId: "ops", sessionId, - filePath, events: [header(sessionId), message("user", "from ops")], }); - expect(filePath).toBe("sqlite-transcript://ops/cross-agent"); expect(readSessionMessages({ sessionId })).toEqual([]); expect(readSessionMessages({ agentId: "ops", sessionId })).toEqual([ expect.objectContaining({ content: "from ops" }), diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index d1eb92d8d2a..1182c5afee8 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -6,7 +6,7 @@ export { } from "../state/openclaw-agent-db.js"; export { openOpenClawStateDatabase } from "../state/openclaw-state-db.js"; export { resolveSessionRowEntry } from "../config/sessions/store-entry.js"; -export { resolveAndPersistSessionTranscriptScope } from "../config/sessions/session-locator.js"; +export { resolveAndPersistSessionTranscriptScope } from "../config/sessions/session-scope.js"; export { resolveSessionKey } from "../config/sessions/session-key.js"; export { resolveGroupSessionKey } from "../config/sessions/group.js"; export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js"; diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts index 0d33625e736..4baf0e114d0 100644 --- a/src/talk/agent-consult-runtime.test.ts +++ b/src/talk/agent-consult-runtime.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createSqliteSessionTranscriptLocator } from "../config/sessions/test-helpers/transcript-locator.js"; import { __setRealtimeVoiceAgentConsultDepsForTest, consultRealtimeVoiceAgent, @@ -14,7 +13,6 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) { { sessionId?: string; updatedAt?: number; - transcriptLocator?: string; spawnedBy?: string; forkedFromParent?: boolean; totalTokens?: number; @@ -213,10 +211,6 @@ describe("realtime voice agent consult runtime", () => { const { runtime, runEmbeddedPiAgent, sessionStore } = createAgentRuntime(); sessionStore["agent:main:main"] = { sessionId: "parent-session", - transcriptLocator: createSqliteSessionTranscriptLocator({ - agentId: "main", - sessionId: "parent-session", - }), totalTokens: 100, updatedAt: 1, }; @@ -227,7 +221,6 @@ describe("realtime voice agent consult runtime", () => { })); const forkSessionFromParent = vi.fn(async () => ({ sessionId: "forked-session", - transcriptLocator: "sqlite-transcript://main/forked-session", })); __setRealtimeVoiceAgentConsultDepsForTest({ resolveParentForkDecision,