ManimCat / frontend /src /studio /store /studio-session-store.test.ts
Bin29's picture
Sync from main: 68df783 feat: support multimodal studio reference images
d47b053
import { describe, expect, it } from 'vitest'
import { createInitialStudioState, mergeStudioSnapshot } from './studio-session-store'
import type { StudioAssistantMessage, StudioRun, StudioSession, StudioSessionSnapshot, StudioUserMessage } from '../protocol/studio-agent-types'
describe('mergeStudioSnapshot', () => {
it('replaces an empty optimistic assistant placeholder with the incoming server assistant message', () => {
const session = createSession()
const current = {
...createInitialStudioState(),
entities: {
...createInitialStudioState().entities,
session,
messagesById: {
'local-user-1': createUserMessage('local-user-1', '请开始', '2026-03-24T00:00:00.000Z'),
'local-assistant-1': createAssistantMessage('local-assistant-1', [], '2026-03-24T00:00:00.000Z'),
},
messageOrder: ['local-user-1', 'local-assistant-1'],
},
}
const snapshot: StudioSessionSnapshot = {
session,
messages: [
createUserMessage('server-user-1', '请开始', '2026-03-24T00:00:01.000Z'),
createAssistantMessage('server-assistant-1', [
{
id: 'part-1',
messageId: 'server-assistant-1',
sessionId: session.id,
type: 'text',
text: '这是正式回复',
},
], '2026-03-24T00:00:40.000Z'),
],
runs: [],
tasks: [],
works: [],
workResults: [],
}
const next = mergeStudioSnapshot(current, snapshot, [])
expect(next.entities.messagesById['local-assistant-1']).toBeUndefined()
expect(next.entities.messagesById['server-assistant-1']?.role).toBe('assistant')
expect(next.entities.messageOrder).toEqual(['server-user-1', 'server-assistant-1'])
expect(next.runtime.pendingAssistantMessageId).toBeNull()
})
it('does not replace a new optimistic assistant placeholder with an older server assistant message', () => {
const session = createSession()
const current = {
...createInitialStudioState(),
entities: {
...createInitialStudioState().entities,
session,
messagesById: {
'server-assistant-old': createAssistantMessage('server-assistant-old', [
{
id: 'part-old-1',
messageId: 'server-assistant-old',
sessionId: session.id,
type: 'text',
text: '上一条回复',
},
], '2026-03-24T00:00:04.000Z'),
'local-user-1': createUserMessage('local-user-1', '继续', '2026-03-24T00:00:05.000Z'),
'local-assistant-1': createAssistantMessage('local-assistant-1', [], '2026-03-24T00:00:05.000Z'),
},
messageOrder: ['server-assistant-old', 'local-user-1', 'local-assistant-1'],
},
}
const snapshot: StudioSessionSnapshot = {
session,
messages: [
createAssistantMessage('server-assistant-old', [
{
id: 'part-old-1',
messageId: 'server-assistant-old',
sessionId: session.id,
type: 'text',
text: '上一条回复',
},
], '2026-03-24T00:00:04.000Z'),
],
runs: [],
tasks: [],
works: [],
workResults: [],
}
const next = mergeStudioSnapshot(current, snapshot, [])
expect(next.entities.messagesById['local-assistant-1']?.role).toBe('assistant')
expect(next.entities.messagesById['server-assistant-old']?.role).toBe('assistant')
expect(next.entities.messageOrder).toEqual(['server-assistant-old', 'local-user-1', 'local-assistant-1'])
})
it('replaces a streamed optimistic assistant message with an equivalent server assistant message', () => {
const session = createSession()
const current = {
...createInitialStudioState(),
entities: {
...createInitialStudioState().entities,
session,
messagesById: {
'local-assistant-1': createAssistantMessage('local-assistant-1', [
{
id: 'part-local-1',
messageId: 'local-assistant-1',
sessionId: session.id,
type: 'text',
text: '好的!我来为你创建一个美观、精确的爱心图像。',
},
], '2026-03-24T00:00:00.000Z'),
},
messageOrder: ['local-assistant-1'],
},
}
const snapshot: StudioSessionSnapshot = {
session,
messages: [
createAssistantMessage('server-assistant-1', [
{
id: 'part-server-1',
messageId: 'server-assistant-1',
sessionId: session.id,
type: 'text',
text: '好的!我来为你创建一个美观、精确的爱心图像。\n\n我选择使用经典的参数方程来绘制爱心。',
},
], '2026-03-24T00:01:00.000Z'),
],
runs: [],
tasks: [],
works: [],
workResults: [],
}
const next = mergeStudioSnapshot(current, snapshot, [])
expect(next.entities.messagesById['local-assistant-1']).toBeUndefined()
expect(next.entities.messagesById['server-assistant-1']?.role).toBe('assistant')
expect(next.entities.messageOrder).toEqual(['server-assistant-1'])
})
it('remaps active optimistic assistant bindings to the adopted server message id', () => {
const session = createSession()
const current = {
...createInitialStudioState(),
entities: {
...createInitialStudioState().entities,
session,
messagesById: {
'local-assistant-1': createAssistantMessage('local-assistant-1', [], '2026-03-24T00:00:00.000Z'),
},
messageOrder: ['local-assistant-1'],
},
runtime: {
...createInitialStudioState().runtime,
optimisticAssistantMessageIdByRunId: {
'run-1': 'local-assistant-1',
},
pendingAssistantMessageId: 'local-assistant-1',
},
}
const snapshot: StudioSessionSnapshot = {
session,
messages: [
createAssistantMessage('server-assistant-1', [
{
id: 'part-server-1',
messageId: 'server-assistant-1',
sessionId: session.id,
type: 'text',
text: '这是正式回复',
},
], '2026-03-24T00:00:40.000Z'),
],
runs: [
createRun(),
],
tasks: [],
works: [],
workResults: [],
}
const next = mergeStudioSnapshot(current, snapshot, [])
expect(next.runtime.optimisticAssistantMessageIdByRunId['run-1']).toBe('server-assistant-1')
expect(next.runtime.pendingAssistantMessageId).toBe('server-assistant-1')
expect(next.entities.messagesById['server-assistant-1']?.renderId).toBe('local-assistant-1')
})
it('collapses duplicated server and optimistic assistant messages with identical tool and text payloads', () => {
const session = createSession()
const current = {
...createInitialStudioState(),
entities: {
...createInitialStudioState().entities,
session,
messagesById: {
'local-assistant-1': createAssistantMessage('local-assistant-1', [
{
id: 'tool-local',
messageId: 'local-assistant-1',
sessionId: session.id,
type: 'tool',
tool: 'write',
callId: 'call-1',
state: {
status: 'completed',
input: { path: 'triangle_sss.py' },
output: 'ok',
title: 'Completed write',
time: { start: 1, end: 2 },
},
},
{
id: 'text-local',
messageId: 'local-assistant-1',
sessionId: session.id,
type: 'text',
text: '我来为你制作几个关于全等三角形的教学图片。',
},
], '2026-03-24T00:00:10.000Z'),
},
messageOrder: ['local-assistant-1'],
},
}
const snapshot: StudioSessionSnapshot = {
session,
messages: [
createAssistantMessage('server-assistant-1', [
{
id: 'tool-server',
messageId: 'server-assistant-1',
sessionId: session.id,
type: 'tool',
tool: 'write',
callId: 'call-2',
state: {
status: 'completed',
input: { path: 'triangle_sss.py' },
output: 'ok',
title: 'Completed write',
time: { start: 3, end: 4 },
},
},
{
id: 'text-server',
messageId: 'server-assistant-1',
sessionId: session.id,
type: 'text',
text: '我来为你制作几个关于全等三角形的教学图片。',
},
], '2026-03-24T00:00:12.000Z'),
],
runs: [],
tasks: [],
works: [],
workResults: [],
}
const next = mergeStudioSnapshot(current, snapshot, [])
expect(Object.keys(next.entities.messagesById)).toEqual(['server-assistant-1'])
expect(next.entities.messageOrder).toEqual(['server-assistant-1'])
})
it('preserves the same assistant message object when a snapshot re-sends unchanged content', () => {
const session = createSession()
const existingAssistant = createAssistantMessage('server-assistant-1', [
{
id: 'text-1',
messageId: 'server-assistant-1',
sessionId: session.id,
type: 'text',
text: '稳定内容',
},
], '2026-03-24T00:00:10.000Z')
existingAssistant.updatedAt = '2026-03-24T00:00:11.000Z'
const current = {
...createInitialStudioState(),
entities: {
...createInitialStudioState().entities,
session,
messagesById: {
[existingAssistant.id]: existingAssistant,
},
messageOrder: [existingAssistant.id],
},
}
const snapshot: StudioSessionSnapshot = {
session,
messages: [
{
...existingAssistant,
parts: existingAssistant.parts.map((part) => ({ ...part })),
},
],
runs: [],
tasks: [],
works: [],
workResults: [],
}
const next = mergeStudioSnapshot(current, snapshot, [])
expect(next.entities.messagesById['server-assistant-1']).toBe(existingAssistant)
})
it('keeps a terminal run when a stale running snapshot arrives later', () => {
const session = createSession()
const current = {
...createInitialStudioState(),
entities: {
...createInitialStudioState().entities,
session,
runsById: {
'run-1': createRun({
status: 'completed',
completedAt: '2026-03-24T00:00:05.000Z',
}),
},
runOrder: ['run-1'],
},
}
const snapshot: StudioSessionSnapshot = {
session,
messages: [],
runs: [
createRun({
status: 'running',
completedAt: undefined,
}),
],
tasks: [],
works: [],
workResults: [],
}
const next = mergeStudioSnapshot(current, snapshot, [])
expect(next.entities.runsById['run-1']?.status).toBe('completed')
expect(next.entities.runsById['run-1']?.completedAt).toBe('2026-03-24T00:00:05.000Z')
})
})
function createSession(): StudioSession {
const now = '2026-03-24T00:00:00.000Z'
return {
id: 'session-1',
projectId: 'project-1',
agentType: 'builder',
title: 'Studio',
directory: 'D:/projects/ManimCat',
permissionLevel: 'L2',
permissionRules: [],
createdAt: now,
updatedAt: now,
}
}
function createUserMessage(id: string, text: string, createdAt: string): StudioUserMessage {
return {
id,
sessionId: 'session-1',
role: 'user',
text,
createdAt,
updatedAt: createdAt,
}
}
function createAssistantMessage(
id: string,
parts: StudioAssistantMessage['parts'],
createdAt: string,
): StudioAssistantMessage {
return {
id,
sessionId: 'session-1',
role: 'assistant',
agent: 'builder',
parts,
createdAt,
updatedAt: createdAt,
}
}
function createRun(overrides: Partial<StudioRun> = {}): StudioRun {
return {
id: 'run-1',
sessionId: 'session-1',
status: 'running',
inputText: '请开始',
activeAgent: 'builder',
createdAt: '2026-03-24T00:00:00.000Z',
...overrides,
}
}