fix: preserve sqlite transcript locator identity

This commit is contained in:
Peter Steinberger
2026-05-08 14:39:13 +01:00
parent f86bea7ff2
commit 75fe202c29
5 changed files with 67 additions and 14 deletions

View File

@@ -264,6 +264,9 @@ The remaining cleanup is mostly consolidation and deletion:
- Bootstrap continuation detection now checks SQLite transcript locators through
`hasCompletedBootstrapTranscriptTurn`; it no longer exposes a
session-file-shaped helper name.
- Embedded-runner tests now use virtual SQLite transcript locators, and opening
a new locator without a duplicate `sessionId` uses the locator's session id
as the database row identity.
- Memory indexing helpers now use SQLite transcript terminology end to end:
host exports list/build session transcript entries, targeted sync queues
`sessionTranscripts`, and QMD/builtin indexers no longer expose session-file

View File

@@ -1,6 +1,11 @@
import path from "node:path";
import "./test-helpers/fast-coding-tools.js";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createSqliteSessionTranscriptLocator,
parseSqliteSessionTranscriptLocator,
} from "../config/sessions/paths.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
buildEmbeddedRunnerAssistant,
cleanupEmbeddedPiRunnerTestWorkspace,
@@ -158,18 +163,27 @@ let agentDir: string;
let workspaceDir: string;
let sessionCounter = 0;
let runCounter = 0;
let previousStateDir: string | undefined;
beforeAll(async () => {
vi.useRealTimers();
vi.resetModules();
installRunEmbeddedMocks();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
({ SessionManager } = await import("./transcript/session-transcript-contract.js"));
e2eWorkspace = await createEmbeddedPiRunnerTestWorkspace("openclaw-embedded-agent-");
({ agentDir, workspaceDir } = e2eWorkspace);
previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = e2eWorkspace.stateDir;
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
({ SessionManager } = await import("./transcript/session-transcript-contract.js"));
}, 180_000);
afterAll(async () => {
closeOpenClawStateDatabaseForTest();
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await cleanupEmbeddedPiRunnerTestWorkspace(e2eWorkspace);
e2eWorkspace = undefined;
});
@@ -195,8 +209,13 @@ beforeEach(() => {
const nextSessionFile = () => {
sessionCounter += 1;
return path.join(workspaceDir, `session-${sessionCounter}.jsonl`);
return createSqliteSessionTranscriptLocator({
agentId: "test",
sessionId: `session-${sessionCounter}`,
});
};
const sessionIdFromLocator = (sessionFile: string) =>
parseSqliteSessionTranscriptLocator(sessionFile)?.sessionId ?? "session:test";
const nextRunId = (prefix = "run-embedded-test") => `${prefix}-${++runCounter}`;
const nextSessionKey = () => `agent:test:embedded:${nextRunId("session-key")}`;
@@ -220,7 +239,7 @@ const runWithOrphanedSingleUserMessage = async (text: string, sessionKey: string
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
return await runEmbeddedPiAgent({
sessionId: "session:test",
sessionId: sessionIdFromLocator(sessionFile),
sessionKey,
sessionFile,
workspaceDir,
@@ -272,7 +291,7 @@ const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string, sessi
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionId: sessionIdFromLocator(sessionFile),
sessionKey,
sessionFile,
workspaceDir,
@@ -482,6 +501,7 @@ describe("runEmbeddedPiAgent", () => {
it("disposes bundle MCP once when a one-shot local run completes", async () => {
const sessionFile = nextSessionFile();
const sessionId = sessionIdFromLocator(sessionFile);
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
const sessionKey = nextSessionKey();
runEmbeddedAttemptMock.mockResolvedValueOnce(
@@ -494,7 +514,7 @@ describe("runEmbeddedPiAgent", () => {
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionId,
sessionKey,
sessionFile,
workspaceDir,
@@ -511,12 +531,13 @@ describe("runEmbeddedPiAgent", () => {
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
expect(disposeSessionMcpRuntimeMock).toHaveBeenCalledTimes(1);
expect(disposeSessionMcpRuntimeMock).toHaveBeenCalledWith("session:test");
expect(disposeSessionMcpRuntimeMock).toHaveBeenCalledWith(sessionId);
});
it("preserves bundle MCP state across retries within one local run", async () => {
refreshRuntimeAuthOnFirstPromptError = true;
const sessionFile = nextSessionFile();
const sessionId = sessionIdFromLocator(sessionFile);
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
const sessionKey = nextSessionKey();
runEmbeddedAttemptMock
@@ -537,7 +558,7 @@ describe("runEmbeddedPiAgent", () => {
});
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionId,
sessionKey,
sessionFile,
workspaceDir,
@@ -555,7 +576,7 @@ describe("runEmbeddedPiAgent", () => {
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
expect(result.payloads?.[0]).toMatchObject({ text: "ok" });
expect(disposeSessionMcpRuntimeMock).toHaveBeenCalledTimes(1);
expect(disposeSessionMcpRuntimeMock).toHaveBeenCalledWith("session:test");
expect(disposeSessionMcpRuntimeMock).toHaveBeenCalledWith(sessionId);
});
it("retries a planning-only GPT turn once with an act-now steer", async () => {
@@ -593,7 +614,7 @@ describe("runEmbeddedPiAgent", () => {
});
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionId: sessionIdFromLocator(sessionFile),
sessionKey,
sessionFile,
workspaceDir,
@@ -622,7 +643,7 @@ describe("runEmbeddedPiAgent", () => {
);
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionId: sessionIdFromLocator(sessionFile),
sessionKey,
sessionFile,
workspaceDir,
@@ -669,7 +690,6 @@ describe("runEmbeddedPiAgent", () => {
usage: createMockUsage(1, 1),
timestamp: Date.now(),
});
await runDefaultEmbeddedTurn(sessionFile, "hello", sessionKey);
const messages = await readSessionMessages(sessionFile);

View File

@@ -9,6 +9,7 @@ import type { EmbeddedRunAttemptResult } from "../pi-embedded-runner/run/types.j
export type EmbeddedPiRunnerTestWorkspace = {
tempRoot: string;
agentDir: string;
stateDir: string;
workspaceDir: string;
};
@@ -17,10 +18,12 @@ export async function createEmbeddedPiRunnerTestWorkspace(
): Promise<EmbeddedPiRunnerTestWorkspace> {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const agentDir = path.join(tempRoot, "agent");
const stateDir = path.join(tempRoot, "state");
const workspaceDir = path.join(tempRoot, "workspace");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(stateDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
return { tempRoot, agentDir, workspaceDir };
return { tempRoot, agentDir, stateDir, workspaceDir };
}
export async function cleanupEmbeddedPiRunnerTestWorkspace(

View File

@@ -127,6 +127,33 @@ describe("TranscriptSessionManager", () => {
]);
});
it("uses the virtual sqlite transcript locator session id when no explicit id is supplied", async () => {
await makeTempSessionFile();
const sessionFile = createSqliteSessionTranscriptLocator({
agentId: "main",
sessionId: "locator-session",
});
const sessionManager = openTranscriptSessionManager({
sessionFile,
cwd: "/tmp/workspace",
});
sessionManager.appendMessage({ role: "user", content: "seed", timestamp: 1 });
expect(sessionManager.getSessionId()).toBe("locator-session");
expect(readSessionEntries(sessionFile)).toMatchObject([
{
type: "session",
id: "locator-session",
cwd: "/tmp/workspace",
},
{
type: "message",
message: { role: "user", content: "seed" },
},
]);
});
it("creates, branches, lists, and forks default sessions with virtual sqlite locators", async () => {
await makeTempSessionFile();
const sessionManager = SessionManager.create("/tmp/sqlite-workspace");

View File

@@ -140,7 +140,7 @@ function loadTranscriptState(params: { sessionFile: string; sessionId?: string;
}
const header = createSessionHeader({
id: params.sessionId,
id: params.sessionId ?? scope.sessionId,
cwd: params.cwd ?? process.cwd(),
});
const state = new TranscriptState({ header, entries: [] });