Files
open-design/apps/web/tests/components/ProjectView.questionFormKey.test.ts
T
lefarcenandGitHub 1bd134dc5f fix(daemon): do not carry run artifacts into forked chats (#8029)
* test(daemon): expose forked artifact history leakage

* fix(daemon): clear run artifacts when forking transcripts

* fix(daemon): preserve run-owned artifacts across conversation forks
2026-09-17 03:13:22 +00:00

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',
);
});
});