Files
openmaic/OpenMAIC/tests/runtime/chat-storage-core.test.ts
2026-08-16 14:58:47 +08:00

287 lines
8.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { RuntimeSessionStatus } from '@openmaic/dsl';
import type { UIMessage } from 'ai';
import type { ChatMessageMetadata, ChatSession } from '@/lib/types/chat';
import {
buildChatRecordInit,
fromLegacyRecords,
normalizeSession,
planChatSync,
} from '@/lib/utils/chat-storage-core';
const STAGE_ID = 'stage-core';
const LEARNER_KEY = 'anon:core';
const BASE_RUNTIME_ID = 'chat:stage-core:anon%3Acore:session-core';
function message(): UIMessage<ChatMessageMetadata> {
return {
id: 'message-1',
role: 'user',
parts: [{ type: 'text', text: 'Hello' }],
metadata: { createdAt: 1_000, originalRole: 'user' },
};
}
function session(overrides: Partial<ChatSession> = {}): ChatSession {
return {
id: 'session-core',
type: 'qa',
title: 'Q&A',
status: 'interrupted',
messages: [message()],
config: { agentIds: ['default-1'], defaultAgentId: 'default-1' },
toolCalls: [],
pendingToolCalls: [],
createdAt: 900,
updatedAt: 1_200,
...overrides,
};
}
function messagePayload(chat: ChatSession) {
return {
kind: 'chat_message' as const,
payloadVersion: 1 as const,
role: 'user' as const,
content: 'Hello',
message: chat.messages[0]!,
sessionUpdatedAt: chat.updatedAt,
};
}
function statePayload(chat: ChatSession) {
return {
kind: 'chat_session_state' as const,
payloadVersion: 1 as const,
role: 'system' as const,
content: chat.title,
chatSessionId: chat.id,
type: chat.type,
title: chat.title,
status: chat.status,
config: chat.config,
toolCalls: chat.toolCalls,
messageIds: chat.messages.map((item) => item.id),
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
};
}
type ChatSyncViews = Parameters<typeof planChatSync>[1];
function view(
chat: ChatSession,
options: {
id?: string;
status?: RuntimeSessionStatus;
records?: number;
state?: ReturnType<typeof statePayload>;
} = {},
): ChatSyncViews[number] {
const id = options.id ?? BASE_RUNTIME_ID;
const payload = messagePayload(chat);
return {
runtimeSession: {
id,
kind: 'chat',
stageId: STAGE_ID,
learnerKey: LEARNER_KEY,
status: options.status ?? 'active',
createdAt: new Date(chat.createdAt).toISOString(),
updatedAt: new Date(chat.updatedAt).toISOString(),
runtimeDslVersion: '0.1.0',
},
records: Array.from(
{ length: options.records ?? 0 },
() => ({}),
) as ChatSyncViews[number]['records'],
folded: {
session: options.state ? chat : undefined,
messages: new Map([[chat.messages[0]!.id, payload]]),
state: options.state,
},
};
}
function desired(chat: ChatSession, isolatedWrites = false) {
return { session: chat, stageId: STAGE_ID, learnerKey: LEARNER_KEY, isolatedWrites };
}
describe('buildChatRecordInit', () => {
it('constructs stable ids and omits malformed optional anchors', () => {
const chat = {
...session(),
sceneId: null,
lastActionIndex: null,
} as unknown as ChatSession;
const payload = messagePayload(chat);
expect(buildChatRecordInit(BASE_RUNTIME_ID, payload, chat, 'message:message-1')).toEqual({
id: `${BASE_RUNTIME_ID}:message:message-1:1200`,
sessionId: BASE_RUNTIME_ID,
createdAt: '1970-01-01T00:00:01.200Z',
payload,
});
});
it('keeps valid scene and action anchors', () => {
const chat = session({ sceneId: 'scene-1', lastActionIndex: 3 });
const record = buildChatRecordInit(BASE_RUNTIME_ID, messagePayload(chat), chat, 'state');
expect(record).toMatchObject({ sceneId: 'scene-1', actionIndex: 3 });
});
});
describe('planChatSync', () => {
it('plans deterministic session creation before any append IO', () => {
expect(planChatSync(desired(session()), [])).toEqual({
kind: 'create-session',
baseRuntimeId: BASE_RUNTIME_ID,
generation: 0,
init: {
id: BASE_RUNTIME_ID,
kind: 'chat',
stageId: STAGE_ID,
learnerKey: LEARNER_KEY,
status: 'active',
createdAt: '1970-01-01T00:00:00.900Z',
updatedAt: '1970-01-01T00:00:01.200Z',
},
});
});
it('plans reactivation, appends, and the final completed status in order', () => {
const chat = session({ status: 'completed', title: 'Updated' });
const previous = session({ status: 'completed' });
const plan = planChatSync(desired(chat), [
view(chat, { status: 'archived', state: statePayload(previous) }),
]);
expect(plan).toMatchObject({
kind: 'write',
preStatus: 'active',
finalStatus: 'completed',
appends: [{ suffix: 'state' }],
});
});
it('plans a generation rollover for a completed destination with changes', () => {
const chat = session({ title: 'Updated' });
const previous = session();
const plan = planChatSync(desired(chat), [
view(chat, { status: 'completed', state: statePayload(previous) }),
]);
expect(plan).toMatchObject({
kind: 'start-generation',
init: { id: `${BASE_RUNTIME_ID}:generation:1`, status: 'active' },
});
});
it('plans record-limit rollover without performing IO', () => {
const chat = session();
const plan = planChatSync(desired(chat), [
view(chat, { records: 257, state: statePayload(chat) }),
]);
expect(plan).toMatchObject({ kind: 'complete-and-refresh' });
});
it('plans isolated reuse and retirement for an unchanged snapshot', () => {
const chat = session();
const newer = view(chat, {
id: `${BASE_RUNTIME_ID}:generation:2:newer`,
state: statePayload(chat),
});
const older = view(chat, {
id: `${BASE_RUNTIME_ID}:generation:1:older`,
status: 'completed',
state: statePayload(chat),
});
const plan = planChatSync(desired(chat, true), [older, newer]);
expect(plan).toMatchObject({
kind: 'reuse-isolated',
destination: { runtimeSession: { id: `${BASE_RUNTIME_ID}:generation:2:newer` } },
retired: [{ runtimeSession: { id: `${BASE_RUNTIME_ID}:generation:1:older` } }],
});
});
});
describe('fromLegacyRecords', () => {
it('passes through finite ISO-valid future timestamps deterministically', () => {
const future = {
...session(),
createdAt: Number.NaN,
updatedAt: Date.UTC(2056, 0, 1),
};
const first = fromLegacyRecords([future]);
const second = fromLegacyRecords([future]);
expect(first).toEqual(second);
expect(first.sessions[0]).toMatchObject({
createdAt: Date.UTC(2056, 0, 1),
updatedAt: Date.UTC(2056, 0, 1),
});
});
it('repairs only out-of-range timestamps and enforces timestamp order', () => {
expect(
fromLegacyRecords([
{ ...session(), createdAt: Number.NaN, updatedAt: 1_200 },
{ ...session({ id: 'second' }), createdAt: 900, updatedAt: Number.NaN },
{ ...session({ id: 'third' }), createdAt: -1, updatedAt: 1_500 },
{
...session({ id: 'fourth' }),
createdAt: 2_000,
updatedAt: 253_402_300_800_000,
},
{
...session({ id: 'fifth' }),
createdAt: Number.NaN,
updatedAt: Number.POSITIVE_INFINITY,
},
]),
).toMatchObject({
sessions: [
{ createdAt: 1_200, updatedAt: 1_200 },
{ createdAt: 900, updatedAt: 900 },
{ createdAt: 1_500, updatedAt: 1_500 },
{ createdAt: 2_000, updatedAt: 2_000 },
{ createdAt: Date.UTC(2026, 7, 1), updatedAt: Date.UTC(2026, 7, 1) },
],
skippedRows: [],
});
});
it('reports malformed payloads and rows to the migration caller', () => {
expect(fromLegacyRecords({ rows: [] })).toEqual({
sessions: [],
skippedRows: [{ index: -1, reason: 'expected an array payload' }],
});
expect(
fromLegacyRecords([
{ ...session(), messages: null },
{ ...session({ id: 'valid' }), toolCalls: [] },
]),
).toMatchObject({
sessions: [{ id: 'valid' }],
skippedRows: [{ index: 0, id: 'session-core' }],
});
});
});
describe('normalizeSession', () => {
it.each(['active', 'soft-closing'] as const)(
'normalizes an inactive %s session to interrupted',
(status) => {
expect(normalizeSession(session({ status })).status).toBe('interrupted');
},
);
it('preserves terminal statuses', () => {
expect(normalizeSession(session({ status: 'completed' })).status).toBe('completed');
});
});