Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

170 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { ThreadMessageLike } from '@assistant-ui/react';
import { serializeThread, deserializeThread } from '@/lib/agent/client/serialize-thread';
describe('serializeThread / deserializeThread', () => {
it('round-trips text user + assistant turns', () => {
const messages: ThreadMessageLike[] = [
{ role: 'user', id: 'u1', content: [{ type: 'text', text: 'hello' }] },
{ role: 'assistant', id: 'a1', content: [{ type: 'text', text: 'hi there' }] },
];
const restored = deserializeThread(serializeThread(messages));
expect(restored).toHaveLength(2);
expect(restored[0].role).toBe('user');
expect((restored[0].content as unknown as { text: string }[])[0].text).toBe('hello');
expect((restored[1].content as unknown as { text: string }[])[0].text).toBe('hi there');
expect(restored[1].status).toEqual({ type: 'complete', reason: 'stop' });
// assistant-ui throws if a user message carries a status — it must be unset.
expect(restored[0].status).toBeUndefined();
});
it('keeps tool-call metadata but strips heavy result element data', () => {
const messages: ThreadMessageLike[] = [
{
role: 'assistant',
id: 'a1',
content: [
{
type: 'tool-call',
toolCallId: 'tc1',
toolName: 'regenerate_scene',
args: { sceneId: 's1', instruction: 'condense' },
result: {
content: [{ type: 'text', text: 'done' }],
details: {
sceneId: 's1',
content: {
elements: [
{ id: 'e1', type: 'image', src: 'data:image/png;base64,AAAA…huge' },
{ id: 'e2', type: 'text', content: 'x'.repeat(5000) },
],
},
actions: [{ type: 'speech', extra: 'dropme' }],
},
},
},
] as ThreadMessageLike['content'],
},
];
const slim = serializeThread(messages);
const part = slim[0].content[0];
expect(part.type).toBe('tool-call');
if (part.type !== 'tool-call') throw new Error('expected tool-call');
expect(part.toolName).toBe('regenerate_scene');
expect(part.args).toEqual({ sceneId: 's1', instruction: 'condense' });
// element count preserved, element payload (incl base64) dropped
expect(part.result?.details?.content).toEqual({ elements: [{}, {}] });
const json = JSON.stringify(slim);
expect(json).not.toContain('base64');
expect(json.length).toBeLessThan(400);
// action types kept, extra fields dropped
expect(part.result?.details?.actions).toEqual([{ type: 'speech' }]);
});
it('preserves details.content === null (failure marker)', () => {
const messages: ThreadMessageLike[] = [
{
role: 'assistant',
id: 'a1',
content: [
{
type: 'tool-call',
toolCallId: 'tc1',
toolName: 'regenerate_scene',
args: { sceneId: 's1' },
result: {
content: [{ type: 'text', text: 'refused' }],
details: { sceneId: 's1', content: null },
},
},
] as ThreadMessageLike['content'],
},
];
const part = serializeThread(messages)[0].content[0];
if (part.type !== 'tool-call') throw new Error('expected tool-call');
expect(part.result?.details?.content).toBeNull();
});
it('round-trips a reasoning part with its duration', () => {
const messages: ThreadMessageLike[] = [
{
role: 'assistant',
id: 'a1',
content: [
{ type: 'reasoning', text: 'let me think' },
{ type: 'text', text: 'answer' },
] as ThreadMessageLike['content'],
},
];
const slim = serializeThread(messages, () => 1500);
expect(slim[0].content[0]).toEqual({
type: 'reasoning',
text: 'let me think',
durationMs: 1500,
});
const restored = deserializeThread(slim);
const parts = restored[0].content as unknown as { type: string; text?: string }[];
expect(parts[0]).toEqual({ type: 'reasoning', text: 'let me think' });
expect(parts[1]).toEqual({ type: 'text', text: 'answer' });
});
it('preserves edit_interactive_html success markers (html applied + editCount)', () => {
const messages: ThreadMessageLike[] = [
{
role: 'assistant',
id: 'a1',
content: [
{
type: 'tool-call',
toolCallId: 'tc1',
toolName: 'edit_interactive_html',
args: { sceneId: 's1' },
result: { details: { sceneId: 's1', html: '<html>…huge 745kb…</html>', editCount: 3 } },
},
] as ThreadMessageLike['content'],
},
];
const slim = serializeThread(messages);
const part = slim[0].content[0];
if (part.type !== 'tool-call') throw new Error('expected tool-call');
// html replaced by a non-null placeholder (success survives, payload dropped)
expect(typeof part.result?.details?.html).toBe('string');
expect(part.result?.details?.editCount).toBe(3);
expect(JSON.stringify(slim).length).toBeLessThan(300);
});
it('preserves edit_interactive_html refusal (html === null)', () => {
const messages: ThreadMessageLike[] = [
{
role: 'assistant',
id: 'a1',
content: [
{
type: 'tool-call',
toolCallId: 'tc1',
toolName: 'edit_interactive_html',
args: { sceneId: 's1' },
result: { details: { sceneId: 's1', html: null, editCount: 0 } },
},
] as ThreadMessageLike['content'],
},
];
const part = serializeThread(messages)[0].content[0];
if (part.type !== 'tool-call') throw new Error('expected tool-call');
expect(part.result?.details?.html).toBeNull();
});
it('drops messages with no renderable content', () => {
const messages: ThreadMessageLike[] = [
{ role: 'assistant', id: 'a1', content: [] },
{ role: 'user', id: 'u1', content: [{ type: 'text', text: 'kept' }] },
];
expect(serializeThread(messages)).toHaveLength(1);
});
it('deserialize handles undefined / empty', () => {
expect(deserializeThread(undefined)).toEqual([]);
expect(deserializeThread([])).toEqual([]);
});
});