Files
oh-my-pi/packages/coding-agent/test/session-manager/signature-persistence.test.ts
T
2026-05-30 18:08:51 +02:00

200 lines
6.6 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { SessionManager, type SessionMessageEntry } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { getBlobsDir, TempDir } from "@oh-my-pi/pi-utils";
function isAssistantSessionEntry(entry: unknown): entry is SessionMessageEntry & { message: AssistantMessage } {
return (
typeof entry === "object" &&
entry !== null &&
"type" in entry &&
entry.type === "message" &&
"message" in entry &&
typeof entry.message === "object" &&
entry.message !== null &&
"role" in entry.message &&
entry.message.role === "assistant"
);
}
function getAssistantMessage(session: SessionManager): AssistantMessage {
const assistantEntry = session.getEntries().find(isAssistantSessionEntry);
if (!assistantEntry) throw new Error("Expected assistant message");
return assistantEntry.message;
}
describe("SessionManager signature persistence", () => {
it("clears oversized signatures instead of truncating them", async () => {
using tempDir = TempDir.createSync("@pi-session-signature-persistence-");
const session = SessionManager.create(tempDir.path(), tempDir.path());
session.appendMessage({ role: "user", content: "continue", timestamp: 1 });
session.appendMessage({
role: "assistant",
content: [
{ type: "thinking", thinking: "reasoning", thinkingSignature: "s".repeat(600_000) },
{ type: "text", text: "done", textSignature: "m".repeat(600_000) },
{ type: "toolCall", id: "tool_1", name: "read", arguments: {}, thoughtSignature: "t".repeat(600_000) },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5-mini",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
} satisfies AssistantMessage);
await session.flush();
const reloaded = await SessionManager.open(session.getSessionFile()!);
const assistant = getAssistantMessage(reloaded);
expect(assistant.content[0]).toMatchObject({ type: "thinking", thinking: "reasoning", thinkingSignature: "" });
expect(assistant.content[1]).toMatchObject({ type: "text", text: "done", textSignature: "" });
expect(assistant.content[2]).toMatchObject({ type: "toolCall", id: "tool_1", thoughtSignature: "" });
});
it("externalizes provider image data URLs and restores preserved history payloads across reload", async () => {
using tempDir = TempDir.createSync("@pi-session-provider-image-persistence-");
const session = SessionManager.create(tempDir.path(), tempDir.path());
const largeImageUrl = `data:image/png;base64,${"a".repeat(600_000)}`;
session.appendMessage({
role: "user",
content: "look at this",
providerPayload: {
type: "openaiResponsesHistory",
provider: "openai-codex",
items: [
{
type: "message",
role: "user",
content: [
{ type: "input_text", text: "look at this" },
{ type: "input_image", detail: "auto", image_url: largeImageUrl },
],
},
],
},
timestamp: 1,
});
session.appendMessage({
role: "assistant",
content: [{ type: "text", text: "done" }],
api: "openai-codex-responses",
provider: "openai-codex",
model: "gpt-5.4",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
});
await session.flush();
const expectedBlobHash = new Bun.SHA256().update(Buffer.from(largeImageUrl, "utf8")).digest("hex");
const persistedBlob = await fs.readFile(path.join(getBlobsDir(), expectedBlobHash), "utf8");
expect(persistedBlob).toBe(largeImageUrl);
const reloaded = await SessionManager.open(session.getSessionFile()!);
const reloadedUserEntry = reloaded
.getEntries()
.find(entry => entry.type === "message" && entry.message.role === "user");
if (reloadedUserEntry?.type !== "message" || reloadedUserEntry.message.role !== "user") {
throw new Error("Expected user message");
}
expect(reloadedUserEntry.message.providerPayload).toEqual({
type: "openaiResponsesHistory",
provider: "openai-codex",
items: [
{
type: "message",
role: "user",
content: [
{ type: "input_text", text: "look at this" },
{ type: "input_image", detail: "auto", image_url: largeImageUrl },
],
},
],
});
});
it("rehydrates assistant replay metadata in memory without rewriting the session file", async () => {
using tempDir = TempDir.createSync("@pi-session-rehydrate-persistence-");
const session = SessionManager.create(tempDir.path(), tempDir.path());
const providerPayload = {
type: "openaiResponsesHistory" as const,
provider: "openai",
items: [
{ type: "reasoning", encrypted_content: "enc_stale" },
{
type: "message",
role: "assistant",
status: "completed",
id: "msg_stale_snapshot",
content: [{ type: "output_text", text: "done" }],
},
],
};
session.appendMessage({ role: "user", content: "continue", timestamp: 1 });
session.appendMessage({
role: "assistant",
content: [
{ type: "thinking", thinking: "reasoning", thinkingSignature: JSON.stringify(providerPayload.items[0]) },
{ type: "text", text: "done" },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5-mini",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
providerPayload,
timestamp: 2,
} satisfies AssistantMessage);
await session.flush();
const sessionFile = session.getSessionFile();
if (!sessionFile) throw new Error("Expected persisted session file");
const persistedBefore = await fs.readFile(sessionFile, "utf8");
const initialMtimeMs = (await fs.stat(sessionFile)).mtimeMs;
await session.close();
const reloaded = await SessionManager.open(sessionFile);
const assistant = getAssistantMessage(reloaded);
// After rehydration, assistant providerPayload must be stripped to prevent
// stale native history replay on warmed sessions.
expect(assistant.providerPayload).toBeUndefined();
expect(assistant.content[0]).toMatchObject({
type: "thinking",
thinking: "reasoning",
thinkingSignature: undefined,
});
expect(await fs.readFile(sessionFile, "utf8")).toBe(persistedBefore);
expect((await fs.stat(sessionFile)).mtimeMs).toBe(initialMtimeMs);
await reloaded.close();
});
});