Files
openwork/apps/app/scripts/session-render-state.test.ts

445 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "bun:test";
import type { UIMessage } from "ai";
import type { OpenworkSessionSnapshot } from "../src/app/lib/openwork-server";
import {
deriveRenderedSessionMessages,
resolveRenderedSessionSnapshot,
} from "../src/react-app/domains/session/surface/session-render-state";
import {
applyRevertCursor,
reconcileTranscriptMessages,
resolveForkBoundaryId,
} from "../src/react-app/domains/session/sync/transcript-reconcile";
import { describeOpencodeSessionError } from "../src/react-app/domains/session/sync/usechat-adapter";
function snapshotWithMessages(
messages: Array<{ id: string; role: "user" | "assistant"; text: string; created?: number }>,
sessionId = "ses_test",
): OpenworkSessionSnapshot {
return {
session: {
id: sessionId,
parentID: undefined,
title: "Test session",
time: { created: 1, updated: 2 },
share: undefined,
version: "0",
},
messages: messages.map((message, index) => ({
info: {
id: message.id,
role: message.role,
sessionID: sessionId,
time: { created: message.created ?? index + 1 },
},
parts: [
{
id: `part_${message.id}`,
type: "text",
text: message.text,
sessionID: sessionId,
messageID: message.id,
},
],
})),
todos: [],
status: { type: "idle" },
} as unknown as OpenworkSessionSnapshot;
}
function uiMessage(id: string, role: "user" | "assistant", text: string, created?: number): UIMessage {
return {
id,
role,
...(typeof created === "number" ? { metadata: { opencode: { created } } } : {}),
parts: [{ type: "text", text, state: "done" }],
};
}
function snapshotWithText(text: string, sessionId = "ses_test"): OpenworkSessionSnapshot {
return snapshotWithMessages([{ id: "msg_user", role: "user", text }], sessionId);
}
describe("reconcileTranscriptMessages", () => {
it("hydrates an empty transcript cache from the snapshot", () => {
const snapshot = [uiMessage("msg_user", "user", "hello")];
expect(reconcileTranscriptMessages({
currentMessages: [],
snapshotMessages: snapshot,
reason: "snapshot",
})).toBe(snapshot);
});
it("does not clear live messages when a snapshot is temporarily empty", () => {
const current = [
uiMessage("msg_user", "user", "latest prompt"),
uiMessage("msg_assistant", "assistant", "latest answer"),
];
expect(reconcileTranscriptMessages({
currentMessages: current,
snapshotMessages: [],
reason: "snapshot",
})).toBe(current);
});
it("keeps older cached messages when a busy snapshot only contains the active tail", () => {
const merged = reconcileTranscriptMessages({
snapshotMessages: [uiMessage("msg_current_user", "user", "latest prompt")],
currentMessages: [
uiMessage("msg_old_user", "user", "old prompt"),
uiMessage("msg_old_assistant", "assistant", "old answer"),
uiMessage("msg_current_user", "user", "latest"),
],
reason: "snapshot",
});
expect(merged.map((message) => message.id)).toEqual([
"msg_old_user",
"msg_old_assistant",
"msg_current_user",
]);
expect(merged[2]?.parts[0]).toMatchObject({ text: "latest prompt" });
});
it("keeps snapshot history and live-only tail messages together", () => {
const merged = reconcileTranscriptMessages({
currentMessages: [
uiMessage("msg_current_user", "user", "latest prompt"),
uiMessage("msg_current_assistant", "assistant", "streaming answer"),
],
snapshotMessages: [
uiMessage("msg_old_user", "user", "old prompt"),
uiMessage("msg_old_assistant", "assistant", "old answer"),
],
reason: "snapshot",
});
expect(merged.map((message) => message.id)).toEqual([
"msg_old_user",
"msg_old_assistant",
"msg_current_user",
"msg_current_assistant",
]);
});
it("keeps longer live text when the snapshot lags the event stream", () => {
const merged = reconcileTranscriptMessages({
currentMessages: [
uiMessage("msg_user", "user", "hello"),
uiMessage("msg_assistant", "assistant", "finished answer"),
],
snapshotMessages: [
uiMessage("msg_user", "user", "hello"),
uiMessage("msg_assistant", "assistant", "finished"),
],
reason: "snapshot",
});
expect(merged[1]?.parts[0]).toMatchObject({ text: "finished answer" });
});
it("inserts cached-only message blocks by timestamp instead of appending them", () => {
const merged = reconcileTranscriptMessages({
currentMessages: [
uiMessage("msg_block1", "user", "block 1", 1),
uiMessage("msg_block2", "assistant", "block 2", 2),
uiMessage("msg_block3", "user", "block 3", 3),
uiMessage("msg_block4", "assistant", "block 4", 4),
uiMessage("msg_streaming", "assistant", "streaming", 5),
],
snapshotMessages: [
uiMessage("msg_block1", "user", "block 1", 1),
uiMessage("msg_block3", "user", "block 3", 3),
uiMessage("msg_block4", "assistant", "block 4", 4),
],
reason: "snapshot",
});
expect(merged.map((message) => message.id)).toEqual([
"msg_block1",
"msg_block2",
"msg_block3",
"msg_block4",
"msg_streaming",
]);
});
});
describe("deriveRenderedSessionMessages", () => {
it("falls back to snapshot messages when transcript cache is empty", () => {
const messages = deriveRenderedSessionMessages({
transcriptState: [],
snapshot: snapshotWithText("still here"),
});
expect(messages).toHaveLength(1);
expect(messages[0]?.parts[0]).toMatchObject({
type: "text",
text: "still here",
});
});
it("keeps longer live text when the cache covers the snapshot", () => {
const cached: UIMessage[] = [
uiMessage("msg_user", "user", "snapshot text plus live tail", 1),
];
const messages = deriveRenderedSessionMessages({
transcriptState: cached,
snapshot: snapshotWithText("snapshot text"),
});
expect(messages[0]?.parts[0]).toMatchObject({ text: "snapshot text plus live tail" });
});
it("keeps snapshot history visible when the live cache only has the active turn", () => {
const messages = deriveRenderedSessionMessages({
transcriptState: [
{
id: "msg_current_user",
role: "user",
parts: [{ type: "text", text: "latest prompt", state: "done" }],
},
{
id: "msg_current_assistant",
role: "assistant",
parts: [{ type: "text", text: "streaming answer", state: "streaming" }],
},
],
snapshot: snapshotWithMessages([
{ id: "msg_old_user", role: "user", text: "old prompt" },
{ id: "msg_old_assistant", role: "assistant", text: "old answer" },
]),
});
expect(messages.map((message) => message.id)).toEqual([
"msg_old_user",
"msg_old_assistant",
"msg_current_user",
"msg_current_assistant",
]);
});
it("keeps live-only tail messages after the stream flips idle before the snapshot catches up", () => {
const messages = deriveRenderedSessionMessages({
transcriptState: [
uiMessage("msg_current_user", "user", "latest prompt"),
uiMessage("msg_current_assistant", "assistant", "latest answer"),
],
snapshot: snapshotWithMessages([
{ id: "msg_old_user", role: "user", text: "old prompt" },
{ id: "msg_old_assistant", role: "assistant", text: "old answer" },
]),
});
expect(messages.map((message) => message.id)).toEqual([
"msg_old_user",
"msg_old_assistant",
"msg_current_user",
"msg_current_assistant",
]);
});
it("renders cached-only message blocks by timestamp instead of appending them", () => {
const messages = deriveRenderedSessionMessages({
transcriptState: [
uiMessage("msg_block1", "user", "block 1", 1),
uiMessage("msg_block2", "assistant", "block 2", 2),
uiMessage("msg_block3", "user", "block 3", 3),
uiMessage("msg_block4", "assistant", "block 4", 4),
uiMessage("msg_streaming", "assistant", "streaming", 5),
],
snapshot: snapshotWithMessages([
{ id: "msg_block1", role: "user", text: "block 1", created: 1 },
{ id: "msg_block3", role: "user", text: "block 3", created: 3 },
{ id: "msg_block4", role: "assistant", text: "block 4", created: 4 },
]),
});
expect(messages.map((message) => message.id)).toEqual([
"msg_block1",
"msg_block2",
"msg_block3",
"msg_block4",
"msg_streaming",
]);
});
it("returns an empty list only when there is no cache or snapshot content", () => {
expect(deriveRenderedSessionMessages({
transcriptState: [],
snapshot: null,
})).toEqual([]);
});
it("does not use a cached snapshot from a different session", () => {
const snapshot = resolveRenderedSessionSnapshot({
sessionId: "ses_next",
currentSnapshot: null,
cachedRendered: {
sessionId: "ses_previous",
snapshot: snapshotWithText("previous session", "ses_previous"),
},
});
expect(snapshot).toBeNull();
expect(deriveRenderedSessionMessages({
transcriptState: [],
snapshot,
})).toEqual([]);
});
it("keeps a cached snapshot for the current session while live cache is empty", () => {
const cached = snapshotWithText("current session", "ses_current");
const snapshot = resolveRenderedSessionSnapshot({
sessionId: "ses_current",
currentSnapshot: null,
cachedRendered: {
sessionId: "ses_current",
snapshot: cached,
},
});
expect(snapshot).toBe(cached);
expect(deriveRenderedSessionMessages({
transcriptState: [],
snapshot,
})[0]?.parts[0]).toMatchObject({ text: "current session" });
});
});
describe("describeOpencodeSessionError", () => {
it("includes API error status and response details", () => {
expect(describeOpencodeSessionError({
name: "APIError",
data: {
message: "Service unavailable",
statusCode: 503,
isRetryable: true,
responseBody: "upstream overloaded",
},
})).toBe("Service unavailable\nStatus: 503\nResponse: upstream overloaded");
});
it("summarizes and bounds an HTML API response body", () => {
const responseBody = `<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head><body>${"x".repeat(1_024 * 1_024)}</body></html>`;
const described = describeOpencodeSessionError({
name: "APIError",
data: { message: "Proxy request failed", statusCode: 502, responseBody },
});
expect(described).toContain("Upstream returned an HTML error page (502 Bad Gateway)");
expect(described).toContain("[Error text truncated; original size:");
expect(described.toLowerCase()).not.toContain("<!doctype");
expect(described.toLowerCase()).not.toContain("<html");
expect(described.length).toBeLessThanOrEqual(500);
});
it("uses named error defaults when opencode omits a message", () => {
expect(describeOpencodeSessionError({
name: "MessageOutputLengthError",
data: {},
})).toBe("The model reached its output limit before finishing");
});
it("surfaces structured output retry counts", () => {
expect(describeOpencodeSessionError({
name: "StructuredOutputError",
data: {
message: "Invalid JSON",
retries: 3,
},
})).toBe("Invalid JSON\nRetries: 3");
});
it("maps OpenAI ChatGPT token refresh 401 to reconnect guidance", () => {
expect(describeOpencodeSessionError(new Error("Token refresh failed: 401"))).toBe(
"OpenAI couldn’t renew the ChatGPT sign-in for this worker. Retry once. If it happens again, reconnect OpenAI under Connect providers → OpenAI → ChatGPT Pro/Plus.",
);
expect(describeOpencodeSessionError("Token refresh failed: 403")).toBe("Token refresh failed: 403");
});
});
describe("applyRevertCursor", () => {
const transcript = [
uiMessage("msg_1", "user", "turn one"),
uiMessage("msg_2", "assistant", "answer one"),
uiMessage("msg_3", "user", "turn two"),
uiMessage("msg_4", "assistant", "answer two"),
];
it("hides the reverted message itself and everything after it", () => {
// OpenCode marks revert.messageID as the FIRST reverted message.
const result = applyRevertCursor(transcript, "msg_3");
expect(result.map((message) => message.id)).toEqual(["msg_1", "msg_2"]);
});
it("returns the transcript unchanged without a revert cursor", () => {
expect(applyRevertCursor(transcript, null)).toBe(transcript);
expect(applyRevertCursor(transcript, undefined)).toBe(transcript);
});
it("returns the transcript unchanged when the cursor id is unknown", () => {
expect(applyRevertCursor(transcript, "msg_missing")).toBe(transcript);
});
it("hides everything when the first message is reverted", () => {
expect(applyRevertCursor(transcript, "msg_1")).toEqual([]);
});
});
describe("deriveRenderedSessionMessages with revert", () => {
it("hides reverted messages from the rendered transcript", () => {
const snapshot = snapshotWithMessages([
{ id: "msg_1", role: "user", text: "turn one" },
{ id: "msg_2", role: "assistant", text: "answer one" },
{ id: "msg_3", role: "user", text: "turn two" },
{ id: "msg_4", role: "assistant", text: "answer two" },
]);
(snapshot.session as { revert?: { messageID: string } }).revert = { messageID: "msg_3" };
const rendered = deriveRenderedSessionMessages({
transcriptState: [],
snapshot,
});
expect(rendered.map((message) => message.id)).toEqual(["msg_1", "msg_2"]);
});
});
describe("resolveForkBoundaryId", () => {
const transcript = [
uiMessage("msg_1", "user", "turn one"),
uiMessage("msg_2", "assistant", "answer one"),
uiMessage("msg_3", "user", "turn two"),
uiMessage("msg_4", "assistant", "answer two"),
];
it("returns the next message so the fork includes the branch point", () => {
expect(resolveForkBoundaryId(transcript, "msg_2")).toBe("msg_3");
expect(resolveForkBoundaryId(transcript, "msg_3")).toBe("msg_4");
});
it("returns null when branching at the last message (fork everything)", () => {
expect(resolveForkBoundaryId(transcript, "msg_4")).toBeNull();
});
it("returns null for unknown ids instead of corrupting the boundary", () => {
expect(resolveForkBoundaryId(transcript, "msg_missing")).toBeNull();
});
it("skips synthetic session-error messages when picking the boundary", () => {
const withSynthetic = [
...transcript.slice(0, 2),
uiMessage("session-error:msg_2", "assistant", "boom"),
...transcript.slice(2),
];
expect(resolveForkBoundaryId(withSynthetic, "msg_2")).toBe("msg_3");
// Branching at the synthetic message itself falls through to the next real message.
expect(resolveForkBoundaryId(withSynthetic, "session-error:msg_2")).toBe("msg_3");
});
});