mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* test(daemon): expose forked artifact history leakage * fix(daemon): clear run artifacts when forking transcripts * fix(daemon): preserve run-owned artifacts across conversation forks
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildQuestionFormKey,
|
|
mergeServerMessagesIntoConversation,
|
|
normalizeConversationMessageOrder,
|
|
} from '../../src/components/ProjectView';
|
|
import type { ChatMessage, ProjectFile } from '../../src/types';
|
|
|
|
describe('buildQuestionFormKey', () => {
|
|
it('is stable across a streaming form-id change (no remount mid-answer)', () => {
|
|
// The streaming preview shows the `discovery` fallback id until the body id
|
|
// streams in; a form that emits answerable questions before its id flips
|
|
// the parsed id late. The React key must NOT change across that flip, or
|
|
// the panel remounts and drops in-progress selections. Same conversation +
|
|
// message ⇒ same key regardless of the parsed id.
|
|
const early = buildQuestionFormKey('conv-1', 'msg-1', true);
|
|
const settled = buildQuestionFormKey('conv-1', 'msg-1', true);
|
|
expect(early).toBe('conv-1:msg-1');
|
|
expect(settled).toBe(early);
|
|
});
|
|
|
|
it('gives a distinct key to a later form in a different assistant message', () => {
|
|
// A second discovery form (same `discovery` template id) lives in its own
|
|
// assistant message, so it still gets its own key and replays the reveal —
|
|
// without folding the id into the key.
|
|
expect(buildQuestionFormKey('conv-1', 'msg-1', true)).not.toBe(
|
|
buildQuestionFormKey('conv-1', 'msg-2', true),
|
|
);
|
|
});
|
|
|
|
it('returns null until a form, conversation, and message are all present', () => {
|
|
expect(buildQuestionFormKey(null, 'msg-1', true)).toBeNull();
|
|
expect(buildQuestionFormKey('conv-1', null, true)).toBeNull();
|
|
expect(buildQuestionFormKey('conv-1', 'msg-1', false)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('mergeServerMessagesIntoConversation', () => {
|
|
it('adds server-created CTA messages while preserving local produced files', () => {
|
|
const producedFile: ProjectFile = {
|
|
name: 'deck.html',
|
|
size: 1024,
|
|
mtime: 1,
|
|
kind: 'html',
|
|
mime: 'text/html',
|
|
};
|
|
const localMessages: ChatMessage[] = [
|
|
{
|
|
id: 'user-1',
|
|
role: 'user',
|
|
content: 'Use this SKILL.md',
|
|
},
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Done',
|
|
runStatus: 'succeeded',
|
|
producedFiles: [producedFile],
|
|
},
|
|
];
|
|
const serverMessages: ChatMessage[] = [
|
|
{
|
|
id: 'user-1',
|
|
role: 'user',
|
|
content: 'Use this SKILL.md',
|
|
},
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Done',
|
|
runStatus: 'succeeded',
|
|
},
|
|
{
|
|
id: 'cta-1',
|
|
role: 'assistant',
|
|
content: '',
|
|
events: [
|
|
{
|
|
kind: 'plugin_candidate',
|
|
candidateId: 'candidate-1',
|
|
title: 'Main',
|
|
description: 'This repo looks like a plugin.',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const merged = mergeServerMessagesIntoConversation(localMessages, serverMessages);
|
|
|
|
expect(merged.map((message) => message.id)).toEqual(['user-1', 'assistant-1', 'cta-1']);
|
|
expect(merged[1]?.producedFiles).toEqual([producedFile]);
|
|
});
|
|
|
|
it('keeps newer optimistic feedback when a server refresh races its save', () => {
|
|
const localMessages: ChatMessage[] = [
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Done',
|
|
runStatus: 'succeeded',
|
|
feedback: {
|
|
rating: 'negative',
|
|
reasonCodes: ['weak_visual'],
|
|
createdAt: 2_000,
|
|
updatedAt: 2_100,
|
|
},
|
|
},
|
|
];
|
|
const serverMessages: ChatMessage[] = [
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Done',
|
|
runStatus: 'succeeded',
|
|
},
|
|
];
|
|
|
|
const merged = mergeServerMessagesIntoConversation(localMessages, serverMessages);
|
|
|
|
expect(merged[0]?.feedback).toEqual(localMessages[0]?.feedback);
|
|
});
|
|
|
|
it('accepts feedback from the server when it is newer than the local copy', () => {
|
|
const localMessages: ChatMessage[] = [
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Done',
|
|
feedback: {
|
|
rating: 'positive',
|
|
createdAt: 1_000,
|
|
updatedAt: 1_100,
|
|
},
|
|
},
|
|
];
|
|
const serverMessages: ChatMessage[] = [
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Done',
|
|
feedback: {
|
|
rating: 'negative',
|
|
createdAt: 1_000,
|
|
updatedAt: 1_200,
|
|
},
|
|
},
|
|
];
|
|
|
|
const merged = mergeServerMessagesIntoConversation(localMessages, serverMessages);
|
|
|
|
expect(merged[0]?.feedback?.rating).toBe('negative');
|
|
});
|
|
});
|
|
|
|
describe('normalizeConversationMessageOrder', () => {
|
|
it('restores a user turn that was persisted after its pinned assistant', () => {
|
|
const messages: ChatMessage[] = [
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Working',
|
|
createdAt: 1_100,
|
|
startedAt: 1_000,
|
|
runId: 'run-1',
|
|
runStatus: 'running',
|
|
},
|
|
{
|
|
id: 'user-1',
|
|
role: 'user',
|
|
content: 'Build the dashboard',
|
|
createdAt: 1_000,
|
|
},
|
|
];
|
|
|
|
expect(normalizeConversationMessageOrder(messages).map((message) => message.id)).toEqual([
|
|
'user-1',
|
|
'assistant-1',
|
|
]);
|
|
});
|
|
|
|
it('does not reorder an unrelated assistant followed by a later user turn', () => {
|
|
const messages: ChatMessage[] = [
|
|
{
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: 'Done',
|
|
createdAt: 1_000,
|
|
startedAt: 900,
|
|
runId: 'run-1',
|
|
runStatus: 'succeeded',
|
|
},
|
|
{
|
|
id: 'user-2',
|
|
role: 'user',
|
|
content: 'Now make it responsive',
|
|
createdAt: 2_000,
|
|
},
|
|
];
|
|
|
|
expect(normalizeConversationMessageOrder(messages).map((message) => message.id)).toEqual([
|
|
'assistant-1',
|
|
'user-2',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('mergeServerMessagesIntoConversation across a multi-Run task', () => {
|
|
const productionFiles: ProjectFile[] = [
|
|
'index.html',
|
|
'illustration-1.png',
|
|
'illustration-2.png',
|
|
'illustration-3.png',
|
|
'illustration-4.png',
|
|
'illustration-5.png',
|
|
].map((name) => ({ name, size: 100, mtime: 300, kind: name.endsWith('.html') ? 'html' : 'image', mime: name.endsWith('.html') ? 'text/html' : 'image/png' }));
|
|
|
|
function threeRunHistory(): ChatMessage[] {
|
|
return [
|
|
{
|
|
id: 'question', role: 'assistant', runId: 'run-question', runStatus: 'succeeded',
|
|
content: 'Which illustration palette?',
|
|
events: [{ kind: 'text', text: 'Which illustration palette?' }],
|
|
strategyTaskExecutionId: 'three-run-task', strategyTaskRunIndex: 0,
|
|
},
|
|
{
|
|
id: 'planning', role: 'assistant', runId: 'run-planning', runStatus: 'succeeded',
|
|
content: 'Palette confirmed. Ready to produce.',
|
|
events: [{ kind: 'text', text: 'Palette confirmed. Ready to produce.' }],
|
|
strategyTaskExecutionId: 'three-run-task', strategyTaskRunIndex: 1,
|
|
},
|
|
{
|
|
id: 'production', role: 'assistant', runId: 'run-production', runStatus: 'succeeded',
|
|
content: 'The lesson is ready.',
|
|
events: [
|
|
{ kind: 'artifact_focus', show: ['index.html'] },
|
|
{ kind: 'text', text: 'The lesson is ready.' },
|
|
],
|
|
strategyTaskExecutionId: 'three-run-task', strategyTaskRunIndex: 2,
|
|
producedFiles: productionFiles,
|
|
},
|
|
];
|
|
}
|
|
|
|
it('restores a three-run planning row without persisting its successor files under the planning run', () => {
|
|
// Native OPEND-2994 repro: question -> clarification -> production. The
|
|
// live planning row temporarily owns the successor stream. The first PUT
|
|
// is rejected by daemon run ownership, then the completion GET restores
|
|
// the planning run. Keeping the local files at that boundary would make
|
|
// the NEXT PUT look like a valid planning-run write, exposing six extra
|
|
// cards when Fork stops grouping the original strategy task.
|
|
const server = threeRunHistory();
|
|
const planning = server[1]!;
|
|
const production = server[2]!;
|
|
const local: ChatMessage[] = [server[0]!, {
|
|
...planning,
|
|
runId: production.runId,
|
|
content: `${planning.content}\n${production.content}`,
|
|
events: [...planning.events!, ...production.events!],
|
|
producedFiles: productionFiles,
|
|
}];
|
|
|
|
const refreshed = mergeServerMessagesIntoConversation(local, server);
|
|
// A follow-up task-projection update saves this refreshed row. Its
|
|
// physical run identity and file ownership must agree before persistence.
|
|
expect(refreshed[1]).toMatchObject({
|
|
id: planning.id, runId: planning.runId,
|
|
content: planning.content, events: planning.events,
|
|
});
|
|
expect(refreshed[1]?.producedFiles ?? []).toEqual([]);
|
|
expect(refreshed[2]?.producedFiles).toEqual(productionFiles);
|
|
expect(refreshed[2]?.events).toEqual(production.events);
|
|
expect(mergeServerMessagesIntoConversation(refreshed, server)).toEqual(refreshed);
|
|
});
|
|
|
|
it('keeps late files from their own planning run even when production has already been hydrated', () => {
|
|
const server = threeRunHistory();
|
|
const planning = server[1]!;
|
|
const outline: ProjectFile = { name: 'outline.html', size: 80, mtime: 200, kind: 'html', mime: 'text/html' };
|
|
const local: ChatMessage[] = [server[0]!, {
|
|
...planning,
|
|
producedFiles: [outline],
|
|
}, server[2]!];
|
|
|
|
const refreshed = mergeServerMessagesIntoConversation(local, server);
|
|
expect(refreshed[1]?.runId).toBe(planning.runId);
|
|
expect(refreshed[1]?.producedFiles).toEqual([outline]);
|
|
expect(refreshed[2]?.producedFiles).toEqual(productionFiles);
|
|
});
|
|
|
|
it('keeps all six late production files and the declared main artifact on their own run', () => {
|
|
const local = threeRunHistory();
|
|
const server = local.map((message) => message.id === 'production'
|
|
? { ...message, producedFiles: undefined }
|
|
: message);
|
|
|
|
const refreshed = mergeServerMessagesIntoConversation(local, server);
|
|
expect(refreshed[0]?.producedFiles ?? []).toEqual([]);
|
|
expect(refreshed[1]?.producedFiles ?? []).toEqual([]);
|
|
expect(refreshed[2]?.runId).toBe('run-production');
|
|
expect(refreshed[2]?.producedFiles).toEqual(productionFiles);
|
|
expect(refreshed[2]?.events).toContainEqual({ kind: 'artifact_focus', show: ['index.html'] });
|
|
});
|
|
|
|
it.each(['local', 'server'] as const)('keeps late files when the %s legacy snapshot has no run identity', (legacySide) => {
|
|
const message: ChatMessage = {
|
|
id: 'legacy-delivery', role: 'assistant', runId: 'run-production',
|
|
content: 'The lesson is ready.', runStatus: 'succeeded',
|
|
};
|
|
const local: ChatMessage = {
|
|
...message,
|
|
...(legacySide === 'local' ? { runId: undefined } : {}),
|
|
producedFiles: productionFiles,
|
|
};
|
|
const server: ChatMessage = {
|
|
...message,
|
|
...(legacySide === 'server' ? { runId: undefined } : {}),
|
|
};
|
|
|
|
expect(mergeServerMessagesIntoConversation([local], [server])[0]?.producedFiles)
|
|
.toEqual(productionFiles);
|
|
});
|
|
|
|
it('does not keep the live copy that absorbed a successor Run', () => {
|
|
// Live streaming re-points the SAME assistant message at each successor
|
|
// Run of a Full Plan task, so the local copy of the FIRST message ends up
|
|
// holding the production output too. The daemon persists one message per
|
|
// Run, so a refresh brings production back as its own row — and the
|
|
// "local is longer, keep local" rule would then render it twice.
|
|
const local: ChatMessage[] = [
|
|
{ id: 'u1', role: 'user', content: '做一个番茄钟' } as ChatMessage,
|
|
{
|
|
id: 'a-plan',
|
|
role: 'assistant',
|
|
content: 'PLAN\nPRODUCTION',
|
|
events: [{ kind: 'text', text: 'PLAN' }, { kind: 'text', text: 'PRODUCTION' }],
|
|
runId: 'run-production',
|
|
} as ChatMessage,
|
|
];
|
|
const server: ChatMessage[] = [
|
|
{ id: 'u1', role: 'user', content: '做一个番茄钟' } as ChatMessage,
|
|
{
|
|
id: 'a-plan',
|
|
role: 'assistant',
|
|
content: 'PLAN',
|
|
events: [{ kind: 'text', text: 'PLAN' }],
|
|
runId: 'run-request',
|
|
strategyTaskExecutionId: 'odnext_1',
|
|
strategyTaskRunIndex: 0,
|
|
} as ChatMessage,
|
|
{
|
|
id: 'a-production',
|
|
role: 'assistant',
|
|
content: 'PRODUCTION',
|
|
events: [{ kind: 'text', text: 'PRODUCTION' }],
|
|
runId: 'run-production',
|
|
strategyTaskExecutionId: 'odnext_1',
|
|
strategyTaskRunIndex: 1,
|
|
} as ChatMessage,
|
|
];
|
|
|
|
const merged = mergeServerMessagesIntoConversation(local, server);
|
|
const whole = merged.map((m) => m.content).join('\n');
|
|
|
|
expect(whole.split('PRODUCTION')).toHaveLength(2);
|
|
expect(whole.split('PLAN')).toHaveLength(2);
|
|
});
|
|
|
|
it('still prefers a longer local body for an ordinary turn', () => {
|
|
// The #6396 guard must survive: without a successor Run there is nothing
|
|
// to have absorbed, so a longer local body is genuinely fresher.
|
|
const local: ChatMessage[] = [
|
|
{
|
|
id: 'a1',
|
|
role: 'assistant',
|
|
content: 'streamed the full answer',
|
|
events: [{ kind: 'text', text: 'streamed the full answer' }],
|
|
} as ChatMessage,
|
|
];
|
|
const server: ChatMessage[] = [
|
|
{ id: 'a1', role: 'assistant', content: 'stale', events: [] } as ChatMessage,
|
|
];
|
|
|
|
expect(mergeServerMessagesIntoConversation(local, server)[0]!.content).toBe(
|
|
'streamed the full answer',
|
|
);
|
|
});
|
|
|
|
it('keeps the local body for the LAST Run of a task', () => {
|
|
// The final Run's own message has no successor, so its live copy is the
|
|
// freshest one and must not be replaced by a lagging server snapshot.
|
|
const local: ChatMessage[] = [
|
|
{
|
|
id: 'a-production',
|
|
role: 'assistant',
|
|
content: 'PRODUCTION plus the tail that has not been persisted yet',
|
|
} as ChatMessage,
|
|
];
|
|
const server: ChatMessage[] = [
|
|
{
|
|
id: 'a-production',
|
|
role: 'assistant',
|
|
content: 'PRODUCTION',
|
|
strategyTaskExecutionId: 'odnext_1',
|
|
strategyTaskRunIndex: 1,
|
|
} as ChatMessage,
|
|
];
|
|
|
|
expect(mergeServerMessagesIntoConversation(local, server)[0]!.content).toContain(
|
|
'not been persisted yet',
|
|
);
|
|
});
|
|
});
|