fix(chat): 封面晚到时把产物卡换成快照,别把整个会话留在活 iframe 上

规格 §4.1 写死了:HTML / prototype / **slide** / document 的卡面只渲染
`<img>` 快照,不再挂该文件的 iframe。用户看到 deck 是活的,不是因为快照没做,
而是**输了 466 毫秒**。

真机数据:打包版里 `opendesign-seed-pitch.html` 的 `capture_state` 就是
`ready`、缩略图字节也在。但 ref 落库 17:13:05、封面 ready 17:13:06 —— 间隔
616ms,而客户端只在 run 终止后 **150ms** 重拉一次(`scheduleConversationMessage
Refresh`),之后再也不拉,`cover.ts` 也不推任何东西。于是卡片在整个会话里停在
§6.4 的降级支上,直到整页刷新。

这正是规格第 505 行规定却从未实现的那半句:「pending thumbnail 不出
placeholder,直接走降级支;**后台 ready 后消息投影更新**」。

封面**故意不 await**(「一轮对话不该为了一张缩略图多等几秒」),所以补的是推送
而不是等待:daemon 在 `attachChatArtifactThumbnail` 之后发一个**瘦事件**
(只有 projectId / conversationId / messageId,不带 refs),web 收到后复用现有
重拉。让 `listMessages` 仍然是「ref 是什么」的唯一权威 —— 事件重复或丢失最多
多一次 fetch,不会画错卡。降级支原样保留。

红测钉的是**时序**,不是分支:既有用例全都直接把 `artifactRefs` 当 prop 注入,
所以分支一直是对的、时序从没被测过。daemon 侧刻意在 run 已终止之后才订阅
(真实用户处境),先断言此刻 ref 还不是 ready 再放行渲染;web 侧先断言是
iframe 且没有 img,投递事件后断言换成 img 且 iframe 消失,另加一条反向控制
——别的 projectId 的事件不得触发重拉,否则「见事件就重拉」也能蒙混过关。

顺手拦下一个真 bug:`projectEventToAgentEvent` 是一条**结尾没有守卫的收窄链**,
排除已知类型后直接假定剩下的是 `live_artifact_refresh` 并读 `evt.phase`。新事件
加进联合后会掉进去,变成一张 phase 为 undefined 的假 live-artifact 卡。
This commit is contained in:
lefarcen
2026-09-04 18:32:44 +08:00
parent 68a15b568f
commit 1bc480a340
6 changed files with 612 additions and 2 deletions
+27 -2
View File
@@ -100,6 +100,21 @@ export interface FreezeChatArtifactCoversInput {
* only tests that want a deterministic failure pass anything else.
*/
videoFrameExtractor?: ChatArtifactVideoFrameExtractor;
/**
* Announce that this ref's projection changed after the turn already ended.
*
* The render outlives the turn on purpose, so by the time a cover exists the
* client has already done its one post-run re-read and is showing the
* live-iframe degrade branch. Something has to tell it to look again — spec
* line 505, "后台 ready 后消息投影更新".
*
* A callback rather than an import: this module has no business knowing what
* an SSE sink is, and the same indifference is what lets the tests observe
* the render without standing up a transport. Fired only for a cover that
* actually landed — a failure changes no projection, so announcing one would
* buy the client a pointless fetch.
*/
onRefsChanged?: (row: MessageArtifactRow) => void;
}
export interface FreezeChatArtifactCoversReport {
@@ -173,7 +188,7 @@ export async function freezeAndRenderChatArtifactCovers(
// Serial: the desktop renderer is one Electron window behind one IPC
// socket, so firing these in parallel would only queue them somewhere less
// observable.
void renderCoversSequentially(deps, input.renderer, pending);
void renderCoversSequentially(deps, input.renderer, pending, input.onRefsChanged);
}
return report;
}
@@ -357,10 +372,11 @@ async function renderCoversSequentially(
deps: ChatArtifactCaptureDeps,
renderer: ChatArtifactCoverRenderer,
pending: ReadonlyArray<{ row: MessageArtifactRow; render: DesktopExportArtifactInput }>,
onRefsChanged: ((row: MessageArtifactRow) => void) | undefined,
): Promise<void> {
for (const item of pending) {
try {
await renderOneCover(deps, renderer, item.row, item.render);
await renderOneCover(deps, renderer, item.row, item.render, onRefsChanged);
} catch (err) {
// A cover is never allowed to take anything else down with it: the turn
// has already been reported as finished by the time this runs.
@@ -379,6 +395,7 @@ async function renderOneCover(
renderer: ChatArtifactCoverRenderer,
row: MessageArtifactRow,
render: DesktopExportArtifactInput,
onRefsChanged: ((row: MessageArtifactRow) => void) | undefined,
): Promise<void> {
let result: DesktopExportArtifactResult;
try {
@@ -401,6 +418,14 @@ async function renderOneCover(
bytes,
mime: result.mime || 'image/png',
});
// The cover is durable now, so the client's re-read is guaranteed to find
// it. Announcing before the attach would race the very fetch it invites.
// Never allowed to fail the render it is reporting on.
try {
onRefsChanged?.(row);
} catch (err) {
logCoverFailure(row.labelAtCapture, err);
}
} finally {
// The renderer's temp file is the daemon's to clean up — the same contract
// the `od export` route follows.
+21
View File
@@ -11427,6 +11427,27 @@ export async function startServer({
projectRoot: outcome.projectRoot,
rows: captured.rows,
renderer: desktopArtifactExporter,
/*
* The cover outlives the turn by design, so it lands into a message
* the client already filed as done — after the single post-run
* re-read (`ProjectView.scheduleConversationMessageRefresh`, 150ms)
* has been and gone. Without this nudge the card keeps the
* live-iframe degrade branch until a full reload, which is the
* regression spec line 505 exists to forbid.
*
* Thin on purpose: it names the stale message and nothing else, so
* `listMessages` stays the only thing that decides what a ref is.
*/
onRefsChanged: (row) => {
if (!run.projectId || !run.conversationId) return;
emitProjectEvent(run.projectId, {
type: 'chat-artifact-refs-changed',
projectId: run.projectId,
conversationId: run.conversationId,
messageId: row.messageId,
at: Date.now(),
});
},
});
} catch (err) {
console.warn('[chat-artifacts] run terminal capture failed', err);
@@ -87,6 +87,73 @@ async function waitFor<T>(
return last;
}
interface CapturedSseEvent {
event: string;
data: any;
}
/**
* Read `/api/projects/:id/events` as raw SSE frames.
*
* `EventSource` does not exist in this runtime and the daemon writes one frame
* per `res.write` (`createSseResponse`), so a body reader split on the blank
* line is both sufficient and closer to the wire than any client shim: it sees
* the event NAME, which is the thing under test.
*/
async function openProjectEventStream(projectId: string): Promise<{
events: CapturedSseEvent[];
ready: Promise<void>;
close: () => void;
}> {
const controller = new AbortController();
const response = await fetch(`${baseUrl}/api/projects/${projectId}/events`, {
headers: { Accept: 'text/event-stream' },
signal: controller.signal,
});
expect(response.ok, 'the project event stream should accept the subscription').toBe(true);
const events: CapturedSseEvent[] = [];
let markReady!: () => void;
const ready = new Promise<void>((resolve) => {
markReady = resolve;
});
void (async () => {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffered = '';
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
let boundary = buffered.indexOf('\n\n');
while (boundary !== -1) {
const frame = buffered.slice(0, boundary);
buffered = buffered.slice(boundary + 2);
const name = /^event:\s*(.*)$/m.exec(frame)?.[1]?.trim();
if (name) {
const raw = /^data:\s*(.*)$/m.exec(frame)?.[1];
let data: any = null;
try {
data = raw ? JSON.parse(raw) : null;
} catch {
// A frame we cannot parse is still a frame; keep the name.
}
events.push({ event: name, data });
if (name === 'ready') markReady();
}
boundary = buffered.indexOf('\n\n');
}
}
} catch {
// Aborted by `close()` at the end of the test.
}
})();
return { events, ready, close: () => controller.abort() };
}
function dataDir(): string {
const dir = process.env.OD_DATA_DIR;
if (!dir) throw new Error('OD_DATA_DIR is required for chat artifact cover tests');
@@ -373,4 +440,83 @@ describe('run terminal HTML cover wiring', () => {
const body = (await versions.json()) as { versions?: Array<{ id: string }> };
expect((body.versions ?? []).map((v) => v.id)).toContain(html!.versionId);
});
/**
* 设计文档第 505 行:「pending thumbnail 不出 placeholder,直接走 §6.4 的降级支;
* **后台 ready 后消息投影更新**」。
*
* 前半条早就成立(上面几条测的就是它),后半条一直是空的:封面**故意不 await**,
* 它在终止帧之后几百毫秒才落库,而 daemon 落库时**一声不吭**。客户端在
* run 终止后 150ms 拉一次(`ProjectView.scheduleConversationMessageRefresh`),
* 那一拉必然早于封面,之后再也不拉 —— 于是卡片在整个会话里一直停在降级支的
* live iframe 上,直到整页刷新。真机实测这一局输了 466 毫秒:ref 落库
* 17:13:05,封面 ready 17:13:06。
*
* ── 这个量法能看见缺陷吗 ────────────────────────────────────────────────
* 关键是**订阅发生在 run 已经终止之后**。这正是真实用户的处境:他盯着一条
* 已经写完的消息。如果先订阅再跑,事件可能是被终止帧顺路带出来的,那就证不出
* 「封面自己会说话」。所以这里先把 renderer 关在闸门后跑完一整轮,确认此刻
* **还没有** ready 的 ref,再连上事件流,最后才放行渲染。
*
* 断言的是 wire 上的事件名和消息身份,不是某个内部函数被调用过 —— 后者在
* daemon 不推送的今天也能被 mock 成绿的。
*/
it('tells the project stream when a cover lands after the turn already ended', async () => {
exporterCalls = [];
let release!: () => void;
renderGate = new Promise<void>((r) => {
release = r;
});
exporterResult = async () => {
const out = join(await fsp.mkdtemp(join(tmpdir(), 'od-cover-out-')), 'cover.png');
await fsp.writeFile(out, PNG_1X1);
return { ok: true, path: out, mime: 'image/png', bytes: PNG_1X1.byteLength };
};
const turn = await runTurnThatWritesHtml();
// The turn is terminal and the renderer is parked at the gate, so the card
// the user is looking at right now has no cover — it is on the live-iframe
// degrade branch. That is the moment this test is about.
await waitFor(() => exporterCalls.length, (n) => n > 0, 8_000);
const beforeRelease = refsFor(turn.projectId, turn.messageId);
expect(
beforeRelease.find((ref) => ref.label === 'index.html')?.snapshotState,
'the cover must still be outstanding, otherwise this test proves nothing',
).not.toBe('ready');
// The viewer connects AFTER the run finished, exactly like someone sitting
// on a completed message.
const stream = await openProjectEventStream(turn.projectId);
try {
await stream.ready;
release();
const signal = await waitFor(
() => stream.events.find((evt) => evt.event === 'chat-artifact-refs-changed'),
(evt) => Boolean(evt),
8_000,
);
expect(
signal,
'a cover that lands after the terminal frame must announce itself on the project stream',
).toBeTruthy();
// The payload has to name the message whose projection went stale, or the
// client cannot know which conversation to re-read.
expect(signal?.data?.projectId).toBe(turn.projectId);
expect(signal?.data?.conversationId).toBe(turn.conversationId);
expect(signal?.data?.messageId).toBe(turn.messageId);
// And the re-read the signal invites must actually return the cover.
const refs = await waitFor(
() => refsFor(turn.projectId, turn.messageId),
(list) => list.some((ref) => ref.label === 'index.html' && ref.snapshotState === 'ready'),
);
expect(refs.find((ref) => ref.label === 'index.html')?.thumbnailUrl).toBeTruthy();
} finally {
stream.close();
}
});
});
+29
View File
@@ -4,6 +4,7 @@ import { bindStreamVisibility } from '../lib/stream-visibility';
import {
COLLAB_PROJECT_INVALIDATION_EVENTS,
PROJECT_CONTENT_TRANSFER_STATE_EVENT,
type ChatArtifactRefsChangedSsePayload,
type CollabProjectInvalidationSsePayload,
type LiveArtifactRefreshSsePayload,
type LiveArtifactSsePayload,
@@ -35,11 +36,21 @@ export type ProjectLiveArtifactEvent = LiveArtifactSsePayload | LiveArtifactRefr
// receipt — the event carries no body.
export type ProjectCollabInvalidationEvent = CollabProjectInvalidationSsePayload;
/**
* A finished message's artifact refs changed after its run's terminal frame.
*
* Same thin-invalidation shape as the collab events above — it names the stale
* message and carries no refs, so the consumer re-reads the conversation rather
* than trusting a projection assembled on the wire.
*/
export type ProjectChatArtifactRefsChangedEvent = ChatArtifactRefsChangedSsePayload;
export type ProjectEvent =
| ProjectFileChangeEvent
| ProjectConversationCreatedEvent
| ProjectLiveArtifactEvent
| ProjectCollabInvalidationEvent
| ProjectChatArtifactRefsChangedEvent
| ProjectContentTransferStateSsePayload;
export interface ProjectEventsConnectionOptions {
@@ -201,6 +212,24 @@ export function createProjectEventsConnection(
}
});
}
// A cover finished rendering after its turn already ended. Thin signal:
// it names the stale message, and the consumer re-reads the conversation.
es.addEventListener('chat-artifact-refs-changed', (evt) => {
try {
const data = JSON.parse(
(evt as MessageEvent).data,
) as ProjectChatArtifactRefsChangedEvent;
onChange(data);
} catch (err) {
if (
typeof process !== 'undefined'
&& process.env?.NODE_ENV === 'development'
) {
// eslint-disable-next-line no-console
console.warn('[project-events] malformed chat-artifact-refs-changed payload', err);
}
}
});
es.addEventListener(PROJECT_CONTENT_TRANSFER_STATE_EVENT, (evt) => {
try {
// Thin invalidation only. The consumer must re-read exact-scoped
@@ -0,0 +1,361 @@
// @vitest-environment jsdom
/**
* 封面**晚到**的那一局 —— `chat-artifact-versioning-design.md` 第 505 行:
* 「pending thumbnail 不出 placeholder,直接走 §6.4 的降级支;
* **后台 ready 后消息投影更新**」。
*
* 前半条一直是对的,后半条一直是空的。HTML 卡的静态封面**故意不 await**
* (`chat-artifacts/cover.ts`:「一轮对话不该为了一张缩略图多等几秒」),
* 它在终止帧之后几百毫秒才落库;而客户端在 run 终止后**只拉一次**
* (`scheduleConversationMessageRefresh`,150ms),那一拉必然早于封面。
* 真机实测这一局输了 466 毫秒:ref 落库 17:13:05,封面 ready 17:13:06。
* 之后再也没有第二次拉取 —— 于是卡片在整个会话里停在降级支的 live iframe 上,
* 直到整页刷新。用户看到的就是「12 页 deck 在卡里活着跑」。
*
* ── 这个量法能看见缺陷吗 ────────────────────────────────────────────────
* 要害是**顺序**,不是「有 ready ref 就画 img」。后者在坏掉的构建上也是绿的
* (`artifact-card-version-semantics.test.tsx` 已经覆盖了那条,注入 prop 即可)。
* 所以这里必须先让卡真的落到 iframe 上,再放事件进来,最后才断言它换成了 img:
*
* ① 挂载时 `listMessages` 返回 `legacy_unavailable` —— 150ms 那一拉的真实返回;
* ② 断言卡面是 `<iframe>`(降级支);
* ③ 送进 `chat-artifact-refs-changed`,此时 `listMessages` 改口 `ready`;
* ④ 断言卡面换成 `<img src=thumbnailUrl>`,且 iframe 消失。
*
* 去掉 ③ 那一步的处理分支,④ 必须重新变红 —— 已验(见本次改动说明)。
*
* ── 为什么 ChatPane 的替身里挂的是**真的** AssistantMessage ────────────
* 被测的是 ProjectView 的**消息状态**会不会因为那个事件而更新。卡片自己怎么在
* `ready` 和降级之间选,是 `FileOpsSummary` 的事,已另有覆盖。所以这里把替身做薄:
* 只替掉 ChatPane 的版式,`message → messageArtifactRefs → FileOpsSummary →
* ArtifactCard` 整条仍然是真的,ProjectView 交出什么样的 message,卡就照着画。
* 如果这里连 AssistantMessage 一起 mock 掉,断言的就是夹具而不是产品。
*
* ── jsdom 的两个坑(不绕过就是假绿)────────────────────────────────────
* 1. HTML 降级支要挂出 iframe 有两道前置:`useInView`(jsdom 没有
* IntersectionObserver,直接判 true)和一次 `HEAD` 探测成功。默认 jsdom 里
* `fetch('/api/...')` 会抛(相对 URL 无 origin),iframe 根本挂不出来 ——
* 那样第 ② 步会**假绿**:它测的是网络在 jsdom 里失败了。所以下面把 `fetch`
* 打桩成 200。
* 2. jsdom **不做布局**(`scrollHeight`/`clientHeight` 恒为 0)也不加载样式表,
* 所以规格 §12.3 那条「pending → ready 不改变卡外框高度、不打断用户滚动」
* **在这里量不到**,本文件一个字都没有断言它。那一条只能真机验。
*/
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import { useProjectFileEvents, type ProjectEvent } from '../../src/providers/project-events';
import type { ChatMessage } from '../../src/types';
const PROJECT_ID = 'project-1';
const CONVERSATION_ID = 'conv-1';
const MESSAGE_ID = 'msg-1';
const ARTIFACT = 'opendesign-seed-pitch.html';
const COVER_URL = `/api/projects/${PROJECT_ID}/chat-artifact-snapshots/snap-1/thumbnail`;
const RAW_URL = `/api/projects/${PROJECT_ID}/raw/${ARTIFACT}`;
const listConversations = vi.fn();
const listMessages = vi.fn();
const fetchPreviewComments = vi.fn();
const loadTabs = vi.fn();
const fetchProjectFiles = vi.fn();
const fetchLiveArtifacts = vi.fn();
const fetchSkill = vi.fn();
const fetchDesignSystem = vi.fn();
const getTemplate = vi.fn();
const fetchChatRunStatus = vi.fn();
const listActiveChatRuns = vi.fn();
const listProjectRuns = vi.fn();
const reattachDaemonRun = vi.fn();
const streamViaDaemon = vi.fn();
const saveMessage = vi.fn();
vi.mock('../../src/i18n', () => ({
useI18n: () => ({ locale: 'en', setLocale: () => undefined, t: (value: string) => value }),
useT: () => ((value: string) => value),
}));
vi.mock('../../src/providers/anthropic', () => ({ streamMessage: vi.fn() }));
vi.mock('../../src/providers/daemon', () => ({
GENERIC_DAEMON_DISCONNECT_CODE: 'GENERIC_DAEMON_DISCONNECT',
GENERIC_DAEMON_DISCONNECT_MESSAGE: 'daemon stream disconnected before run completed',
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
listProjectRuns: (...args: unknown[]) => listProjectRuns(...args),
publishDaemonRunFinishedEvent: vi.fn(),
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
streamViaDaemon: (...args: unknown[]) => streamViaDaemon(...args),
}));
/*
* 部分 mock:产物卡要用真的 `projectFileUrl` 去拼降级支那个 iframe 的 src ——
* 那正是第 ② 步要断言的东西,替成假的就等于自己写了个答案。
*/
vi.mock('../../src/providers/registry', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
deletePreviewComment: vi.fn(),
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
fetchProjectDesignSystemPackageAudit: vi.fn(async () => null),
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
patchPreviewCommentStatus: vi.fn(),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: vi.fn(),
}));
vi.mock('../../src/providers/project-events', () => ({ useProjectFileEvents: vi.fn() }));
vi.mock('../../src/router', () => ({ navigate: vi.fn() }));
vi.mock('../../src/state/projects', () => ({
cacheTabsLocally: vi.fn((projectId: string, tabs: unknown) => ({ projectId, tabs })),
createConversation: vi.fn(),
deleteConversation: vi.fn(),
getTemplate: (...args: unknown[]) => getTemplate(...args),
listConversations: (...args: unknown[]) => listConversations(...args),
listMessages: (...args: unknown[]) => listMessages(...args),
loadTabs: (...args: unknown[]) => loadTabs(...args),
patchConversation: vi.fn(),
patchProject: vi.fn(),
persistTabsToDaemonNow: vi.fn(),
saveMessage: (...args: unknown[]) => saveMessage(...args),
saveTabs: vi.fn(),
}));
vi.mock('../../src/components/AppChromeHeader', () => ({ AppChromeHeader: () => null }));
vi.mock('../../src/components/AvatarMenu', () => ({ AvatarMenu: () => null }));
vi.mock('../../src/components/Loading', () => ({ CenteredLoader: () => null }));
vi.mock('../../src/components/FileWorkspace', () => ({
DESIGN_SYSTEM_TAB: '__design_system__',
FileWorkspace: () => null,
}));
/*
* 薄替身:只替掉 ChatPane 的版式,产物卡那条链路留真的。ProjectView 手里的
* message 是什么样,卡就画成什么样 —— 这正是本文件要观察的东西。
*/
vi.mock('../../src/components/ChatPane', async () => {
const { AssistantMessage } = await import('../../src/components/AssistantMessage');
return {
ChatPane: ({ messages }: { messages: ChatMessage[] }) => (
<>
{messages
.filter((message) => message.role === 'assistant')
.map((message) => (
<AssistantMessage
key={message.id}
message={message}
streaming={false}
projectId={PROJECT_ID}
/>
))}
</>
),
};
});
/** 这一轮产出的 HTML。有 `producedFiles` 才会出产物卡。 */
function producedHtml() {
return [
{
name: ARTIFACT,
path: ARTIFACT,
size: 49_711,
mtime: Date.now(),
kind: 'html' as const,
mime: 'text/html',
},
];
}
/**
* 一条**已经结束**的轮次。
*
* `snapshotState` 由调用方给:挂载那次是 `legacy_unavailable`(150ms 那一拉的
* 真实返回 —— 封面还没渲染完,ref 上根本没有 snapshot 行),事件之后才是 `ready`。
*/
function finishedTurn(ref: Record<string, unknown>): ChatMessage[] {
return [
{
id: MESSAGE_ID,
role: 'assistant',
agentId: 'agent-1',
content: '做好了',
createdAt: Date.now(),
runId: 'run-1',
runStatus: 'succeeded',
producedFiles: producedHtml(),
artifactRefs: [ref],
} as unknown as ChatMessage,
];
}
const pendingRef = {
id: 'ref-1',
label: ARTIFACT,
kind: 'html',
displayPolicy: 'latest_with_static_preview',
workspaceArtifactId: 'wa-1',
snapshotState: 'legacy_unavailable',
};
const readyRef = {
...pendingRef,
snapshotId: 'snap-1',
thumbnailUrl: COVER_URL,
snapshotState: 'ready',
};
function renderProjectView() {
const project = {
id: PROJECT_ID,
name: 'OpenDesign 种子轮路演 Deck',
skillId: null,
designSystemId: null,
} as never;
return render(
<ProjectView
project={project}
initialProjectDetail={{ project, resolvedDir: null }}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
}
/** 让 HTML 降级支那道 HEAD 探测**通过** —— 见文件头的假绿说明。 */
function stubHeadProbeAsReachable() {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async () => new Response('', { status: 200 }),
);
}
function primeDaemon() {
listConversations.mockResolvedValue([{ id: CONVERSATION_ID, title: 'Conversation' }]);
listMessages.mockResolvedValue(finishedTurn(pendingRef));
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue(producedHtml());
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
listProjectRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue(null);
}
/** ProjectView 挂给 `useProjectFileEvents` 的那个 handler(第 3 个实参)。 */
function projectEventHandler(): (evt: ProjectEvent) => void {
const calls = vi.mocked(useProjectFileEvents).mock.calls;
const handler = calls.at(-1)?.[2];
if (typeof handler !== 'function') {
throw new Error('ProjectView never subscribed to the project event stream');
}
return handler as (evt: ProjectEvent) => void;
}
const card = () =>
document.querySelector<HTMLElement>(`[data-testid="artifact-card-${ARTIFACT}"]`);
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
window.sessionStorage.clear();
});
beforeEach(() => {
primeDaemon();
stubHeadProbeAsReachable();
});
describe('封面在终止帧之后才落地时,卡面必须自己换成快照', () => {
it('先降级成 live iframe,收到 chat-artifact-refs-changed 后换成 <img> 快照', async () => {
renderProjectView();
await waitFor(() => expect(listMessages).toHaveBeenCalled());
// ① + ②:150ms 那一拉返回的就是「没有 ready 快照」,卡落在降级支上。
const frame = await waitFor(() => {
const found = card()?.querySelector<HTMLIFrameElement>('iframe');
expect(found, '没有当轮快照时,卡面应当是 live iframe(§6.4 降级支)').toBeTruthy();
return found!;
}, { timeout: 2_000 });
expect(frame.getAttribute('src')).toBe(RAW_URL);
expect(
card()?.querySelector('img.artifact-card-media'),
'快照还没落地就画 img,卡面会是一张碎图',
).toBeNull();
// ③:封面这时候才渲染完落库,daemon 推事件,重拉才能拿到 ready。
listMessages.mockResolvedValue(finishedTurn(readyRef));
act(() => {
projectEventHandler()({
type: 'chat-artifact-refs-changed',
projectId: PROJECT_ID,
conversationId: CONVERSATION_ID,
messageId: MESSAGE_ID,
} as ProjectEvent);
});
// ④:卡面自己换成当轮静态首屏截图,不需要整页刷新。
const shot = await waitFor(() => {
const found = card()?.querySelector<HTMLImageElement>('img.artifact-card-media');
expect(
found,
'封面已经 ready 却还挂着 live iframe:整条会话里 deck 会一直活着跑',
).toBeTruthy();
return found!;
}, { timeout: 2_000 });
expect(shot.getAttribute('src')).toBe(COVER_URL);
expect(
card()?.querySelector('iframe'),
'换成快照后还留着 iframe = 两份卡面同时在,活的那份还会跟着 latest 漂',
).toBeNull();
});
it('别的项目的同名事件不该惊动这条会话', async () => {
renderProjectView();
await waitFor(() => expect(listMessages).toHaveBeenCalled());
await waitFor(() => expect(card()?.querySelector('iframe')).toBeTruthy(), {
timeout: 5_000,
});
const before = listMessages.mock.calls.length;
act(() => {
projectEventHandler()({
type: 'chat-artifact-refs-changed',
projectId: 'some-other-project',
conversationId: CONVERSATION_ID,
messageId: MESSAGE_ID,
} as ProjectEvent);
});
/*
* 反向对照。少了它,一个「收到任何事件就无脑重拉」的实现也能把上面那条弄绿,
* 而那种实现会让每个开着的项目互相拉对方的会话。
*/
await new Promise((resolve) => setTimeout(resolve, 300));
expect(listMessages.mock.calls.length).toBe(before);
});
});
+28
View File
@@ -60,6 +60,34 @@ export interface ProjectConversationCreatedSsePayload {
createdAt: number;
}
/**
* Emitted by the daemon on `/api/projects/:id/events` when a finished message's
* artifact refs changed AFTER its run's terminal frame already went out.
*
* The one producer today is the HTML card's static cover. Freezing the renderer
* input is awaited at the terminal chokepoint, but the render itself is
* deliberately not (`chat-artifacts/cover.ts`) — a turn should not stay open for
* a thumbnail. So the cover lands a few hundred milliseconds into a message the
* client already considers done, and the client's one post-run re-read has
* already been and gone. Without this signal the card keeps the live-iframe
* degrade branch for the rest of the session, which is precisely what
* `chat-artifact-versioning-design.md` line 505 rules out: "pending thumbnail
* 不出 placeholder,直接走 §6.4 的降级支;后台 ready 后消息投影更新".
*
* Thin by design, like the collab invalidation events it sits beside: it names
* the message whose projection went stale and carries no refs of its own. The
* consumer re-reads the conversation, so one authority (`listMessages`) keeps
* deciding what a ref actually is, and a dropped or duplicated event costs a
* redundant fetch rather than a wrong card.
*/
export interface ChatArtifactRefsChangedSsePayload {
type: 'chat-artifact-refs-changed';
projectId: string;
conversationId: string;
messageId: string;
at?: number;
}
export const CHAT_SSE_PROTOCOL_VERSION = 1;
export interface ChatSseStartPayload {