Files
openclaw/test/embedded-transcript-cursor.e2e.test.ts
Peter Steinberger 9ae9fcb1cd fix: preserve transcript cursors across session resets (#112988)
* feat: add reset transcript boundaries

* feat: project reset boundaries in history

* refactor: reset sessions in place

* fix: fence reset runtime caches by lifecycle

* fix: preserve reset filtering during compaction

* fix: anchor compaction after reset boundaries

* fix: budget reset preludes during compaction

* fix: honor reset visibility in bounded history

* fix: couple reset boundaries to lifecycle commits

* fix: commit reset boundaries atomically

* fix: preserve reset boundary transition states

* fix: preserve concurrent reset lifecycle owners

* fix: preserve reset boundary storage compatibility

* fix: preserve reset windows across persisted histories

* fix: fence legacy reset lifecycle state

* fix: keep stale appends behind reset boundaries

* perf: index reset descendants linearly

* fix: bound reset history projection reads

* fix: narrow invalid leaf control types

* test: use typed cron lifecycle revision

* style: keep acp lifecycle helper within limit

* style: satisfy reset boundary lint rules

* test: split reset concurrency coverage

* style: satisfy compaction fixture lint

* refactor: remove retired reset replay exports

* test: assert in-place reply session resets

* test: assert reset boundary persistence

* test: mock session boundary counts
2026-07-23 06:22:43 -04:00

312 lines
10 KiB
TypeScript

// E2E: ordinary embedded Gateway turns preserve raw transcript cursor continuity.
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { readSessionTranscriptRawDelta } from "openclaw/plugin-sdk/session-transcript-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { listSessionEntries, loadSessionEntry } from "../src/config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../src/config/types.openclaw.js";
import { connectGatewayClient, disconnectGatewayClient } from "../src/gateway/test-helpers.e2e.js";
import {
createOpenClawTestInstance,
type OpenClawTestInstance,
} from "./helpers/openclaw-test-instance.js";
const TEST_TIMEOUT_MS = 180_000;
const MODEL_REF = "cursor-settlement/cursor-settlement";
const SESSION_KEY = "agent:main:main";
const GATEWAY_TOKEN_OPTION = "token";
type MockModelServer = {
baseUrl: string;
close: () => Promise<void>;
};
const instances: OpenClawTestInstance[] = [];
const modelServers: MockModelServer[] = [];
afterEach(async () => {
await Promise.allSettled(instances.splice(0).map((instance) => instance.cleanup()));
await Promise.allSettled(modelServers.splice(0).map((server) => server.close()));
});
describe("embedded transcript cursor settlement", () => {
it(
"resumes a public raw cursor across /new and the next Gateway turn",
{ timeout: TEST_TIMEOUT_MS },
async () => {
const modelServer = await startMockModelServer();
modelServers.push(modelServer);
const instance = await createOpenClawTestInstance({
name: "embedded-transcript-cursor",
config: createTestConfig(modelServer.baseUrl),
env: { OPENCLAW_SKIP_PROVIDERS: undefined },
});
instances.push(instance);
await instance.startGateway();
const client = await connectGatewayClient({
url: instance.url,
[GATEWAY_TOKEN_OPTION]: instance.gatewayToken,
role: "operator",
scopes: ["operator.admin", "operator.read", "operator.write"],
});
try {
await runAgentTurn(client, instance, "first cursor turn");
const storePath = path.join(instance.state.sessionsDir("main"), "sessions.json");
const sessionId = await waitForSessionId(storePath);
expect(
listSessionEntries({ agentId: "main", storePath }).map((entry) => entry.sessionKey),
instance.logs(),
).toEqual([SESSION_KEY]);
const target = { agentId: "main", sessionId, sessionKey: SESSION_KEY, storePath };
const bootstrap = await readSessionTranscriptRawDelta({
...target,
maxBytes: 1_000_000,
maxEvents: 100,
});
expect(bootstrap.kind, instance.logs()).toBe("page");
if (bootstrap.kind !== "page") {
throw new Error(`expected bootstrap page, got ${bootstrap.kind}`);
}
expect(bootstrap.hasMore).toBe(false);
const reset = await runAgentTurn(client, instance, "/new");
expect(
(reset as { result?: { meta?: { agentMeta?: { sessionId?: string } } } }).result?.meta
?.agentMeta?.sessionId,
instance.logs(),
).toBe(sessionId);
expect(await waitForSessionId(storePath), instance.logs()).toBe(sessionId);
await runAgentTurn(client, instance, "post-reset cursor turn");
const resumed = await readSessionTranscriptRawDelta({
...target,
cursor: bootstrap.cursor,
maxBytes: 1_000_000,
maxEvents: 100,
});
expect(resumed.kind, `${JSON.stringify(resumed)}\n${instance.logs()}`).toBe("page");
if (resumed.kind !== "page") {
throw new Error(`expected resumed page, got ${resumed.kind}`);
}
expect(resumed.hasMore).toBe(false);
expect(
resumed.events.some(
(row) =>
row.event &&
typeof row.event === "object" &&
(row.event as { type?: unknown }).type === "reset",
),
).toBe(true);
const messageTexts = resumed.events.flatMap((row) => readMessageText(row.event));
expect(messageTexts).toContain("post-reset cursor turn");
expect(messageTexts).toContain("cursor settlement response 2");
expect(messageTexts).not.toContain("first cursor turn");
expect(messageTexts).not.toContain("cursor settlement response 1");
} finally {
await disconnectGatewayClient(client);
}
},
);
});
function createTestConfig(baseUrl: string): OpenClawConfig {
return {
plugins: { slots: { memory: "none" } },
agents: {
defaults: {
heartbeat: { every: "0m" },
model: { primary: MODEL_REF },
models: { [MODEL_REF]: { agentRuntime: { id: "openclaw" } } },
skipBootstrap: true,
skills: [],
},
},
tools: { profile: "minimal" },
models: {
mode: "replace",
providers: {
"cursor-settlement": {
baseUrl: `${baseUrl}/v1`,
apiKey: "test-token-placeholder",
api: "openai-responses",
request: { allowPrivateNetwork: true },
models: [
{
id: "cursor-settlement",
name: "cursor-settlement",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4_096,
},
],
},
},
},
};
}
async function runAgentTurn(
client: Awaited<ReturnType<typeof connectGatewayClient>>,
instance: OpenClawTestInstance,
message: string,
): Promise<{ runId?: string; status?: string }> {
const requestedRunId = randomUUID();
const started = await client.request<{ runId?: string; status?: string }>("agent", {
sessionKey: SESSION_KEY,
message,
deliver: false,
idempotencyKey: requestedRunId,
});
if (started.status === "ok") {
return started;
}
expect(started.status).toBe("accepted");
const completed = await client.request<{ error?: unknown; status?: string }>(
"agent.wait",
{ runId: started.runId ?? requestedRunId, timeoutMs: 120_000 },
{ timeoutMs: 125_000 },
);
expect(completed.status, `${JSON.stringify(completed)}\n${instance.logs()}`).toBe("ok");
return started;
}
async function waitForSessionId(storePath: string): Promise<string> {
let sessionId: string | undefined;
await vi.waitFor(
() => {
sessionId = loadSessionEntry({
agentId: "main",
readConsistency: "latest",
sessionKey: SESSION_KEY,
storePath,
})?.sessionId;
expect(sessionId).toBeTruthy();
},
{ interval: 20, timeout: 30_000 },
);
if (!sessionId) {
throw new Error(`session id was not persisted for ${SESSION_KEY}`);
}
return sessionId;
}
function readMessageText(event: unknown): string[] {
if (!event || typeof event !== "object" || (event as { type?: unknown }).type !== "message") {
return [];
}
const message = (event as { message?: { content?: unknown } }).message;
if (typeof message?.content === "string") {
return [message.content];
}
if (!Array.isArray(message?.content)) {
return [];
}
return message.content.flatMap((part) =>
part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
? [(part as { text: string }).text]
: [],
);
}
async function startMockModelServer(): Promise<MockModelServer> {
let responseCount = 0;
const server = createServer((request, response) => {
void (async () => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/v1/models") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ data: [{ id: "cursor-settlement", object: "model" }] }));
return;
}
if (request.method !== "POST" || url.pathname !== "/v1/responses") {
response.writeHead(404).end();
return;
}
await drainRequest(request);
responseCount += 1;
writeModelResponse(response, responseCount);
})().catch((error) => {
response.writeHead(500, { "content-type": "application/json" });
response.end(JSON.stringify({ error: { message: String(error) } }));
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("cursor settlement model server did not bind");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
},
};
}
async function drainRequest(request: IncomingMessage): Promise<void> {
for await (const chunk of request) {
void chunk;
}
}
function writeModelResponse(response: ServerResponse, sequence: number): void {
const text = `cursor settlement response ${sequence}`;
const message = {
type: "message",
id: `cursor-settlement-message-${sequence}`,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text, annotations: [] }],
};
const events = [
{
type: "response.output_item.added",
output_index: 0,
item: { ...message, status: "in_progress", content: [] },
},
{
type: "response.output_text.delta",
item_id: message.id,
output_index: 0,
content_index: 0,
delta: text,
},
{
type: "response.output_text.done",
item_id: message.id,
output_index: 0,
content_index: 0,
text,
},
{ type: "response.output_item.done", output_index: 0, item: message },
{
type: "response.completed",
response: {
id: `cursor-settlement-response-${sequence}`,
status: "completed",
output: [message],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
},
];
response.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-store",
connection: "keep-alive",
});
response.end(
`${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`,
);
}