diff --git a/docs/rework/hel898/f92a86f-rework/HEL-898-f92a86f-rework.patch b/docs/rework/hel898/f92a86f-rework/HEL-898-f92a86f-rework.patch new file mode 100644 index 0000000..edf4a47 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/HEL-898-f92a86f-rework.patch @@ -0,0 +1,1036 @@ +diff --git a/docs/requirements/handoff-HEL-898.json b/docs/requirements/handoff-HEL-898.json +--- a/docs/requirements/handoff-HEL-898.json ++++ b/docs/requirements/handoff-HEL-898.json +@@ -1,7 +1,7 @@ + { + "baseline_version": "1.11", ++ "read_commit": "f92a86f5d8f839338311cdee08179049de34eb20", ++ "task": "HEL-898(HEL-879 接线卡,视觉总监本人编码):在已认可的交谈界面接上她的增量字幕(cg.subtitle 契约 v2)与语音内主动开口呈现,处理打断/文字接管/错误/挂断收尾;不另做聊天页、不改主题与整体动效;f92a86f 统一复核后的有限返修三项(worker 离场诚实收场、已退休 speech 不复活、短语音回复播放中可见)及回放基础设施", +- "read_commit": "3228b1e0d7f822b7030f945e285eac74126771aa", +- "task": "HEL-898(HEL-879 接线卡,视觉总监本人编码):在已认可的交谈界面接上她的增量字幕(cg.subtitle 契约 v2)与语音内主动开口呈现,处理打断/文字接管/错误/挂断收尾;不另做聊天页、不改主题与整体动效", + "requirement_ids": [ + "CG-004", "CG-006", "CG-010", "CG-011", "CG-018", "CG-020", + "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", +@@ -10,7 +10,7 @@ + "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006", + "DEL-001" + ], ++ "deliverable": "自总管输入包 3228b1e(INPUT-MANIFEST 逐文件 SHA-256)起的前端补丁,只动 web/src/voice/session.ts、web/src/chat/ChatPanel.tsx 与测试:1) session.ts 严格解析 v2 信封(v===2、正整数 seq、kind 白名单、角色与种类对应、她的事件必带 speech_id、assistant_final 必带 turn_id、proactive_* 必带正整数 message_id;不合格整条丢弃且不推进序号);每次通话(控制器代次)独立 seq 高水位,seq<=已应用即丢;按 speech 生命周期只接受当前打开的一轮,已关闭/被取代的 speech 不再显示,旧 speech 的高 seq 事件不能关闭新轮。VoiceView 新增 her(speech/text/state open|final|done/turnId/messageId/proactive),与用户 ASR 草稿 heard 分槽。2) turn_start 开轮,assistant_interim 整句替换,assistant_final 原子替换为落库文本并请页面回读 /api/messages,turn_end 关轮(无 final 的关轮按未成为正式回复即清),turn_cancel/error(φ)/user_final/文字接管 hush/结束/掉线/卸载/重开即时清;φ 原句仍在语音行以“未记入聊天”显示。3) proactive_start 以落库原文、同源 message_id 显示在她的位置,proactive_end 保留至记录或下一轮接替,proactive_cancel 即清;不发任何请求、不标已读、不开麦、不重 TTS、不进入正式记录。4) ChatPanel:她的语音行进入与文字回复相同的舞台槽位(当前一两句、自动续读、暂停/回看、全文),生长中沿用文字流的“最新一句先压住”规则,逐分片不重启动画;落库行到来时同一 turn 同一位置接替,行键不变不重放;被撤回的语音行以 instant 方式立即离场(不淡出);减少动态设置沿用既有逻辑。你用语音开始新一轮时与发送文字一致恢复自动呈现(原先暂停状态下她的回答不会进来)。5) 测试:voice.test.ts 改为 v2 帧并新增 11 项(录制样例三场景回放、信封负例、乱序/重放/迟到/双 handle、φ、主动开口及打断、文字接管/挂断/重开、舞台行键接替);fake-livekit.js 未带信封的帧自动套 v2;hel878_mock.mjs 增 /__proactive;hel878_shots.mjs 的她的 final 与 φ 改为 v2 生命周期;新增 hel898_shots.mjs(两主题×电脑/手机)。【f92a86f 有限返修,首轮读取 3228b1e;本轮输入包 166 文件 SHA-256 全符】1) livekit.ts 监听 RoomEvent.ParticipantDisconnected(vendor 2.22.4 原名 participantDisconnected,emit(participant, reason)),只在本房间她的 worker(按身份记住的 agent 或 isAgent)离开、且房内没有其他 agent 时报 agentLeft;房间关闭中不报。session.ts 的 RoomEvents 新增 agentLeft:仅当前代次且 agent 已接入时走既有 fail(清字幕/草稿、停麦、清播放、关房、DELETE 本次服务房,失败入欠账重试),中文提示 WORDS.agentLeft;普通参与者离场、旧房间迟到离场、用户主动挂断都不触发。2) 已关闭 speech 由 64 项窗口数组改为每次通话一个 Set,随通话代次释放;seq 严格门不变。3) 语音回复开放态改为 live=speaking:reading.pagesOf 对其最后一块只显示已成句部分(。!?!?… 及其后的收尾引号括号),未成句部分仍压住;文字流 live=generating 的规则不变。已显示的语音行之后只会在同键下增长(短句与下一句合并时):LineStage.grow 原位追加新字的既有墨迹,不重建行、不重放;减少动态时直接设文本。ChatPanel 签名对语音行计入文本,全文页“还在说…”也适用于 speaking;ReadingClock 对同键增长只延长停留时间。没有新布局或样式。4) 测试:hel879_frames 的 DOCUMENTED GAP 改为拒绝断言,并新增每通话独立用例;voice.test 新增短回复/长句/文字流不变用例;新增 hel898_agent_left.test.ts(真实 livekitTransport 适配器 + 脚本化 SDK 事件名,另有控制器负例);fake-livekit.js 增 ParticipantDisconnected/leave/lateLeave/guest;hel878_mock.mjs 增 /__pid、/__reset,监听失败即退出;hel879_unified_shots.mjs 修回放基础设施(端口空闲与 pid 确认、多段录制按当前通话单调重编号并写 seq-renumbering.json、新通话重置、每主题 /__reset 隔离、负例原样发送并断言拒绝),加三项正反例和 CDP screencast→WebM 录屏", +- "deliverable": "自总管输入包 3228b1e(INPUT-MANIFEST 逐文件 SHA-256)起的前端补丁,只动 web/src/voice/session.ts、web/src/chat/ChatPanel.tsx 与测试:1) session.ts 严格解析 v2 信封(v===2、正整数 seq、kind 白名单、角色与种类对应、她的事件必带 speech_id、assistant_final 必带 turn_id、proactive_* 必带正整数 message_id;不合格整条丢弃且不推进序号);每次通话(控制器代次)独立 seq 高水位,seq<=已应用即丢;按 speech 生命周期只接受当前打开的一轮,已关闭/被取代的 speech 不再显示,旧 speech 的高 seq 事件不能关闭新轮。VoiceView 新增 her(speech/text/state open|final|done/turnId/messageId/proactive),与用户 ASR 草稿 heard 分槽。2) turn_start 开轮,assistant_interim 整句替换,assistant_final 原子替换为落库文本并请页面回读 /api/messages,turn_end 关轮(无 final 的关轮按未成为正式回复即清),turn_cancel/error(φ)/user_final/文字接管 hush/结束/掉线/卸载/重开即时清;φ 原句仍在语音行以“未记入聊天”显示。3) proactive_start 以落库原文、同源 message_id 显示在她的位置,proactive_end 保留至记录或下一轮接替,proactive_cancel 即清;不发任何请求、不标已读、不开麦、不重 TTS、不进入正式记录。4) ChatPanel:她的语音行进入与文字回复相同的舞台槽位(当前一两句、自动续读、暂停/回看、全文),生长中沿用文字流的“最新一句先压住”规则,逐分片不重启动画;落库行到来时同一 turn 同一位置接替,行键不变不重放;被撤回的语音行以 instant 方式立即离场(不淡出);减少动态设置沿用既有逻辑。你用语音开始新一轮时与发送文字一致恢复自动呈现(原先暂停状态下她的回答不会进来)。5) 测试:voice.test.ts 改为 v2 帧并新增 11 项(录制样例三场景回放、信封负例、乱序/重放/迟到/双 handle、φ、主动开口及打断、文字接管/挂断/重开、舞台行键接替);fake-livekit.js 未带信封的帧自动套 v2;hel878_mock.mjs 增 /__proactive;hel878_shots.mjs 的她的 final 与 φ 改为 v2 生命周期;新增 hel898_shots.mjs(两主题×电脑/手机)", + "acceptance": [ + "假 LiveKit 有序回放 normal-turn.jsonl:用户草稿→用户终稿→turn_start→两次增量(整句替换)→final(带 turn_id)→turn_end;她的字在舞台出现时 /__rows 尚无该回复;落库后记录中只出现一次(页面只回读,不写)", + "interrupt.jsonl 与负例:turn_cancel 后她的未完成行立即离场;被取消轮之后的高 seq 增量/final/重复 turn_start 均不显示;整段录制重放不改变界面;双 handle 时旧 speech 的 turn_end/turn_cancel 不关闭新轮;无 final 的 turn_end 视为未成为正式回复并清除", +@@ -18,7 +18,11 @@ + "proactive.jsonl:原文一字不差显示在她的位置并保留落库行身份(id/turn),可被你的语音打断(proactive_cancel 即清);全程页面无任何非 GET 请求(无回执、无已读、无消息写入),不调用 onSpoke/onRecord", + "文字接管、结束语音、掉线、新通话:她的行即时清;旧房间迟到帧被代次挡住;新通话序号从 1 重新开始并正常显示", + "两主题(月光/雾光)电脑 1440×900 与手机 390×844 截图与连续帧:沿用已认可位置与组件,无新增组件或样式;减少动态设置下她的字照常出现、无入场动画", ++ "工程:tsc 0 错;eslint 0 错 1 条既有警告(ChatPanel fullButton ref,HEL-897 起即有);vitest 8 文件 99/99;vite build 通过;浏览器 hel898 61/61,HEL-878 回归 49/49;修前对照:新 voice.test.ts 在原 session.ts/ChatPanel 上 12 项失败", ++ "f92a86f 返修-1:worker 离场(房间仍开)时通话即时结束:她的行清除,提示“她那边的语音中断了……”,麦克风轨 ended,播放容器移除,本次 session DELETE 恰好一次;普通参与者离场不挂断;旧房间迟到离场不影响新通话;用户主动挂断只有一次 DELETE、没有离场提示;DELETE 失败入欠账并重试(单测)", ++ "f92a86f 返修-2:adversarial-gates 66 次挤出后,speech_A 以新 seq 重开/增量/终稿都被拒(单测与浏览器原样探针),之后新 speech 正常进入;新通话从 seq 1 开始,不受旧通话已退休 id 影响", ++ "f92a86f 返修-3:worker-normal 回放中“在听呢,”“…那件事”未成句时舞台不显示她的半句;第三个增量成句后、final 之前即显示整句,并在播放期间保持;final 与落库行接替为同一 DOM 行(不重建、不重放);短句与下一句合并时同节点原位增长、首字墨迹不动;长句只显示已完成的行、不整段铺满;文字流仍压住最新块", ++ "f92a86f 返修工程:tsc 0;eslint 0 错 1 条既有警告;vitest 10 文件 118/118;build 通过;修前对照 vitest 7 项失败;统一实际 worker 帧浏览器回放(moon/fog × 电脑/手机 + 减少动态)修后 53/53、修前 30/53(失败都落在三项及其连带);hel898_shots 61/61、hel878_shots 49/49;8 段 WebM 录屏取自真实 screencast 帧" +- "工程:tsc 0 错;eslint 0 错 1 条既有警告(ChatPanel fullButton ref,HEL-897 起即有);vitest 8 文件 99/99;vite build 通过;浏览器 hel898 61/61,HEL-878 回归 49/49;修前对照:新 voice.test.ts 在原 session.ts/ChatPanel 上 12 项失败" + ], + "deferred": [ + "真实通话未测:真实 LiveKit/WebRTC、真实 worker 与 SDK 回调时序、真机/Safari/iOS、可信 HTTPS+WSS、R5 连续三轮/1 秒打断/停麦无残音/有限预算,均仍由 HEL-797 主卡承接;本卡全部为假 LiveKit、假音频设备与 Chromium 无头 Edge", +@@ -26,7 +30,9 @@ + "后台并行小修(建房延迟后的清理唤醒、主动 say 的 start 发得过晚、无完整 receipt 错误发 final)由原总工在 HEL-879 处理;本卡按 v2 正确语义实现,最终后台 commit 出来后需按其统一复核(特别是 proactive_start 晚于音频开始时的呈现)", + "已知边界:seq 按“一次 worker job 一个序列”实现;若同一房间内 worker 重连且序号归零,前端会按契约丢弃其帧直到超过旧高水位——需后台确认 job 与房间一一对应", + "原版 Python 需求检查(scripts/check_requirements.py --base-ref origin/main,含本交接)未在本运行时执行(无 Python、无 git),须总管对实际目标分支复跑", ++ "D1 音色、cap=0、R6/R7、整体沉浸体验由主卡与白栖知决定;工程自测不是用户体验验收", ++ "f92a86f 返修:真实 SDK 下 worker 离场事件的时序(含网络重连期间 remoteParticipants 被清空但不发离场事件)只按 vendor 源码核对,未在真实房间验证", ++ "回放基础设施把多段录制重编号(原始帧保留在 fixtures,映射见 seq-renumbering.json),证明的是页面在“同一通话单调 seq”下的呈现与门,不证明 worker 跨 job 的 seq 行为" +- "D1 音色、cap=0、R6/R7、整体沉浸体验由主卡与白栖知决定;工程自测不是用户体验验收" + ], + "requirement_changes": [], + "verification": [ +@@ -34,6 +40,8 @@ + "工具:沿用本机既有离线工具链(HEL-834 留存 node v22.11.0;HEL-897 留存 web/node_modules,package-lock 哈希与本包一致,目录联接使用);未下载、未安装", + "web:tsc --noEmit 0;eslint src 0 错 1 既有警告;vitest run 8 文件 99/99(原 88 + 新增);vite build 通过;日志随交付包", + "浏览器:node tests/hel878_mock.mjs + node tests/hel898_shots.mjs(无头 Edge、脚本化 LiveKit 替身、Chromium 假音频设备)61/61;node tests/hel878_shots.mjs 回归 49/49;截图与连续帧随交付包", ++ "无模型、嵌入、ASR、TTS 请求,无真实麦克风采集,无部署、合并、服务或绑定改动;未改后台、总装分支或 PR#11", ++ "f92a86f 返修读取:卡内三项说明、HEL-898-统一复核返修输入-f92a86f.zip(166 文件 SHA-256 全符)、web/public/vendor/livekit-client-2.22.4.umd.min.js 中 RoomEvent.ParticipantDisconnected 的定义与 emit(participant, reason)、断开清理路径不发该事件;本包缺 docs/rework/hel879/subtitle-samples,测试运行时沿用 3228b1e 输入包同名文件(不入补丁)", ++ "f92a86f 返修浏览器:node tests/hel879_unified_shots.mjs 8879 9499(自带 mock,先确认端口空闲与 pid);hel898_shots/hel878_shots 由本运行自起 mock(核对 pid 后按 PID 停止)" +- "无模型、嵌入、ASR、TTS 请求,无真实麦克风采集,无部署、合并、服务或绑定改动;未改后台、总装分支或 PR#11" + ] + } +diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx +--- a/web/src/chat/ChatPanel.tsx ++++ b/web/src/chat/ChatPanel.tsx +@@ -173,7 +173,8 @@ + // Her voice line withdrawn (cut, φ, text took over, you spoke, the call ended): it leaves at once, no fade. + const hadLine = useRef(false); + useLayoutEffect(() => { if (hadLine.current && !her) how.current = 'instant'; hadLine.current = !!herLine; }, [her, herLine]); ++ // Her spoken line can take in her next sentence under the same key, so its words count there too. ++ const signature = pages.map(p => p.message.live === 'speaking' || p.message.id.startsWith('voice-') ? `${p.key}=${p.text}` : p.key).join('|'); +- const signature = pages.map(p => p.key).join('|'); + useLayoutEffect(() => { + lines.current?.present(pages, cursor, how.current, name, !motionOff()); + how.current = 'advance'; +@@ -360,7 +361,7 @@ + + {full && fullMessage && } +-

整段完整可读。关闭后回到刚才那一句。

{fullMessage.role === 'user' ? '你' : name}

{fullMessage.text}

{fullMessage.live === 'generating' && 还在说…}{fullMessage.live === 'cancelled' && 这段回复已停止,内容未完成,未存入聊天记录。}
} + {real && permission === 'mic' && { setPermission(''); setNotice('没有开启语音,文字仍可使用。'); }} confirm="开始语音" onConfirm={() => { setPermission(''); setNotice(''); void voiceCtl.current?.start(); }}>浏览器会请求麦克风权限。你的声音只送到这台服务器上{name}的语音服务,她用同一份记忆、同一段聊天回应你;你说的话和她的回答照常存进聊天记录。直接开口就能打断她。结束语音、离开交谈或关上页面时,麦克风立即关闭。
语音通路还没有经过真机验收,效果可能不稳定。
} + {!real && permission && { setPermission(''); setDenied(true); setNotice('没有开启。真实服务尚未接通,文字仍可使用。'); }} confirm="开始模拟" onConfirm={() => { if (permission === 'mic') { setDenied(false); setVoice(true); onVoice(true); voiceController.current = new AbortController(); onPresence('在听你说(模拟)'); setNotice('模拟语音没有收音,可继续用文字输入。'); } else { if (fail) { onObservation('模拟 · 看不清,视觉服务未接通'); setNotice('模拟观察失败,文字仍可使用。'); } else setVision(true); } setPermission(''); }}>本次仅模拟界面状态,不申请麦克风或摄像头权限,不采集声音或影像,也不向外接服务发送请求。} + ; +diff --git a/web/src/chat/lineStage.ts b/web/src/chat/lineStage.ts +--- a/web/src/chat/lineStage.ts ++++ b/web/src/chat/lineStage.ts +@@ -14,6 +14,7 @@ + const el = document.createElement('div'); + el.className = 'line' + (current ? ' is-current' : ' is-prev'); + el.dataset.role = page.message.role; el.dataset.key = page.key; ++ if (page.message.live === 'speaking') el.dataset.spoken = '1'; + const who = document.createElement('span'); who.className = 'by'; + const dot = document.createElement('i'), label = document.createElement('b'), state = document.createElement('em'); + label.textContent = page.message.role === 'user' ? '你' : name; label.title = label.textContent; state.className = 'state'; +@@ -38,6 +39,15 @@ + { duration: quick ? 240 : 520, easing: EASE, fill: 'forwards' }).finished.then(() => node.remove()).catch(() => node.remove()); + } + clear() { this.stage.replaceChildren(); } ++ // Her spoken line took in the sentence she said next: the words already there stay as they are and only the new ++ // ones ink in after them (no motion: they just appear). Anything that is not a plain continuation is set at once. ++ private grow(node: HTMLElement, text: string, animate: boolean) { ++ const p = node.querySelector('p'); if (!p) return; ++ const was = p.textContent || ''; if (was === text) return; ++ if (!animate || !text.startsWith(was)) { p.className = ''; p.textContent = text; return; } ++ if (p.className !== 'ink') { p.append(text.slice(was.length)); return; } ++ graphemes(text.slice(was.length)).forEach((c, i) => { const s = document.createElement('span'); s.style.setProperty('--i', String(Math.min(i, 48))); s.textContent = c; p.append(s); }); ++ } + // Shows the current page with the one before it. Returns early when the same lines are already on screen, so + // a growing stream, a status change or a re-render never replays anything. + present(pages: Page[], cursor: number, how: PresentHow, name: string, animate: boolean) { +@@ -46,6 +56,8 @@ + const prev = pages[cursor - 1], want = [prev, page].filter((p): p is Page => !!p); + const have = [...stage.querySelectorAll('.line:not(.leaving)')], keys = have.map(n => n.dataset.key); + const inkFor = (p: Page) => p === page && animate && p.message.role === 'companion' && !this.inked.has(p.key); ++ // A line first shown while she was saying it keeps growing under its key (her final, then her stored row). ++ for (const p of want) { const n = have.find(x => x.dataset.key === p.key && x.dataset.spoken); if (n) this.grow(n, p.text, animate); } + if (how === 'instant' || !animate) { + // Keyboard review and reduced motion: show the target lines as they are — no slide, no re-played ink. + if (keys.join() === want.map(p => p.key).join() && how !== 'instant') return; +diff --git a/web/src/chat/reading.ts b/web/src/chat/reading.ts +--- a/web/src/chat/reading.ts ++++ b/web/src/chat/reading.ts +@@ -2,8 +2,8 @@ + // Reading logic carried over from the verified HEL-793/795 prototype (PR#8) and HEL-799 (approved 01a16810), moved + // onto the real message flow: replies are shown one or two lines at a time and flow on by themselves. + ++// A message as the stage sees it: stored messages plus, at most, the reply still being written, spoken or cut off. ++export interface ShownMessage extends Message { live?: 'generating' | 'speaking' | 'cancelled' } +-// A message as the stage sees it: stored messages plus, at most, the reply still being written or cut off. +-export interface ShownMessage extends Message { live?: 'generating' | 'cancelled' } + export interface Turn { key: string; items: ShownMessage[] } + export interface Page { key: string; text: string; index: number; last: boolean; mi: number; message: ShownMessage } + export interface Metrics { width: number; font: number; compact: boolean } +@@ -42,16 +42,23 @@ + return out; + } + ++// The finished sentences at the start of a line (with their closing marks); '' while the first is still forming. ++const SAID = /^[\s\S]*[。!?!?…][」』))”’"]*/u; ++export const said = (line: string) => SAID.exec(line)?.[0] ?? ''; ++ + export function pagesOf(turn: Turn | undefined, metrics: Metrics): Page[] { + const list: Page[] = []; + if (!turn) return list; + turn.items.forEach((message, mi) => { ++ const parts = chunks(message.text, metrics), shown = parts.slice(); +- const parts = chunks(message.text, metrics); + // The newest chunk of a reply still being written is held back (it may still grow or merge with the next + // sentence), so a line never changes or re-animates after it has appeared. ++ if (message.live === 'generating') shown.pop(); ++ // Her reply while she speaks it: the newest chunk shows the sentences she has already finished. It may still ++ // take the next short sentence in (it only ever grows, see LineStage.grow); the words still forming wait. ++ else if (message.live === 'speaking' && shown.length) { const done = said(shown.pop()!); if (done) shown.push(done); } +- const held = message.live === 'generating' && parts.length ? 1 : 0; + // Keys follow the turn and position, not the message id, so a streamed reply keeps its lines when it is stored. ++ shown.forEach((text, index) => list.push({ key: `${turn.key}:${mi}:${index}`, text, index, last: index === parts.length - 1, mi, message })); +- for (let index = 0; index < parts.length - held; index++) list.push({ key: `${turn.key}:${mi}:${index}`, text: parts[index], index, last: index === parts.length - 1, mi, message }); + }); + return list; + } +@@ -75,6 +82,8 @@ + const dt = this.last ? Math.min(STALL, Math.max(0, now - this.last)) : 0; this.last = now; + if (!page) return false; + if (page.key !== this.key) { this.key = page.key; this.dwell = 0; this.total = dwellFor(page); } ++ // A spoken line that took in her next sentence gets the extra time it now needs (never less). ++ else this.total = Math.max(this.total, dwellFor(page)); + if (running && this.dwell < this.total) this.dwell = Math.min(this.total, this.dwell + dt); + return running && this.dwell >= this.total; + } +diff --git a/web/src/voice/livekit.ts b/web/src/voice/livekit.ts +--- a/web/src/voice/livekit.ts ++++ b/web/src/voice/livekit.ts +@@ -20,7 +20,7 @@ + } + export interface Livekit { + Room: new (options?: Record) => LkRoom; ++ RoomEvent: Record<'TrackSubscribed' | 'TrackUnsubscribed' | 'ParticipantConnected' | 'ParticipantDisconnected' | 'ParticipantAttributesChanged' | 'DataReceived' | 'Disconnected' | 'AudioPlaybackStatusChanged', string>; +- RoomEvent: Record<'TrackSubscribed' | 'TrackUnsubscribed' | 'ParticipantConnected' | 'ParticipantAttributesChanged' | 'DataReceived' | 'Disconnected' | 'AudioPlaybackStatusChanged', string>; + Track: { Source: { Microphone: string } }; + ParticipantKind?: { AGENT?: number }; + createLocalAudioTrack(options?: MediaTrackConstraints): Promise; +@@ -57,6 +57,8 @@ + const holder = document.createElement('div'), E = lk.RoomEvent; + holder.hidden = true; holder.dataset.voice = 'her'; document.body.append(holder); + let closing = false, hushed = false; ++ // Her worker as seen in this room (by identity), so its leaving is recognised even if its attributes are gone. ++ const agents = new Set(), joined = (p: LkParticipant) => { agents.add(p.identity); on.agentJoined(); }; + const players = () => [...holder.querySelectorAll('audio,video')]; + const close = async () => { + if (closing) return; closing = true; +@@ -66,7 +68,13 @@ + }; + room.on(E.TrackSubscribed, ((track: LkTrack) => { if (closing || track.kind !== 'audio') return; const el = track.attach(); el.muted = hushed; holder.append(el); }) as (...a: never[]) => void); + room.on(E.TrackUnsubscribed, ((track: LkTrack) => { track.detach().forEach(el => el.remove()); }) as (...a: never[]) => void); ++ room.on(E.ParticipantConnected, ((p: LkParticipant) => { if (!closing && isAgent(p)) joined(p); }) as (...a: never[]) => void); ++ // Only her worker leaving ends the call (anyone else coming and going does not); our own closing says nothing. ++ room.on(E.ParticipantDisconnected, ((p: LkParticipant) => { ++ if (closing || !(agents.has(p.identity) || isAgent(p))) return; ++ agents.delete(p.identity); ++ if (![...room.remoteParticipants.values()].some(o => o.identity !== p.identity && isAgent(o))) on.agentLeft(); ++ }) as (...a: never[]) => void); +- room.on(E.ParticipantConnected, ((p: LkParticipant) => { if (!closing && isAgent(p)) on.agentJoined(); }) as (...a: never[]) => void); + room.on(E.ParticipantAttributesChanged, ((changed: Record, p: LkParticipant) => { if (!closing && AGENT_STATE in changed && isAgent(p)) on.agentState(changed[AGENT_STATE]); }) as (...a: never[]) => void); + room.on(E.DataReceived, ((payload: Uint8Array, _p: unknown, _kind: unknown, topic?: string) => { + if (closing || topic !== SUBTITLES) return; +@@ -80,7 +88,7 @@ + catch (e) { await close(); throw e; } + if (signal.aborted) { await close(); throw new DOMException('ended', 'AbortError'); } + signal.removeEventListener('abort', abort); ++ room.remoteParticipants.forEach(p => { if (!isAgent(p)) return; joined(p); const state = p.attributes?.[AGENT_STATE]; if (state) on.agentState(state); }); +- room.remoteParticipants.forEach(p => { if (!isAgent(p)) return; on.agentJoined(); const state = p.attributes?.[AGENT_STATE]; if (state) on.agentState(state); }); + void room.startAudio().catch(() => {}).then(() => { if (!closing && !room.canPlaybackAudio) on.audioBlocked(true); }); + const voiceRoom: VoiceRoom = { + async publish(mic) { const track = tracks.get(mic); if (!track) throw new Error('unknown microphone'); await room.localParticipant.publishTrack(track, { source: lk.Track.Source.Microphone }); }, +diff --git a/web/src/voice/session.ts b/web/src/voice/session.ts +--- a/web/src/voice/session.ts ++++ b/web/src/voice/session.ts +@@ -56,6 +56,8 @@ + export interface MicTrack { stop(): void; onEnded(cb: () => void): void } + export interface RoomEvents { + agentJoined(): void; ++ // Her worker left the room while it stays open (its job ended or crashed): nobody is answering any more. ++ agentLeft(): void; + // The worker's own state attribute (lk.agent.state): initializing / listening / thinking / speaking. + agentState(state: string): void; + subtitle(payload: unknown): void; +@@ -81,6 +83,7 @@ + client: '语音组件没有加载出来,文字仍可使用。', + unreachable: '通话服务暂时连不上,稍后再试试。', + noAgent: '她没有接上这次通话,麦克风已经关闭。文字仍可使用。', ++ agentLeft: '她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。', + dropped: '通话断开了,麦克风已经关闭。文字仍可使用。', + micGone: '麦克风被关掉了,语音已结束。', + offline: '网络断开了,语音已结束,麦克风已关闭。', +@@ -100,9 +103,9 @@ + } + + // seq: the highest subtitle sequence applied in this call (one worker job, one sequence). speech: her speech open now; ++// closed: speeches that ended, were cancelled or were superseded in this call; nothing for them is shown again. It is ++// kept for the whole call (no window a long call could push a retired speech out of) and goes with the call. ++interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: VoiceRoom; session?: string; agent: boolean; stated: boolean; hushed: boolean; timer?: ReturnType; seq: number; speech: string; pro: boolean; message?: number; closed: Set } +-// closed: speeches that ended, were cancelled or were superseded here; nothing for them is shown again. +-interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: VoiceRoom; session?: string; agent: boolean; stated: boolean; hushed: boolean; timer?: ReturnType; seq: number; speech: string; pro: boolean; message?: number; closed: string[] } +-const CLOSED_KEPT = 64; + + const AGENT_PHASE: Record = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' }; + +@@ -152,7 +155,7 @@ + // The generation is taken before anything is awaited, so a start that wakes up after a newer one never takes + // the call over; the call it replaces is released first, as end() would. + const prev = this.live; ++ const gen = ++this.gen, live: Live = { gen, abort: new AbortController(), agent: false, stated: false, hushed: false, seq: 0, speech: '', pro: false, closed: new Set() }; +- const gen = ++this.gen, live: Live = { gen, abort: new AbortController(), agent: false, stated: false, hushed: false, seq: 0, speech: '', pro: false, closed: [] }; + this.live = live; + this.view = { ...OFF, phase: 'opening' }; this.deps.onView(this.view); + const stale = () => gen !== this.gen; +@@ -203,6 +206,9 @@ + this.show({ phase: 'joining' }, gen); + const events: RoomEvents = { + agentJoined: () => { if (stale() || live.agent) return; live.agent = true; clearTimeout(live.timer); if (!live.stated) this.show({ phase: 'listening' }, gen); }, ++ // Her worker gone with the room still open: no one would ever answer, so the call ends honestly, exactly as a ++ // failure does (her line, microphone, her audio, room and server room all go; an unsent DELETE is owed). ++ agentLeft: () => { if (!stale() && live.agent) void fail(WORDS.agentLeft); }, + agentState: state => { + if (stale()) return; + const phase = AGENT_PHASE[state]; if (!phase) return; +@@ -267,7 +273,7 @@ + } + const pro = s.kind.startsWith('proactive_'); + if (s.kind === 'turn_start' || s.kind === 'proactive_start') { ++ if (live.closed.has(s.speech) || live.speech === s.speech) return; +- if (live.closed.includes(s.speech) || live.speech === s.speech) return; + if (live.speech) this.close(live, live.speech); + live.speech = s.speech; live.pro = pro; live.message = s.messageId; + // A reach-out arrives whole (its stored text); a reply starts empty and grows. +@@ -306,7 +312,7 @@ + } + private close(live: Live, speech: string) { + if (live.speech === speech) { live.speech = ''; live.pro = false; live.message = undefined; } ++ live.closed.add(speech); +- if (!live.closed.includes(speech)) { live.closed.push(speech); if (live.closed.length > CLOSED_KEPT) live.closed.shift(); } + } + + // You sent text while she was answering by voice: the server cancels her voice turn; keep its last audio silent. +@@ -384,8 +390,8 @@ + if (her.state !== 'open' && messages.slice(since).some(m => !own(m))) return null; + // A reach-out keeps its stored row's identity (id, turn), so the row taking over keeps the same lines on the stage. + const row = her.proactive ? [...messages, ...incoming].find(own) : undefined; ++ // Her reply while she says it: every sentence she has finished is on the stage, only the one still forming waits. ++ return { id: row?.id ?? `voice-${her.speech}`, role: 'companion', text: her.text, lifeIds: [], turnId: row ? row.turnId : her.turnId, ...(her.proactive ? { proactive: true } : {}), ...(her.state === 'open' && !her.proactive ? { live: 'speaking' as const } : {}) }; +- // Her reply still growing holds back its newest line, as a streamed text reply does. +- return { id: row?.id ?? `voice-${her.speech}`, role: 'companion', text: her.text, lifeIds: [], turnId: row ? row.turnId : her.turnId, ...(her.proactive ? { proactive: true } : {}), ...(her.state === 'open' && !her.proactive ? { live: 'generating' as const } : {}) }; + } + + // Newest rows down to `since` (exclusive), at most `pages` pages of 50. Voice turns are few per read; a longer gap is +diff --git a/web/tests/fake-livekit.js b/web/tests/fake-livekit.js +--- a/web/tests/fake-livekit.js ++++ b/web/tests/fake-livekit.js +@@ -3,7 +3,7 @@ + // createLocalAudioTrack); no WebRTC, no signalling. The microphone is a real MediaStreamTrack from the browser's fake + // device (--use-fake-device-for-media-stream), so stopping it can be observed. window.__lk drives her side. + (() => { ++ const RoomEvent = { TrackSubscribed: 'trackSubscribed', TrackUnsubscribed: 'trackUnsubscribed', ParticipantConnected: 'participantConnected', ParticipantDisconnected: 'participantDisconnected', ParticipantAttributesChanged: 'participantAttributesChanged', DataReceived: 'dataReceived', Disconnected: 'disconnected', AudioPlaybackStatusChanged: 'audioPlaybackChanged' }; +- const RoomEvent = { TrackSubscribed: 'trackSubscribed', TrackUnsubscribed: 'trackUnsubscribed', ParticipantConnected: 'participantConnected', ParticipantAttributesChanged: 'participantAttributesChanged', DataReceived: 'dataReceived', Disconnected: 'disconnected', AudioPlaybackStatusChanged: 'audioPlaybackChanged' }; + const lk = window.__lk = { rooms: [], connects: [], published: [], mics: [], connectDelay: 200 }; + class Room { + constructor() { this.handlers = {}; this.remoteParticipants = new Map(); this.canPlaybackAudio = true; this.state = 'new'; const self = this; this.localParticipant = { tracks: [], async publishTrack(t) { if (self.state !== 'connected') throw new Error('not connected'); self.localParticipant.tracks.push(t); lk.published.push(t); } }; lk.rooms.push(this); } +@@ -28,6 +28,14 @@ + lk.state = s => { const room = lk.rooms.at(-1), p = [...room.remoteParticipants.values()][0]; if (!p) return false; p.attributes['lk.agent.state'] = s; room.emit(RoomEvent.ParticipantAttributesChanged, { 'lk.agent.state': s }, p); return true; }; + // Frames without an envelope get the worker's v2 one (next seq of this room); explicit frames are sent as given. + lk.say = obj => { const room = lk.rooms.at(-1), frame = 'v' in obj ? obj : { v: 2, seq: (room.seq || 0) + 1, ...obj }; if (Number.isSafeInteger(frame.seq)) room.seq = Math.max(room.seq || 0, frame.seq); room.emit(RoomEvent.DataReceived, new TextEncoder().encode(JSON.stringify(frame)), undefined, 0, 'cg.subtitle'); return true; }; ++ // HEL-898 (f92a86f): someone leaves while the room stays connected, as the SDK reports it (removed from the map, ++ // then ParticipantDisconnected(participant, reason)). leave() is her worker; guest()/guestLeave() anyone else. ++ // `room` picks an older room (index into lk.rooms) for late events. ++ const part = (room, identity) => { const p = room.remoteParticipants.get(identity); if (!p) return false; room.remoteParticipants.delete(identity); room.emit(RoomEvent.ParticipantDisconnected, p, 1); return true; }; ++ lk.leave = (at = -1) => { const room = lk.rooms.at(at), p = [...room.remoteParticipants.values()].find(x => x.kind === 4); return p ? part(room, p.identity) : false; }; ++ lk.lateLeave = at => { const room = lk.rooms.at(at); room.emit(RoomEvent.ParticipantDisconnected, { identity: 'agent-AJ_fake', kind: 4, attributes: {} }, 1); return true; }; ++ lk.guest = () => { const room = live(), p = { identity: 'guest-1', kind: 0, attributes: {} }; room.remoteParticipants.set(p.identity, p); room.emit(RoomEvent.ParticipantConnected, p); return true; }; ++ lk.guestLeave = () => part(live(), 'guest-1'); + lk.drop = () => { const room = live(); room.state = 'gone'; room.emit(RoomEvent.Disconnected); return true; }; + lk.block = () => { const room = live(); room.canPlaybackAudio = false; room.emit(RoomEvent.AudioPlaybackStatusChanged); return true; }; + async function createLocalAudioTrack(constraints) { +diff --git a/web/tests/hel878_mock.mjs b/web/tests/hel878_mock.mjs +--- a/web/tests/hel878_mock.mjs ++++ b/web/tests/hel878_mock.mjs +@@ -18,8 +18,11 @@ + const epoch = 3, log = [], rows = [], sessions = new Map(), held = new Map(); + let voice = { configured: true, tts: true }, refuse = 0, made = 0, voiceTurn = 0, textStream = null; + const add = row => { const id = rows.length + 1; rows.push({ id, ts: T0 + id * 60e3, status: 'sent', lifeIds: [], ...row }); return id; }; ++const seed = () => { ++ add({ role: 'user', text: '下雨天的时候,你会想听什么?', turn_id: '00000000-0000-4000-8000-000000000001' }); ++ add({ role: 'companion', text: '会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累?', turn_id: '00000000-0000-4000-8000-000000000001' }); ++}; ++seed(); +-add({ role: 'user', text: '下雨天的时候,你会想听什么?', turn_id: '00000000-0000-4000-8000-000000000001' }); +-add({ role: 'companion', text: '会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累?', turn_id: '00000000-0000-4000-8000-000000000001' }); + const dto = r => ({ id: r.id, ts: r.ts, role: r.role, text: r.text, turn_id: r.turn_id, status: r.status, lifeIds: r.lifeIds, ...(r.proactive ? { proactive: true } : {}) }); + const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); }; + const sleep = ms => new Promise(r => setTimeout(r, ms)); +@@ -29,6 +32,10 @@ + const url = new URL(req.url, 'http://x'), p = url.pathname; + if (!p.startsWith('/__') && !p.startsWith('/assets/') && !p.startsWith('/vendor/')) log.push(`${req.method} ${p}${url.search}`); + if (p === '/__log') return send(res, 200, log.splice(0)); ++ // HEL-898 (f92a86f): which process answers (a driver checks it started THIS mock, not someone else's service on ++ // the port), and a clean record for an isolated scenario (same fixture turn ids replayed per theme). ++ if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel878' }); ++ if (p === '/__reset') { rows.splice(0); log.splice(0); sessions.clear(); held.clear(); voiceTurn = 0; textStream = null; seed(); return send(res, 200, { rows: rows.length }); } + if (p === '/__rows') return send(res, 200, rows.map(dto)); + if (p === '/__sessions') return send(res, 200, { open: [...sessions.keys()], made }); + if (p === '/__voice') { voice = { configured: url.searchParams.get('configured') !== '0', tts: url.searchParams.get('tts') !== '0' }; return send(res, 200, voice); } +@@ -96,4 +103,5 @@ + if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain'); + try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); } + catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); } ++}).on('error', e => { console.error(`hel878 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); }) ++ .listen(port, '127.0.0.1', () => console.log(`hel878 mock on ${port} pid ${process.pid}`)); +-}).listen(port, '127.0.0.1', () => console.log(`hel878 mock on ${port}`)); +diff --git a/web/tests/hel879_frames.test.ts b/web/tests/hel879_frames.test.ts +--- a/web/tests/hel879_frames.test.ts ++++ b/web/tests/hel879_frames.test.ts +@@ -164,22 +164,41 @@ + expect(r.view().her).toMatchObject({ speech: 'speech_A', state: 'done', turnId: 'gate-turn-1' }); + }); + ++ it('a retired speech stays retired for the whole call, however many speeches closed after it (was: re-entered after 64)', async () => { +- it('DOCUMENTED GAP: after 64+ later closures a retired speech falls out of the window and a duplicate start with a fresh seq re-enters', async () => { + const r = await connected(), room = r.transport.rooms[0]; + const frames = load('adversarial-gates'); ++ // Up to the end of the 66 squeeze speeches: speech_A is long closed; nothing of it is on the page. +- // Up to the end of the 66 squeeze speeches: speech_A is long closed (and by now pushed +- // out of the 64-entry window); nothing of it is on the page. + const dupStart = frames.filter(f => f.kind === 'turn_start' && f.speech_id === 'speech_A').at(-1)!; + play(room, frames.filter(f => f.seq < dupStart.seq)); + expect(r.view().her).toBeNull(); + expect(dupStart.seq).toBeGreaterThan(140); ++ // The current worker never re-emits a speech id (each SDK speech gets a fresh id and interim.begin runs once ++ // per stream), so this needs a misbehaving/second source — a low-cost guard, not a fix of a worker fault. ++ // Its fresh seqs pass the sequence gate; the retired speech id does not. ++ const views = r.views.length; +- // The current worker never re-emits a speech id (each SDK speech gets a fresh id and +- // interim.begin runs once per stream), so this needs a misbehaving/second source — +- // but if it happens, the page DOES re-open the long-retired speech... + play(room, frames.filter(f => f.seq >= dupStart.seq)); ++ expect(r.view().her).toBeNull(); ++ expect(r.views.slice(views).some(v => v.her || v.phase !== 'listening')).toBe(false); ++ // The seq gate is still strict on its own: nothing at or below the high-water mark gets in. ++ const high = frames.at(-1)!.seq; ++ room.on.subtitle({ v: 2, seq: high, kind: 'turn_start', role: 'assistant', text: '', speech_id: 'speech_new' }); ++ expect(r.view().her).toBeNull(); ++ room.on.subtitle({ v: 2, seq: high + 1, kind: 'turn_start', role: 'assistant', text: '', speech_id: 'speech_new' }); ++ expect(r.view().her).toEqual({ speech: 'speech_new', text: '', state: 'open' }); ++ }); ++ it('the retired set is per call: a new call starts clean, and an old call\'s retired ids never leak or block', async () => { ++ const r = await connected(), room = r.transport.rooms[0]; ++ play(room, load('adversarial-gates')); ++ await r.c.end(); ++ await r.c.start(); const next = r.transport.rooms[1]; next.on.agentJoined(); ++ // A new worker job: its own sequence from 1. A speech id the old call retired is not this call's business. ++ next.on.subtitle({ v: 2, seq: 1, kind: 'turn_start', role: 'assistant', text: '', speech_id: 'speech_A' }); ++ next.on.subtitle({ v: 2, seq: 2, kind: 'assistant_interim', role: 'assistant', text: '新的一通。', speech_id: 'speech_A' }); ++ expect(r.view().her).toEqual({ speech: 'speech_A', text: '新的一通。', state: 'open' }); ++ // The old room, however late, moves nothing. ++ room.on.subtitle({ v: 2, seq: 9999, kind: 'turn_cancel', role: 'assistant', text: '', speech_id: 'speech_A' }); ++ expect(r.view().her?.state).toBe('open'); ++ await r.c.end(); +- expect(r.view().her).toMatchObject({ speech: 'speech_A', state: 'done', turnId: 'gate-turn-1' }); +- // ...whose replayed line stands as her words again. +- expect(r.view().her?.text).toBe('被挤出后的重放终稿'); + }); + + it('your interim leaves her open line to the server\'s cancel; your final closes it at once', async () => { +diff --git a/web/tests/hel879_unified_shots.mjs b/web/tests/hel879_unified_shots.mjs +--- a/web/tests/hel879_unified_shots.mjs ++++ b/web/tests/hel879_unified_shots.mjs +@@ -2,23 +2,47 @@ + // the .12 real SDK by scripts/gen_hel879_unified_frames.py) replayed through the current page. + // Headless Chromium over CDP against tests/hel878_mock.mjs + tests/fake-livekit.js (no WebRTC, no + // model, no TTS; fake audio device as the microphone). Everything on the subtitle side is the ++// worker's own output — turn ids, speech ids, kinds, texts; the mock's record rows are keyed to the +-// worker's own output — turn ids, speech ids, seqs, kinds; the mock's record rows are keyed to the + // fixture turn ids so the stage takeover is exercised end to end. Usage: ++// node tests/hel879_unified_shots.mjs [port] [cdpPort] ++// ++// Replay rules (HEL-898 f92a86f rework of this driver; the page is not changed by them): ++// - One call is one worker job is one strictly rising seq. The fixtures were recorded by separate worker jobs, each ++// from seq 1; replayed into one call they are RENUMBERED onto that call's sequence, in order. The original frames ++// are untouched in the fixtures; every renumbering is written to /seq-renumbering.json. ++// - A new call (a new room) starts the sequence again at 1. ++// - Negatives (replays, late and out-of-order frames, retired speeches) are sent RAW, exactly as written, and each is ++// asserted to be refused by the page itself — the driver never repairs a bad frame and then claims the gate held. ++// - Each theme/viewport block starts from a reset record (/__reset), so the same fixture turn ids replayed per theme ++// never pile up in one record. ++// - The mock and CDP ports must be free before anything starts, and the mock must answer /__pid with the pid this ++// driver spawned: it never drives somebody else's service. +-// node tests/hel879_unified_shots.mjs [port] + import { spawn } from 'node:child_process'; + import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { tmpdir } from 'node:os'; ++import { createConnection } from 'node:net'; ++import { fileURLToPath } from 'node:url'; + ++const [chrome, out, portArg, cdpArg] = process.argv.slice(2); ++const port = Number(portArg || 8879), base = `http://127.0.0.1:${port}`, cdpPort = Number(cdpArg || 9499); +-const [chrome, out, portArg] = process.argv.slice(2); +-const port = Number(portArg || 8879), base = `http://127.0.0.1:${port}`, cdpPort = 9499; + mkdirSync(out, { recursive: true }); ++const sleep = ms => new Promise(r => setTimeout(r, ms)); ++const busy = p => new Promise(ok => { const s = createConnection({ port: p, host: '127.0.0.1' }); s.once('connect', () => { s.destroy(); ok(true); }); s.once('error', () => ok(false)); }); ++for (const [what, p] of [['mock', port], ['CDP', cdpPort]]) if (await busy(p)) { console.error(`port ${p} (${what}) is already in use: refusing to start over someone else's service`); process.exit(3); } + // The mock rides along (child of this run, killed at the end): one self-contained command. ++const mock = spawn(process.execPath, [fileURLToPath(new URL('./hel878_mock.mjs', import.meta.url)), String(port)], { stdio: ['ignore', 'pipe', 'pipe'] }); ++const listening = await new Promise(ok => { ++ let said = ''; const t = setTimeout(() => ok(false), 8000); ++ mock.stdout.on('data', d => { said += d; if (said.includes(`hel878 mock on ${port} pid ${mock.pid}`)) { clearTimeout(t); ok(true); } }); ++ mock.stderr.on('data', d => process.stderr.write(d)); ++ mock.once('exit', () => { clearTimeout(t); ok(false); }); ++}); ++const who = listening ? await fetch(`${base}/__pid`).then(r => r.json(), () => null) : null; ++if (!listening || who?.pid !== mock.pid) { console.error(`mock did not come up as pid ${mock.pid} on ${port} (answered: ${JSON.stringify(who)})`); mock.kill(); process.exit(3); } ++console.log(`mock pid ${mock.pid} listening on ${port}; CDP on ${cdpPort}`); +-const mock = spawn(process.execPath, [new URL('./hel878_mock.mjs', import.meta.url).pathname, String(port)], { stdio: 'ignore' }); +-await new Promise((ok, ko) => { const t = setTimeout(() => ok(undefined), 3000); mock.once('spawn', () => setTimeout(ok, 400)); }); + const profile = join(tmpdir(), `hel879-unified-${Date.now()}`); + const browser = spawn(chrome, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' }); +-const sleep = ms => new Promise(r => setTimeout(r, ms)); + let targets; + for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } } + const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl); +@@ -28,164 +52,306 @@ + const cdp = (method, params = {}) => new Promise((ok, ko) => { const n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, method, params })); }); + const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; }; + const results = []; ++const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${String(detail).replace(/\n/g, ' / ').slice(0, 220)}` : ''}`); }; +-const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); }; + const ctl = async path => (await fetch(`${base}${path}`)).json(); + const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); }; +-const frames = async (name, ms) => { const list = []; const end = Date.now() + ms; while (Date.now() < end) { const { data } = await cdp('Page.captureScreenshot', { format: 'jpeg', quality: 70 }); list.push(data); } list.forEach((d, i) => writeFileSync(join(out, `${name}-${String(i).padStart(2, '0')}.jpg`), Buffer.from(d, 'base64'))); return list.length; }; + const click = async (selector, text = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(text)})); if (!el) return false; el.click(); return true; })()`); + const type = t => js(`(() => { const el = document.querySelector('textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(el, ${JSON.stringify(t)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`); + const until = async (fn, ms = 4000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; }; + const stage = () => js(`document.querySelector('.conversation .stage')?.innerText || ''`); ++const status = () => js(`document.querySelector('.talk-status')?.innerText || ''`); +-const said = () => js(`document.querySelector('.conversation .sr')?.textContent || ''`); + const row = () => js(`document.querySelector('.voice-row.real')?.innerText || ''`); + const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`); + const lk = expr => js(`window.__lk ? (${expr}) : null`); +-const say = frame => lk(`__lk.say(${JSON.stringify(frame)})`); + const state = s => lk(`__lk.state(${JSON.stringify(s)})`); + const exists = sel => js(`!!document.querySelector(${JSON.stringify(sel)})`); + const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } }; + async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); } ++const reduce = on => cdp('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: on ? 'reduce' : 'no-preference' }] }); + async function boot(theme) { ++ await ctl('/__reset'); + await cdp('Page.navigate', { url: `${base}/` }); await sleep(500); ++ await js(`localStorage.clear(); localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`); +- await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`); + await cdp('Page.navigate', { url: `${base}/` }); await sleep(1600); + } ++ ++// ── Sequence of the call now open (see the replay rules above). ── ++const call = { id: 0, n: 0 }, renumbered = []; + async function startVoice() { + await click('.voice-button'); await until(() => exists('.modal.open')); + await click('.modal.open .btn-primary', '开始语音'); + await until(async () => (await phase()) === 'joining'); + await until(async () => (await lk('__lk.rooms.at(-1)?.state')) === 'connected'); + await lk('__lk.join()'); await sleep(250); ++ call.id++; call.n = 0; + } ++const send = async (f, from) => { const sent = { ...f, seq: ++call.n }; renumbered.push({ call: call.id, from, orig_seq: f.seq, seq: sent.seq, kind: f.kind, speech_id: f.speech_id }); await lk(`__lk.say(${JSON.stringify(sent)})`); return sent; }; ++const sendAll = async (frames, from) => { for (const f of frames) await send(f, from); }; ++// Negatives: exactly as written. Returns nothing the page could use to "pass" — the page must refuse them itself. ++const raw = async f => { await lk(`__lk.say(${JSON.stringify(f)})`); call.n = Math.max(call.n, f.seq); }; ++const fresh = (kind, speech, extra = {}) => ({ v: 2, seq: 0, kind, role: 'assistant', text: '', speech_id: speech, ...extra }); ++ + // The actual worker fixtures + the sidecar that carries their turn ids / receipts. + const load = name => readFileSync(new URL(`./fixtures/hel879/${name}.jsonl`, import.meta.url), 'utf8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l)); + const sidecar = JSON.parse(readFileSync(new URL('./fixtures/hel879/sidecar.json', import.meta.url), 'utf8')); + const of = (frames, kind, nth = 0) => frames.filter(f => f.kind === kind)[nth]; +-const sayAll = async frames => { for (const f of frames) await say(f); }; + const calls = async () => ctl('/__log'); + const rows = async () => ctl('/__rows'); ++const herLine = `[...document.querySelectorAll('.conversation .stage .line[data-role=companion]:not(.leaving)')].at(-1)`; ++ ++// ── Real screencast → WebM. No ffmpeg here: the frames Chromium itself painted are re-played at their own timestamps ++// into the browser's MediaRecorder at the end of the run. Every frame is also kept as a JPEG with its timestamp. ── ++const recordings = []; ++async function record(name, fn) { ++ const got = []; ++ const onFrame = ({ data }) => { const m = JSON.parse(data); if (m.method !== 'Page.screencastFrame') return; got.push({ d: m.params.data, t: m.params.metadata.timestamp }); ws.send(JSON.stringify({ id: ++id, method: 'Page.screencastFrameAck', params: { sessionId: m.params.sessionId } })); }; ++ ws.addEventListener('message', onFrame); ++ await cdp('Page.startScreencast', { format: 'jpeg', quality: 80, everyNthFrame: 1 }); ++ try { await fn(); await sleep(600); } finally { await cdp('Page.stopScreencast'); ws.removeEventListener('message', onFrame); } ++ const dir = join(out, `${name}-frames`); mkdirSync(dir, { recursive: true }); ++ got.forEach((f, i) => writeFileSync(join(dir, `${String(i).padStart(3, '0')}.jpg`), Buffer.from(f.d, 'base64'))); ++ writeFileSync(join(dir, 'timing.json'), JSON.stringify(got.map((f, i) => ({ i, t: f.t })))); ++ recordings.push({ name, frames: got }); ++} ++async function encodeAll() { ++ await cdp('Page.navigate', { url: 'about:blank' }); await sleep(300); ++ for (const { name, frames: list } of recordings) { ++ if (list.length < 2) { console.log(`video ${name}: only ${list.length} frame(s), not encoded`); continue; } ++ await js(`window.__f = []; true`); ++ for (let i = 0; i < list.length; i += 6) await js(`window.__f.push(...${JSON.stringify(list.slice(i, i + 6))}); true`); ++ const b64 = await js(`(async () => { ++ const f = window.__f, imgs = await Promise.all(f.map(x => new Promise(ok => { const i = new Image(); i.onload = () => ok(i); i.src = 'data:image/jpeg;base64,' + x.d; }))); ++ const c = document.createElement('canvas'); c.width = imgs[0].naturalWidth; c.height = imgs[0].naturalHeight; document.body.append(c); ++ const g = c.getContext('2d'), rec = new MediaRecorder(c.captureStream(30), { mimeType: 'video/webm;codecs=vp8', videoBitsPerSecond: 5e6 }), parts = []; ++ rec.ondataavailable = e => parts.push(e.data); g.drawImage(imgs[0], 0, 0); rec.start(200); ++ for (let k = 0; k < imgs.length; k++) { g.drawImage(imgs[k], 0, 0); const gap = k + 1 < f.length ? (f[k + 1].t - f[k].t) * 1000 : 1000; await new Promise(r => setTimeout(r, Math.min(4000, Math.max(16, gap)))); } ++ rec.stop(); await new Promise(r => { rec.onstop = r; }); ++ const bytes = new Uint8Array(await new Blob(parts, { type: 'video/webm' }).arrayBuffer()); let s = ''; ++ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); ++ c.remove(); return btoa(s); ++ })()`); ++ writeFileSync(join(out, `${name}.webm`), Buffer.from(b64, 'base64')); ++ console.log(`video ${name}.webm: ${list.length} real frames, ${((list.at(-1).t - list[0].t)).toFixed(1)} s`); ++ } ++} + await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); + + // ── The full run on moon/desktop: every worker scenario through the real page. ── ++await view('desktop'); await reduce(false); await boot('moon'); await click('.nav-item', '交谈'); await sleep(1400); +-await view('desktop'); await boot('moon'); await click('.nav-item', '交谈'); await sleep(1400); + await startVoice(); await calls(); + ++// 1. Normal turn (worker-normal.jsonl). Fix 3: her short reply is on the stage while she says it, not after the final. +-// 1. Normal turn (worker-normal.jsonl): her words grow on the stage before anything is stored. + const normal = load('worker-normal'), turn = sidecar.scenarios.normal.turn_id; ++const interims = normal.filter(f => f.kind === 'assistant_interim'); ++let beforeSentence = '', afterSentence = '', heldOn = '', storedThen = -1; ++await record('v1-moon-desktop-short-reply-while-speaking', async () => { ++ await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(turn)}&text=${encodeURIComponent('你在听吗')}`); ++ await sendAll(normal.filter(f => f.seq <= 2), 'worker-normal'); await state('thinking'); await sleep(500); ++ await send(of(normal, 'turn_start'), 'worker-normal'); await state('speaking'); ++ await send(interims[0], 'worker-normal'); await sleep(900); ++ await send(interims[1], 'worker-normal'); await sleep(1600); ++ beforeSentence = await stage(); ++ await send(interims[2], 'worker-normal'); ++ await until(async () => (await stage()).includes('我还在想'), 6000); ++ afterSentence = await stage(); storedThen = (await rows()).filter(r => r.turn_id === turn && r.role === 'companion').length; ++ await js(`window.__line = ${herLine}; true`); ++ // She is still speaking it (the final comes only after playout): it stays there the whole time. ++ await sleep(2600); heldOn = await stage(); ++ await shot('w1-her-words-growing'); ++ await ctl(`/__voice-turn?stage=reply&turn=${encodeURIComponent(turn)}&text=${encodeURIComponent('在听呢,你刚才说到一半的那件事我还在想。')}`); ++ await sendAll(normal.filter(f => f.kind === 'assistant_final' || f.kind === 'turn_end'), 'worker-normal'); await state('listening'); await sleep(1200); ++}); ++check('fix 3: while the sentence is still forming, her half-said words wait (one or two whole sentences, as before)', !beforeSentence.includes('你刚才说到一半'), beforeSentence); ++check('fix 3: her short reply is on the stage while she speaks it, before anything is stored', afterSentence.includes('在听呢,你刚才说到一半的那件事我还在想。') && storedThen === 0, `${afterSentence} (stored rows of hers then: ${storedThen})`); ++check('fix 3: it stays through the playout (seconds before the final)', heldOn.includes('我还在想'), heldOn); ++check('fix 3: her final and her stored row take over the very same line (no re-made line, no replayed ink)', await js(`window.__line === ${herLine} && window.__line.isConnected`), await stage()); +-await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(turn)}&text=${encodeURIComponent('你在听吗')}`); +-await sayAll(normal.filter(f => f.seq <= 2)); await state('thinking'); +-for (const f of normal.filter(f => f.kind === 'turn_start' || f.kind === 'assistant_interim')) { +- await say(f); await state('speaking'); +- if (f.text.length > 8) { await until(async () => (await stage()).includes(f.text.slice(0, 6)), 6000); await frames(`w-normal-grow-${f.seq}`, 350); } +-} +-check('worker frames: her reply grows on the stage while she speaks, before it is stored', (await stage()).includes('在听呢') && (await rows()).every(r => r.role !== 'companion' || r.turn_id !== turn), (await stage())); + check('worker frames: your final is your line, her words never in the voice line', !(await row()).includes('在听呢'), await row()); +-await shot('w1-her-words-growing'); +-// The stored row takes over: same turn id, exactly once, stage keeps its place. +-await ctl(`/__voice-turn?stage=reply&turn=${encodeURIComponent(turn)}&text=${encodeURIComponent('在听呢,你刚才说到一半的那件事我还在想。')}`); +-await sayAll(normal.filter(f => f.kind === 'assistant_final' || f.kind === 'turn_end')); await state('listening'); await sleep(900); + await click('.log-btn'); await sleep(700); + const record1 = await js(`document.querySelector('.log-panel .scroll')?.innerText || ''`); ++check('worker frames: the stored row (same turn id) takes over exactly once — re-read, never written by the page', record1.split('在听呢,你刚才说到一半的那件事我还在想。').length - 1 === 1, record1.slice(-140)); +-check('worker frames: the stored row (same turn id) takes over exactly once — re-read, never written by the page', record1.split('在听呢,你刚才说到一半的那件事我还在想。').length - 1 === 1, record1.slice(-140).replace(/\n/g, ' ')); + await shot('w2-record-after-final'); ++await click('.log-panel .btn-round'); await sleep(400); ++ ++// 1b. Fix 3, a short sentence the next one joins: the line grows in place; the words already there are not redone. ++await send(fresh('turn_start', 'speech_grow'), 'driver'); await state('speaking'); ++await send(fresh('assistant_interim', 'speech_grow', { text: '嗯。' }), 'driver'); ++await until(async () => (await stage()).includes('嗯。'), 6000); ++await js(`window.__line = ${herLine}; window.__first = window.__line.querySelector('p').firstChild; true`); ++await send(fresh('assistant_interim', 'speech_grow', { text: '嗯。我在这儿' }), 'driver'); await sleep(500); ++const growHeld = await js(`${herLine}.innerText`); ++await send(fresh('assistant_interim', 'speech_grow', { text: '嗯。我在这儿,一直都在。' }), 'driver'); await sleep(900); ++check('fix 3: the next sentence joins the same line in place (same node, first ink untouched; the unfinished part waited)', await js(`window.__line === ${herLine} && window.__line.querySelector('p').firstChild === window.__first && window.__line.innerText.includes('嗯。我在这儿,一直都在。')`) && !growHeld.includes('我在这儿'), await js(`${herLine}.innerText`)); ++await send(fresh('turn_cancel', 'speech_grow', { reason: 'interrupted' }), 'driver'); await state('listening'); await sleep(400); ++ ++// 1c. Fix 3 negative: a long sentence still reads a line at a time; the part still being said waits. ++const long = '我下午去了河边,看到一群白鹭从水面上飞过去了,它们飞得很低,几乎贴着水,翅膀尖在水面上划出一道一道细细的白线,后来又绕回来落在对岸的芦苇里'; ++await send(fresh('turn_start', 'speech_long'), 'driver'); await state('speaking'); ++await send(fresh('assistant_interim', 'speech_long', { text: long }), 'driver'); ++await until(async () => (await stage()).includes('白鹭'), 8000); ++const longStage = await stage(); ++check('fix 3 negative: a long sentence shows its finished lines only, never the whole text at once', longStage.includes('白鹭') && !longStage.includes('芦苇里'), longStage); ++await send(fresh('turn_cancel', 'speech_long', { reason: 'interrupted' }), 'driver'); await state('listening'); await sleep(400); ++check('a cancelled reply leaves at once', !(await stage()).includes('白鹭'), await stage()); ++await click('.read-all'); await sleep(400); await click('.record.full .btn-round'); await sleep(300); +-await click('.log-panel .btn-round'); await sleep(400); await click('.read-all'); await sleep(400); await click('.record.full .btn-round'); await sleep(300); + + // 2. Barge-in (worker-interrupt.jsonl): the cut turn never gets a final; the next turn lands clean. + const it = load('worker-interrupt'), cutTurn = sidecar.scenarios.interrupt.turn_ids.cut, nextTurn = sidecar.scenarios.interrupt.turn_ids.next; + await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(cutTurn)}&text=${encodeURIComponent('讲讲你下午做了什么')}`); ++await sendAll(it.slice(0, 4), 'worker-interrupt'); await state('speaking'); await sleep(1500); +-await sayAll(it.slice(0, 4)); await state('speaking'); +-await until(async () => (await stage()).includes('白鹭'), 6000); + await shot('w3-before-cut'); +-await sayAll(it.slice(4, 5)); await state('listening'); await ctl(`/__voice-turn?stage=cancel&turn=${encodeURIComponent(cutTurn)}`); await sleep(250); +-check('worker frames: interrupted — her unfinished line is gone at once, no final exists for the cut turn', !(await stage()).includes('白鹭') && it.filter(f => f.kind === 'assistant_final' && f.speech_id === of(it, 'turn_cancel').speech_id).length === 0, await stage()); +-// Late frames of the cut speech (fresh seqs, dead speech) change nothing. + const cutSpeech = of(it, 'turn_cancel').speech_id; ++const sentInterim = renumbered.filter(x => x.from === 'worker-interrupt' && x.kind === 'assistant_interim').at(-1); ++await send(it[4], 'worker-interrupt'); await state('listening'); await ctl(`/__voice-turn?stage=cancel&turn=${encodeURIComponent(cutTurn)}`); await sleep(250); ++check('worker frames: interrupted — her unfinished line is gone at once, no final exists for the cut turn', !(await stage()).includes('白鹭') && it.filter(f => f.kind === 'assistant_final' && f.speech_id === cutSpeech).length === 0, await stage()); ++// Negatives, RAW. (a) The cut speech's late frames with seqs ABOVE the high-water mark: only the speech gate stops them. ++const high = call.n; ++await raw({ v: 2, seq: high + 1, kind: 'assistant_interim', role: 'assistant', speech_id: cutSpeech, text: '白鹭又飞回来了。' }); ++await raw({ v: 2, seq: high + 2, kind: 'assistant_final', role: 'assistant', speech_id: cutSpeech, turn_id: 'late', text: '白鹭又飞回来了。' }); ++await sleep(400); ++check('negative (raw): late frames of the cut speech, fresh seqs — refused by the speech gate', !(await stage()).includes('飞回来'), await stage()); ++// (b) A replay of a frame already applied (same seq) and an out-of-order lower seq for a new speech: the seq gate. ++await raw({ v: 2, seq: sentInterim.seq, kind: 'assistant_interim', role: 'assistant', speech_id: cutSpeech, text: '我下午去了河边。' }); ++await raw({ v: 2, seq: high, kind: 'turn_start', role: 'assistant', speech_id: 'speech_late', text: '' }); ++await raw({ v: 2, seq: high - 1, kind: 'assistant_interim', role: 'assistant', speech_id: 'speech_late', text: '乱序的旧话。' }); ++await sleep(400); ++check('negative (raw): a replayed seq and out-of-order lower seqs — refused by the seq gate', !(await stage()).includes('乱序的旧话') && !(await stage()).includes('去了河边'), await stage()); ++// The next clean turn of the same recording lands with its own receipt (renumbered past everything above). +-await say({ v: 2, seq: 90, kind: 'assistant_interim', role: 'assistant', speech_id: cutSpeech, text: '白鹭又飞回来了' }); +-await say({ v: 2, seq: 91, kind: 'assistant_final', role: 'assistant', speech_id: cutSpeech, turn_id: 'late', text: '白鹭又飞回来了。' }); +-await sleep(350); +-check('worker frames: late frames of the cut speech stay out', !(await stage()).includes('飞回来')); +-// The next clean turn of the same recording lands with its own receipt. + await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(nextTurn)}&text=${encodeURIComponent('那后来呢')}`); + await ctl(`/__voice-turn?stage=reply&turn=${encodeURIComponent(nextTurn)}&text=${encodeURIComponent('后来我在桥上站了一会儿,风把水面吹皱了。')}`); ++await sendAll(it.slice(5), 'worker-interrupt'); await state('speaking'); await until(async () => (await stage()).includes('桥上'), 6000); await state('listening'); await sleep(700); +-await sayAll(it.slice(5)); await state('speaking'); await until(async () => (await stage()).includes('桥上'), 6000); await state('listening'); await sleep(700); + await click('.log-btn'); await sleep(600); + const record2 = await js(`document.querySelector('.log-panel .scroll')?.innerText || ''`); ++check('worker frames: the next turn lands clean (one stored row of its own)', record2.split('风把水面吹皱了').length - 1 === 1, record2.slice(-120)); +-check('worker frames: the next turn lands clean (one stored row of its own)', record2.includes('风把水面吹皱了') && record2.split('风把水面吹皱了').length - 1 === 1, record2.slice(-120).replace(/\n/g, ' ')); + await click('.log-panel .btn-round'); await sleep(300); + + // 3. phi (worker-phi.jsonl): her failed line is heard, not stored. + const phi = load('worker-phi'); + await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(sidecar.scenarios.phi.turn_id)}&text=${encodeURIComponent('说说那件事吧')}`); ++await sendAll(phi, 'worker-phi'); await state('speaking'); await sleep(300); await state('listening'); await sleep(300); +-await sayAll(phi); await state('speaking'); await sleep(300); await state('listening'); await sleep(300); + check('worker frames: phi is heard in the voice line, not stored as her reply', (await row()).includes('说不出来话') && !(await stage()).includes('说不出来话'), await row()); + await shot('w4-phi-heard'); + await sleep(600); + ++// 4. No legal receipt (worker-no_receipt.jsonl): cancel only, nothing of hers stays. +-// 4. No legal receipt (worker-no_receipt.jsonl): cancel only, nothing on the stage. + const nr = load('worker-no_receipt'); + await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(sidecar.scenarios.no_receipt.turn_id)}&text=${encodeURIComponent('再讲讲那座桥')}`); ++await sendAll(nr.filter(f => f.kind !== 'turn_cancel'), 'worker-no_receipt'); await state('speaking'); await sleep(1500); ++await send(of(nr, 'turn_cancel'), 'worker-no_receipt'); await state('listening'); await sleep(300); ++check('worker frames: no legal receipt — nothing of that reply stays (cancel only, never a final)', !(await stage()).includes('水声很好听') && nr.every(f => f.kind !== 'assistant_final'), await stage()); +-await sayAll(nr.filter(f => f.kind !== 'turn_cancel')); await state('speaking'); +-await until(async () => (await stage()).includes('水声很好听'), 6000); +-await say(of(nr, 'turn_cancel')); await state('listening'); await sleep(300); +-check('worker frames: no legal receipt — the page never shows a final (cancel only)', !(await stage()).includes('水声很好听') && nr.every(f => f.kind !== 'assistant_final'), await stage()); + + // 5. Proactive (worker-proactive.jsonl): start DURING playout, end/cancel with the row's own id. + await ctl(`/__proactive?id=301&text=${encodeURIComponent('刚才路过花店,想起你说过喜欢白色的洋桔梗。')}`); await calls(); + const pro = load('worker-proactive'); ++await send(of(pro.filter(f => f.message_id === 301), 'proactive_start'), 'worker-proactive'); await state('speaking'); await sleep(600); +-await say(of(pro.filter(f => f.message_id === 301), 'proactive_start')); await state('speaking'); await sleep(600); + check('worker frames: reach-out read aloud is shown word for word while she speaks it', (await stage()).includes('洋桔梗'), await stage()); + await shot('w5-reach-out'); ++await send(of(pro.filter(f => f.message_id === 301), 'proactive_end'), 'worker-proactive'); await state('listening'); await sleep(400); ++await calls(); ++await send(pro.filter(f => f.message_id === 302)[0], 'worker-proactive'); await state('speaking'); await sleep(250); ++await send(pro.filter(f => f.message_id === 302)[1], 'worker-proactive'); await state('listening'); await sleep(250); +-await say(of(pro.filter(f => f.message_id === 301), 'proactive_end')); await state('listening'); await sleep(400); +-// The interrupted variant clears without a trace and the page wrote nothing. +-const before = await calls(); +-await say(pro.filter(f => f.message_id === 302)[0]); await state('speaking'); await sleep(250); +-await say(pro.filter(f => f.message_id === 302)[1]); await state('listening'); await sleep(250); + const after = await calls(); + check('worker frames: an interrupted reach-out clears; the page still wrote nothing (no receipt, no read mark)', !after.some(l => !l.startsWith('GET ')), after.join(' | ')); + + // 6. Text takes over while she speaks (the worker's own next turn): typed words come next. ++await send({ ...normal[2], speech_id: 'speech_text_takeover' }, 'worker-normal (speech id renamed)'); ++await send({ ...interims[2], speech_id: 'speech_text_takeover' }, 'worker-normal (speech id renamed)'); await state('speaking'); await sleep(200); +-const nt = load('worker-normal'), nt2 = { ...normal[2], seq: 100, speech_id: 'speech_text_takeover' }; +-await say(nt2); await state('speaking'); await sleep(200); + await type('先等等,我打字跟你说'); await js(`document.querySelector('.send').click(), true`); await sleep(400); ++check('text over voice: her worker line is gone at once, your typed words are on the stage', !(await stage()).includes('我还在想') && (await stage()).includes('先等等'), await stage()); +-check('text over voice: her worker line is gone at once, your typed words are on the stage', !(await stage()).includes('在听呢,') && (await stage()).includes('先等等'), await stage()); + await until(async () => !(await exists('.reader-bar .stop')), 9000); await state('listening'); await sleep(400); + ++// 7. Fix 2 in the page: the hand-built gate probes (66 later speeches, then speech_A again with fresh seqs), RAW, ++// in a call of their own (their seqs are their own, from 1). ++await click('.voice-row.real .voice-end'); await sleep(400); ++await startVoice(); ++for (const f of load('adversarial-gates')) await raw(f); ++// Long enough for the reading flow to reach the newest line of this turn (the lines before it take their time). ++const back = await until(async () => /被挤出后的重放|迟到的旧稿/u.test(await stage()), 12000); ++check('fix 2 (raw probes): a long-retired speech re-sent with fresh seqs never comes back (watched 12 s)', !back, await stage()); ++await raw({ v: 2, seq: call.n + 1, kind: 'turn_start', role: 'assistant', speech_id: 'speech_after_probe', text: '' }); ++await raw({ v: 2, seq: call.n + 1, kind: 'assistant_interim', role: 'assistant', speech_id: 'speech_after_probe', text: '还在呢。' }); await state('speaking'); ++check('fix 2: a new speech after the probes still comes in', await until(async () => (await stage()).includes('还在呢'), 15000), await stage()); ++await state('listening'); ++ ++// 8. The room drops mid-sentence: everything clears with the call; starting again works. ++await send(fresh('turn_start', 'speech_drop'), 'driver'); ++await send(fresh('assistant_interim', 'speech_drop', { text: '晚安,明天见。' }), 'driver'); await state('speaking'); +-// 7. The room drops mid-sentence: everything clears with the call; starting again works. +-await say({ v: 2, seq: 120, kind: 'turn_start', role: 'assistant', speech_id: 'speech_drop', text: '' }); +-await say({ v: 2, seq: 121, kind: 'assistant_interim', role: 'assistant', speech_id: 'speech_drop', text: '晚安,明天见。' }); await state('speaking'); + await until(async () => (await stage()).includes('晚安'), 6000); + await lk('__lk.drop()'); await sleep(400); + check('dropped mid-sentence: her line and the call clear together', !(await stage()).includes('晚安') && (await phase()) === 'off', await stage()); + await shot('w6-after-drop'); + await startVoice(); await calls(); ++await send(fresh('turn_start', 'speech_fresh'), 'driver'); await send(fresh('assistant_interim', 'speech_fresh', { text: '又见面了。' }), 'driver'); await state('speaking'); +-const fresh = { v: 2, seq: 1, kind: 'turn_start', role: 'assistant', speech_id: 'speech_fresh', text: '' }; +-const freshText = { v: 2, seq: 2, kind: 'assistant_interim', role: 'assistant', speech_id: 'speech_fresh', text: '又见面了。' }; +-await say(fresh); await say(freshText); await state('speaking'); + await until(async () => (await stage()).includes('又见面了'), 6000); ++check('a new call is a new room and a new sequence (from 1): her words flow again, nothing of the old call remains', (await stage()).includes('又见面了') && !(await stage()).includes('晚安'), await stage()); +-check('a new call is a new room and a new sequence: her words flow again, nothing of the old call remains', (await stage()).includes('又见面了') && !(await stage()).includes('晚安'), await stage()); + await shot('w7-reopened'); ++await send(fresh('turn_end', 'speech_fresh'), 'driver'); await state('listening'); await sleep(300); ++ ++// 9. Fix 1: her worker leaves while the room stays open. ++async function workerLeaves(tag) { ++ await calls(); ++ await lk('__lk.guest()'); await sleep(200); await lk('__lk.guestLeave()'); await sleep(500); ++ check(`${tag} fix 1 negative: someone else leaving does not hang up`, (await phase()) !== 'off' && !(await status()).includes('中断'), `${await phase()} ${await status()}`); ++ const sid = (await ctl('/__sessions')).open.at(-1); ++ let gone = ''; ++ await record(`v2-${tag}-worker-leaves`, async () => { ++ await send(fresh('turn_start', `speech_${tag}_left`), 'driver'); await state('speaking'); ++ await send(fresh('assistant_interim', `speech_${tag}_left`, { text: '我先去看看窗外。' }), 'driver'); ++ await until(async () => (await stage()).includes('窗外'), 6000); await sleep(800); ++ await lk('__lk.leave()'); ++ await until(async () => (await phase()) === 'off', 3000); await sleep(300); gone = await stage(); ++ await sleep(1500); ++ }); ++ const mic = await lk(`__lk.mics.at(-1).readyState`), holder = await exists('[data-voice=her]'), log = await calls(), open = (await ctl('/__sessions')).open; ++ check(`${tag} fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone`, (await phase()) === 'off' && !gone.includes('窗外') && (await status()).includes('她那边的语音中断了') && mic === 'ended' && !holder, `${await status()} mic=${mic} holder=${holder}`); ++ check(`${tag} fix 1: this call's server room is deleted (once)`, log.filter(l => l === `DELETE /api/voice/session/${sid}`).length === 1 && !open.includes(sid), log.join(' | ')); ++ await shot(`${tag}-worker-left`); ++ // Late events of that room (its own Disconnected, its worker "leaving" again) say nothing more. ++ await lk('__lk.lateLeave(-1)'); await sleep(400); ++ check(`${tag} fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE)`, !(await calls()).some(l => l.startsWith('DELETE')), await status()); ++} ++await workerLeaves('moon-desktop'); ++// An old room's worker leaving late never ends the call that replaced it; your own hang-up adds no notice. ++await startVoice(); await calls(); ++const oldRoom = await lk('__lk.rooms.length') - 2; ++await lk(`__lk.lateLeave(${oldRoom})`); await sleep(500); ++check('fix 1 negative: an old room\'s late worker leave does not end the new call', (await phase()) !== 'off' && !(await status()).includes('中断'), `${await phase()} ${await status()}`); ++const sid2 = (await ctl('/__sessions')).open.at(-1); ++await click('.voice-row.real .voice-end'); await sleep(500); ++await lk(`__lk.lateLeave(-1)`); await sleep(400); ++const endLog = await calls(); ++check('fix 1 negative: your own hang-up — one DELETE, no "she left" notice', endLog.filter(l => l === `DELETE /api/voice/session/${sid2}`).length === 1 && !(await status()).includes('中断'), endLog.join(' | ')); +-await click('.voice-row.real .voice-end'); await sleep(300); + ++// ── Second theme, phone, and reduced motion: the same worker frames and the three fixes on the approved visuals. ── ++for (const [theme, v, tag, still] of [['fog', 'desktop', 'fog-desktop', false], ['moon', 'phone', 'moon-phone', false], ['fog', 'phone', 'fog-phone', false], ['moon', 'desktop', 'reduced-motion', true]]) { ++ await view(v); await reduce(still); await boot(theme); await click('.nav-item', '交谈'); await sleep(1400); await startVoice(); await calls(); +-// ── Second theme + phone: the same worker frames on the approved visuals. ── +-for (const [theme, v, tag] of [['fog', 'desktop', 'fog'], ['moon', 'phone', 'phone']]) { +- await view(v); await boot(theme); await click('.nav-item', '交谈'); await sleep(1400); await startVoice(); await calls(); + const t = load('worker-normal'), tid = sidecar.scenarios.normal.turn_id; ++ let early = ''; ++ const run = async () => { ++ await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(tid)}&text=${encodeURIComponent('你在听吗')}`); ++ await sendAll(t.filter(f => f.seq <= 2), 'worker-normal'); await state('thinking'); await sleep(400); ++ await send(of(t, 'turn_start'), 'worker-normal'); await state('speaking'); ++ for (const f of t.filter(f => f.kind === 'assistant_interim')) { await send(f, 'worker-normal'); await sleep(500); } ++ await until(async () => (await stage()).includes('我还在想'), 8000); early = await stage(); ++ stored = (await rows()).filter(r => r.role === 'companion' && r.turn_id === tid).length; ++ await sleep(1800); ++ }; ++ let stored = -1; ++ if (tag === 'moon-phone' || tag === 'reduced-motion') await record(`v3-${tag}-short-reply-while-speaking`, run); else await run(); ++ check(`${tag}: her short reply is on the stage while she speaks it (before the final), approved ${theme} ${v}`, early.includes('在听呢,你刚才说到一半的那件事我还在想。') && stored === 0, early); ++ if (still) check('reduced motion: her words arrive without ink or slide', !(await exists('.conversation .stage .ink')), ''); +- await ctl(`/__voice-turn?stage=user&turn=${encodeURIComponent(tid)}&text=${encodeURIComponent('你在听吗')}`); +- await sayAll(t.filter(f => f.seq <= 2)); +- await say(of(t, 'turn_start')); await state('speaking'); +- for (const f of t.filter(f => f.kind === 'assistant_interim')) { await say(f); await sleep(260); } +- check(`${tag}: worker frames grow her reply on the approved ${theme} ${v} layout`, await until(async () => (await stage()).includes('在听呢,你刚才说到一半的那件事'), 8000), await stage()); +- if (tag === 'phone') await frames('phone-growing', 700); + await shot(`${tag}-her-words-growing`); + await ctl(`/__voice-turn?stage=reply&turn=${encodeURIComponent(tid)}&text=${encodeURIComponent('在听呢,你刚才说到一半的那件事我还在想。')}`); ++ await sendAll(t.filter(f => f.kind === 'assistant_final' || f.kind === 'turn_end'), 'worker-normal'); await state('listening'); await sleep(800); +- await sayAll(t.filter(f => f.kind === 'assistant_final' || f.kind === 'turn_end')); await state('listening'); await sleep(800); + await click('.log-btn'); await sleep(600); + const rec = await js(`document.querySelector('.log-panel .scroll')?.innerText || ''`); ++ check(`${tag}: the stored row takes over once on ${theme} ${v} (isolated record)`, rec.split('在听呢,你刚才说到一半的那件事我还在想。').length - 1 === 1, rec.slice(-120)); +- check(`${tag}: the stored row takes over once on ${theme} ${v}`, rec.split('在听呢,你刚才说到一半的那件事我还在想。').length - 1 === 1, ''); + await click('.log-panel .btn-round'); await sleep(300); ++ await workerLeaves(tag); +- await click('.voice-row.real .voice-end'); await sleep(300); + } + ++writeFileSync(join(out, 'seq-renumbering.json'), JSON.stringify(renumbered, null, 1)); ++await reduce(false); await view('desktop'); ++await encodeAll(); + ws.close(); browser.kill(); mock.kill(); + const failed = results.filter(r => !r).length; + console.log(`${results.length - failed}/${results.length} passed`); +diff --git a/web/tests/hel898_agent_left.test.ts b/web/tests/hel898_agent_left.test.ts +new file mode 100644 +--- /dev/null ++++ b/web/tests/hel898_agent_left.test.ts +@@ -0,0 +1,137 @@ ++import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; ++import { livekitTransport, type Livekit } from '../src/voice/livekit'; ++import { VoiceController, WORDS, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session'; ++ ++// HEL-898 (f92a86f review): her worker leaving a room that stays open. Driven through the real transport adapter ++// (src/voice/livekit.ts) with a scripted client that uses the vendored livekit-client 2.22.4 event names ++// (RoomEvent.ParticipantDisconnected = 'participantDisconnected', emitted as (participant, reason)); no WebRTC. ++const RoomEvent = { TrackSubscribed: 'trackSubscribed', TrackUnsubscribed: 'trackUnsubscribed', ParticipantConnected: 'participantConnected', ParticipantDisconnected: 'participantDisconnected', ParticipantAttributesChanged: 'participantAttributesChanged', DataReceived: 'dataReceived', Disconnected: 'disconnected', AudioPlaybackStatusChanged: 'audioPlaybackChanged' }; ++const AGENT = 4; ++interface P { identity: string; kind?: number; attributes?: Record } ++class FakeRoom { ++ handlers: Record void)[]> = {}; remoteParticipants = new Map(); canPlaybackAudio = true; disconnects = 0; published: unknown[] = []; ++ localParticipant = { publishTrack: async (t: unknown) => { this.published.push(t); } }; ++ constructor() { rooms.push(this); } ++ on(e: string, cb: (...a: unknown[]) => void) { (this.handlers[e] ||= []).push(cb); return this; } ++ emit(e: string, ...a: unknown[]) { (this.handlers[e] || []).forEach(cb => cb(...a)); } ++ async connect() {} ++ async disconnect() { this.disconnects++; [...this.remoteParticipants.values()].forEach(p => this.leave(p.identity)); this.emit(RoomEvent.Disconnected); } ++ async startAudio() {} ++ enter(p: P) { this.remoteParticipants.set(p.identity, p); this.emit(RoomEvent.ParticipantConnected, p); } ++ // As the SDK does: the participant is removed from the map, then the event carries it and a reason. ++ leave(identity: string) { const p = this.remoteParticipants.get(identity); if (!p) return; this.remoteParticipants.delete(identity); this.emit(RoomEvent.ParticipantDisconnected, p, 1); } ++ say(frame: unknown) { this.emit(RoomEvent.DataReceived, new TextEncoder().encode(JSON.stringify(frame)), undefined, 0, 'cg.subtitle'); } ++} ++let rooms: FakeRoom[] = [], mics: { stopped: number }[] = []; ++const lk = { ++ Room: FakeRoom, RoomEvent, Track: { Source: { Microphone: 'microphone' } }, ParticipantKind: { AGENT }, ++ async createLocalAudioTrack() { const m = { stopped: 0 }; mics.push(m); const mst = { stop: () => { m.stopped++; }, addEventListener() {} }; return { kind: 'audio', mediaStreamTrack: mst, attach: () => ({}), detach: () => [], stop: () => {} }; }, ++} as unknown as Livekit; ++const worker: P = { identity: 'agent-AJ_1', kind: AGENT, attributes: { 'lk.agent.state': 'listening' } }; ++ ++// The adapter keeps her audio in a hidden holder element; a node test has no DOM, so the few calls it makes are stubbed. ++beforeEach(() => { ++ rooms = []; mics = []; ++ const el = () => ({ hidden: false, dataset: {}, querySelectorAll: () => [], remove() {}, append() {} }); ++ vi.stubGlobal('document', { createElement: el, body: { append() {} } }); ++}); ++afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); ++ ++function rig(deleteStatus = 200) { ++ const calls: string[] = [], views: VoiceView[] = [], record = vi.fn(); ++ let status = deleteStatus, made = 0; ++ const f = vi.fn(async (url: string, init: RequestInit = {}) => { ++ const method = init.method || 'GET'; calls.push(`${method} ${url}`); ++ if (url === '/api/voice/status') return new Response(JSON.stringify({ configured: true, tts_ok: true })); ++ if (url === '/api/voice/session' && method === 'POST') { const id = `s${++made}`; return new Response(JSON.stringify({ url: 'wss://lk.test', token: `t-${id}`, room: `cg-voice-${id}`, session_id: id, epoch: 3, expires_at: 0 }), { status: 201 }); } ++ if (method === 'DELETE') return new Response('{}', { status }); ++ return new Response('{}', { status: 404 }); ++ }); ++ const c = new VoiceController({ transport: async () => livekitTransport(lk), onView: v => views.push(v), onRecord: record, onSpoke: () => {}, fetch: f as unknown as typeof fetch, secure: () => true, agentWaitMs: 60000 }); ++ return { c, calls, views, view: () => views.at(-1)!, setDelete: (s: number) => { status = s; } }; ++} ++const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); }; ++const deletes = (calls: string[]) => calls.filter(c => c.startsWith('DELETE')); ++ ++describe('her worker leaves while the room stays open (real adapter, SDK event names)', () => { ++ it('ends the call honestly: her line, microphone, audio, room and server room all go at once', async () => { ++ const r = rig(); await r.c.start(); const room = rooms[0]; ++ room.enter(worker); ++ room.say({ v: 2, seq: 1, kind: 'user_final', role: 'user', text: '你在听吗' }); ++ room.say({ v: 2, seq: 2, kind: 'turn_start', role: 'assistant', text: '', speech_id: 'sp' }); ++ room.say({ v: 2, seq: 3, kind: 'assistant_interim', role: 'assistant', text: '在听呢。', speech_id: 'sp' }); ++ expect(r.view()).toMatchObject({ phase: 'thinking', her: { speech: 'sp', text: '在听呢。', state: 'open' } }); ++ room.leave(worker.identity); await flush(); ++ expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false }); ++ expect(mics[0].stopped).toBeGreaterThan(0); ++ expect(room.disconnects).toBe(1); ++ expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']); ++ // Nothing of that room is heard from again (its own Disconnected, late frames): one notice, one cleanup. ++ room.say({ v: 2, seq: 9, kind: 'assistant_final', role: 'assistant', text: '晚到。', speech_id: 'sp', turn_id: 'T' }); ++ room.emit(RoomEvent.ParticipantDisconnected, worker, 1); await flush(); ++ expect(r.views.filter(v => v.notice === WORDS.agentLeft)).toHaveLength(1); ++ expect(r.view().her).toBeNull(); ++ expect(deletes(r.calls)).toHaveLength(1); ++ }); ++ it('a DELETE that could not get through is owed and sent again, as any failure', async () => { ++ const r = rig(503); await r.c.start(); const room = rooms[0]; ++ room.enter(worker); room.leave(worker.identity); await flush(); ++ expect(r.view().notice).toBe(WORDS.agentLeft); ++ r.setDelete(404); await r.c.settle(); ++ expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1', 'DELETE /api/voice/session/s1']); ++ }); ++ it('anyone else leaving does not hang up; nor does a second worker leaving while hers stays', async () => { ++ const r = rig(); await r.c.start(); const room = rooms[0]; ++ room.enter(worker); room.enter({ identity: 'guest-1', kind: 0, attributes: {} }); ++ room.leave('guest-1'); await flush(); ++ expect(r.view()).toMatchObject({ phase: 'listening', notice: '' }); ++ room.enter({ identity: 'agent-AJ_2', kind: AGENT, attributes: {} }); room.leave('agent-AJ_2'); await flush(); ++ expect(r.view()).toMatchObject({ phase: 'listening', notice: '' }); ++ expect(deletes(r.calls)).toHaveLength(0); ++ await r.c.end(); ++ }); ++ it('your own hang-up says nothing more: the room closing takes her worker with it silently', async () => { ++ const r = rig(); await r.c.start(); const room = rooms[0]; ++ room.enter(worker); ++ await r.c.end(); await flush(); ++ expect(room.disconnects).toBe(1); ++ expect(r.views.some(v => v.notice)).toBe(false); ++ expect(deletes(r.calls)).toHaveLength(1); ++ }); ++ it('an old room\'s worker leaving late never ends the new call', async () => { ++ const r = rig(); await r.c.start(); const first = rooms[0]; first.enter(worker); ++ await r.c.start(); const second = rooms[1]; second.enter({ ...worker, identity: 'agent-AJ_new' }); ++ // The first room was closed by the restart; its handlers stay registered, and fire late. ++ first.emit(RoomEvent.ParticipantDisconnected, worker, 1); await flush(); ++ expect(r.view()).toMatchObject({ phase: 'listening', notice: '' }); ++ expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']); ++ await r.c.end(); ++ }); ++}); ++ ++describe('controller: agentLeft only for this call, only once she was there', () => { ++ class Room implements VoiceRoom { closed = 0; on!: RoomEvents; async publish(_m: MicTrack) {} hush() {} async resumeAudio() {} async close() { this.closed++; } } ++ const make = () => { ++ const list: Room[] = [], views: VoiceView[] = []; ++ const transport = { capture: async () => ({ stop() {}, onEnded() {} }), join: async (_u: string, _t: string, on: RoomEvents) => { const x = new Room(); x.on = on; list.push(x); return x; } }; ++ const f = vi.fn(async (url: string, init: RequestInit = {}) => url === '/api/voice/status' ? new Response('{"configured":true,"tts_ok":true}') : init.method === 'POST' ? new Response(JSON.stringify({ url: 'u', token: 't', room: 'r', session_id: `s${list.length + 1}`, epoch: 1, expires_at: 0 }), { status: 201 }) : new Response('{}')); ++ const c = new VoiceController({ transport: async () => transport, onView: v => views.push(v), onRecord: () => {}, onSpoke: () => {}, fetch: f as unknown as typeof fetch, secure: () => true, agentWaitMs: 60000 }); ++ return { c, list, views, view: () => views.at(-1)! }; ++ }; ++ it('before her worker ever joined, a leave is not hers to report (the join wait says it honestly)', async () => { ++ const r = make(); await r.c.start(); ++ r.list[0].on.agentLeft(); await flush(); ++ expect(r.view()).toMatchObject({ phase: 'joining', notice: '' }); ++ await r.c.end(); ++ }); ++ it('a stale call\'s agentLeft changes nothing in the new call', async () => { ++ const r = make(); await r.c.start(); r.list[0].on.agentJoined(); ++ await r.c.end(); await r.c.start(); r.list[1].on.agentJoined(); ++ r.list[0].on.agentLeft(); await flush(); ++ expect(r.view()).toMatchObject({ phase: 'listening', notice: '' }); ++ expect(r.list[1].closed).toBe(0); ++ r.list[1].on.agentLeft(); await flush(); ++ expect(r.view()).toMatchObject({ phase: 'off', notice: WORDS.agentLeft }); ++ expect(r.list[1].closed).toBe(1); ++ }); ++}); +diff --git a/web/tests/voice.test.ts b/web/tests/voice.test.ts +--- a/web/tests/voice.test.ts ++++ b/web/tests/voice.test.ts +@@ -533,11 +533,13 @@ + const stage = (list: ShownMessage[]) => pagesOf(groupTurns(list).at(-1), metrics).map(p => `${p.key}=${p.text}`); + const you = M(4, 'user', '你在听吗?', 'T'); + it('grows in her slot after your voice turn; the stored row takes over under the same line keys', () => { ++ const open: HerLine = { speech: 's', text: '在听呢,你刚才说到一半的那件事我一直还在心里想着。后来', state: 'open' }; +- const open: HerLine = { speech: 's', text: '在听呢,你刚才说到一半的那件事我一直还在心里想着。后来呢?', state: 'open' }; + const growing = voiceLine(open, [you], 1)!; ++ expect(growing).toMatchObject({ role: 'companion', live: 'speaking' }); ++ // While she speaks, the sentence still forming waits; what she has finished is there. +- expect(growing).toMatchObject({ role: 'companion', live: 'generating' }); +- // The newest line of a reply still growing is held back, as in a streamed text reply. + expect(stage([you, growing])).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我一直还在心里想着。']); ++ open.text += '呢?'; ++ expect(stage([you, voiceLine(open, [you], 1)!])).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我一直还在心里想着。', 'T:1:1=后来呢?']); + const done: HerLine = { ...open, state: 'done', turnId: 'T' }; + const shown = stage([you, voiceLine(done, [you], 1)!]); + expect(shown).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我一直还在心里想着。', 'T:1:1=后来呢?']); +@@ -545,6 +547,34 @@ + expect(voiceLine(done, stored, 1)).toBeNull(); + expect(stage(stored)).toEqual(shown); + }); ++ it('a short spoken reply is on the stage while she says it, not only once it is stored (HEL-898 f92a86f)', () => { ++ const at = (text: string) => stage([you, voiceLine({ speech: 's', text, state: 'open' }, [you], 1)!]); ++ // worker-normal.jsonl, interim by interim: nothing of hers until a sentence is finished, then the whole of it. ++ expect(at('在听呢,')).toEqual(['T:0:0=你在听吗?']); ++ expect(at('在听呢,你刚才说到一半的那件事')).toEqual(['T:0:0=你在听吗?']); ++ expect(at('在听呢,你刚才说到一半的那件事我还在想。')).toEqual(['T:0:0=你在听吗?', 'T:1:0=在听呢,你刚才说到一半的那件事我还在想。']); ++ // Her final and her stored row keep exactly that line under that key: nothing moves when it is stored. ++ expect(stage([you, voiceLine({ speech: 's', text: '在听呢,你刚才说到一半的那件事我还在想。', state: 'final', turnId: 'T' }, [you], 1)!])).toEqual(at('在听呢,你刚才说到一半的那件事我还在想。')); ++ expect(stage([you, M(5, 'companion', '在听呢,你刚才说到一半的那件事我还在想。', 'T')])).toEqual(at('在听呢,你刚才说到一半的那件事我还在想。')); ++ // A very short sentence shows at once; the next one joins the same line (it only ever grows, never shrinks). ++ expect(at('嗯。')).toEqual(['T:0:0=你在听吗?', 'T:1:0=嗯。']); ++ expect(at('嗯。我在')).toEqual(['T:0:0=你在听吗?', 'T:1:0=嗯。']); ++ expect(at('嗯。我在。')).toEqual(['T:0:0=你在听吗?', 'T:1:0=嗯。我在。']); ++ expect(at('“好啊。”')).toEqual(['T:0:0=你在听吗?', 'T:1:0=“好啊。”']); ++ }); ++ it('a long spoken reply still reads one or two sentences at a time; a text stream keeps holding its newest line', () => { ++ const long = '我下午去了河边,看到一群白鹭从水面上飞过去了,它们飞得很低,几乎贴着水,翅膀尖在水面上划出一道一道细细的白线,后来又绕回来落在对岸的芦苇里'; ++ const lines = stage([you, voiceLine({ speech: 's', text: long, state: 'open' }, [you], 1)!]).slice(1); ++ // Every finished line of a long sentence is shown; the part still being said waits; no line is the whole text. ++ expect(lines.length).toBeGreaterThan(0); ++ expect(lines.every(l => l.length - 'T:1:0='.length <= 60)).toBe(true); ++ expect(lines.join('')).not.toContain('芦苇里'); ++ const full = stage([you, voiceLine({ speech: 's', text: long + '。', state: 'final', turnId: 'T' }, [you], 1)!]).slice(1); ++ expect(full.slice(0, lines.length)).toEqual(lines); ++ // The text chat's own rule is untouched: a streamed reply holds back its newest chunk even when it is a sentence. ++ const typed = pagesOf(groupTurns([you, { ...M(9, 'companion', '在听呢,你刚才说到一半的那件事我还在想。', 'T'), live: 'generating' }]).at(-1), metrics); ++ expect(typed.map(p => p.text)).toEqual(['你在听吗?']); ++ }); + it('a finished line never stands in front of a newer turn; an open one waits for its own end', () => { + const done: HerLine = { speech: 's', text: '好。', state: 'done', turnId: 'T' }; + expect(voiceLine(done, [you, M(6, 'user', '还有', 'U')], 1)).toBeNull(); diff --git a/docs/rework/hel898/f92a86f-rework/MANIFEST-HEL-898-f92a86f.json b/docs/rework/hel898/f92a86f-rework/MANIFEST-HEL-898-f92a86f.json new file mode 100644 index 0000000..ada11fc --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/MANIFEST-HEL-898-f92a86f.json @@ -0,0 +1,223 @@ +{ + "base_commit": "f92a86f5d8f839338311cdee08179049de34eb20", + "changed": { + "docs/requirements/handoff-HEL-898.json": { + "before": "42c39b5e8eb4bcc7c9eb3b216957b5dfe0503873e56e123d5ccee25858eadd2d", + "after": "af31e5dca084900b4b8e61adaeeb6868a377f74ba1a5afb57219c4ff783adb31" + }, + "web/src/chat/ChatPanel.tsx": { + "before": "d8f2f9e186372d3ba02ae6ae958b298f7b27a75053bb21a1338ea2728b49b669", + "after": "38c4912f43c01b7399f3d687e022b14135f5ad648cfd005ecc0b93637b055900" + }, + "web/src/chat/lineStage.ts": { + "before": "21ddd34964237f537262f557e56dced2fd72a51c8a24577d867bc3933b1d40f7", + "after": "687835a7e3f6f3a9bfbff863e2cd7e1ea96c463d1bc35b9e9be2833cd3252da2" + }, + "web/src/chat/reading.ts": { + "before": "c00aa08141d00047bc9097a50197e5223772baa7af8b1c747bb259c9a5ada16b", + "after": "b35aa4b7c433f660929b2bb46f85d06ebbbde942621df5c2df8da6b3312f4dfb" + }, + "web/src/voice/livekit.ts": { + "before": "c42475d37f7eef7abe0903b66656aedc5379bc9de3dcba11c1b88450a470ab04", + "after": "0148fabff55c4d6112aca2b412c53280104442601d6f5e39349279298761293e" + }, + "web/src/voice/session.ts": { + "before": "a4036aa04a370b258a38b84a3370f82e2dc6707b6bda4d8e26c713669f660b14", + "after": "2a9a8cd5f1b32fabc68151fee19561e88983b4f5b739da40806dbfeb7698d6e5" + }, + "web/tests/fake-livekit.js": { + "before": "d6309571c7f03a0eef158354d95c9d9637e362cd4312e4c118031057b13efddc", + "after": "fe6489d393aa686cbb527f04d397b3bea19b9e663a665b74369310719c12a917" + }, + "web/tests/hel878_mock.mjs": { + "before": "fb687d17549c9aaeb3f61d79d78ba722092502ae7b48ff764bf83415d5c6fee0", + "after": "4c8cf85102a0c5cecab47ad493c7a698073d4aa25f0d1c0ff598c032640a8d76" + }, + "web/tests/hel879_frames.test.ts": { + "before": "f6c723aa4f2aae736ceeac164e298f80a7da96be4a1899bdd051c8d3c7a90c52", + "after": "86b9cbb402e28661f37e7ad4e292ed4a6f8a3212921930bfc246fe26475d6fe8" + }, + "web/tests/hel879_unified_shots.mjs": { + "before": "ee624f14e050b4e2156e1ddddc7dc525062b119a6c9140e69f765d1c26fb52b4", + "after": "1678eb9548b9427d1746aacb913c7f52246f9e0d2a87006f9d6fc411a862f82f" + }, + "web/tests/hel898_agent_left.test.ts": { + "before": null, + "after": "727752e2cfa56618353ca9a95cb4f4a308fa3020a7fe6a210eb3f021a6654c9b" + }, + "web/tests/voice.test.ts": { + "before": "bcda0c1f622bd08a26c74c20bc2a85e75075c6ea2de1ef22eafd8834adf16f73", + "after": "360ac94641a64fe6dd9d83a9243d037afc789b034bcfbc2fb558bc604262e4a5" + } + }, + "files": { + "AGENTS.md": "40eae2dec1bddfe05318eeb76815806a3efa3e4ce23e3d3668132a7ee4cf61b7", + "INPUT-MANIFEST.json": "84c24d8d4e360ddf7b7fbf8660a495f2a780b3b3bc29e2ad61e74c2d88bd42d5", + "docs/requirements/README.md": "9b1fe8e94c74950ed671c381ebd0b9e254673b4f4cc226978153a8cd16549e73", + "docs/requirements/baseline.json": "d36bd3d4e2187925645790637468ee116d5701a6182ef1fc78db77a994ce1bdf", + "docs/requirements/changes.json": "8b815a75ae6978e8d62929b8708a235acd90f7d2be1037e2fb4adaadd82e9dd1", + "docs/requirements/changes.md": "c2a50b361bed40238c5d64e78485c08b4055d40eb3d7427c9100371c6999410c", + "docs/requirements/coverage.json": "0f631bf848c4331b3281086a2265c29d8d381dda3ca93164a05ca0d71bd99254", + "docs/requirements/coverage.md": "228fcd71faa5c509b19143f8c871efa764f8c0e9e5ac627e33f3428c407f6658", + "docs/requirements/current.md": "3ecae2bf182e19d990cf5030b1fe585adb514fd1415260e3da9e766a6fc8fafa", + "docs/requirements/discussion-takeaways-20261025.md": "e2ee666e5193b237a739a8d022a82bbb868cd144f8f4e1fe34f0e3981f94cf7d", + "docs/requirements/handoff-HEL-697.json": "2ddfca357e170e864d482ca469da0129c1cf26ef6b437ee24b8a3d34947b4510", + "docs/requirements/handoff-HEL-811.json": "07a596b9328224dd621c9d1d4b428f2d76ea5dc81c173d9d7a55c0b70ee8d8be", + "docs/requirements/handoff-HEL-816.json": "4577524f25db9b301a3bc68fb970d31e4320cae11e4354d23881bb134223f642", + "docs/requirements/handoff-HEL-821.json": "7f3be159f017a9600708c7d0ff2809c67bd2811498f0c232d040a9601b78862f", + "docs/requirements/handoff-HEL-822.json": "8753bd145ae4ed0d045d0d4f6160101e200b4f1071601228ef2c0b43db1a5303", + "docs/requirements/handoff-HEL-823.json": "5d6337ad15e82538b8b284b4aa7889579a1b3fc89029af221ffdf35d8f0a0fef", + "docs/requirements/handoff-HEL-824.json": "302fd163a35f296d64cecbd521d84b2de525d5bddf27bc1a9dbb9f7e969f6f1d", + "docs/requirements/handoff-HEL-825.json": "60e2c4f87ee1c2a814e934337a2a48801fcef7750cfcc9134578f6c77238d898", + "docs/requirements/handoff-HEL-827.json": "b1e625dac3163d09967c7c8d43de2f0927becefac535f58844ee6045fa2b1f6a", + "docs/requirements/handoff-HEL-828.json": "89f05d82366835a425d0fc5db9bce9a7b600dd0a782608f2b0a494f1d9ffe054", + "docs/requirements/handoff-HEL-830.json": "16fa3048878b08dafc2a8ca02e8d3db417530a1678c5cb31a20987ad5b684de6", + "docs/requirements/handoff-HEL-857.json": "382392235666d735655e241cb4bd3c8208e15b6fdc2427b86de30b9d472064a9", + "docs/requirements/handoff-HEL-858.json": "68c1975f89a926822321c4159d7fe60d6a8569cf382321290aa23fc05b47b538", + "docs/requirements/handoff-HEL-859.json": "388c620e99367a24e499efb01361a04c8dfdcafd36cf575f0a69d28eb79cd09f", + "docs/requirements/handoff-HEL-860.json": "1222921c61630b072f58ee1c643a4da02721d592199b34bd0806d8c5be4e58d7", + "docs/requirements/handoff-HEL-861.json": "d604581abc60ff553fe62ccfe4d98c8b8532da948f8f33ea2bd53333fa7961ff", + "docs/requirements/handoff-HEL-862.json": "d08fda4d2c4f2ef0ca9fd8c4e265cef85b5d520e40da6a322a2d099648b94acc", + "docs/requirements/handoff-HEL-863.json": "30d5322a2673d6d4d71bb07153d3d974a636af1f04f96f31e85a7c082b568c86", + "docs/requirements/handoff-HEL-864.json": "909213044fa7a75f8043c95bb071636ff376200a124e4d1300f8ff65bbec802e", + "docs/requirements/handoff-HEL-865.json": "1e361d2b7b2a66f56f88da021fda5a8432308c855975b0613cf35c9da525852d", + "docs/requirements/handoff-HEL-866.json": "539759490fb6f1795b5a325632e3720a7332c06640038fbbc958eca11df59324", + "docs/requirements/handoff-HEL-867.json": "3eb2fd69e5231e257967160195bcc3ee93975f27c339d3eefe4f295e0efd818e", + "docs/requirements/handoff-HEL-868.json": "dd609e7828b8ff20c13a6b938fce40d77f38aa30b978bd451b468b2169d5db0c", + "docs/requirements/handoff-HEL-869.json": "8954ea038f375934732adc2cfbe60e75eaa23969b4fb163bca29512d6520b7b4", + "docs/requirements/handoff-HEL-870.json": "71baea0fb2cec30b7ff40d3382fa749b0c46213ad34ac64d02456ac1c0527bdc", + "docs/requirements/handoff-HEL-874.json": "73cd0a585b86c2f3b03d7b4bdca65f933065efc0c55e82fdef550d1fed253a9f", + "docs/requirements/handoff-HEL-875.json": "984f362ba3cb42434c53194d479c1dfe4b03113261a74b46e7e6ff599e8ce5ae", + "docs/requirements/handoff-HEL-876.json": "df18ad67b29ab72b9ac9a2928d88b2ab25d4ae0975b3fc2c41802f97ddd5e8cd", + "docs/requirements/handoff-HEL-877.json": "238dc7f98fe7617051a7d121175f450cf9fbe8aaeb2153e86fc9ea5daa93fc1e", + "docs/requirements/handoff-HEL-878.json": "258cf2935f5aafc20cd7a80a9550ae062024849ff39c078068f371692bbaa516", + "docs/requirements/handoff-HEL-879.json": "47312170b2786db5f2208ea05cd438b9d031ec89c1fedd82a9567f19013da791", + "docs/requirements/handoff-HEL-880.json": "9506c3c5c1278b090d1e3eaf0f6191290af6114031e1f0e1ec514efeb74fb275", + "docs/requirements/handoff-HEL-882.json": "8901262dc1714b4f6264c2f375e7b12e977653ed5ad80a5efacf48754c1c1089", + "docs/requirements/handoff-HEL-885.json": "caf97d45ef8c23777942ed504d5ca6d3411ab5b86fda58c5ba308e31ffb80d32", + "docs/requirements/handoff-HEL-886.json": "5e965ee60fba01a4e7ebde534c76177aa509421aea31e33beb83d01387b7413b", + "docs/requirements/handoff-HEL-889.json": "bc28ee707a433d28f4efef9c33088781f98e1fe323f0c228f9444789d37363d3", + "docs/requirements/handoff-HEL-894.json": "932251b76b952705a97e5b4259c0bb5596d8b51347c4c91ee082146a4a3fa3fa", + "docs/requirements/handoff-HEL-895.json": "afb68a2db07dbf791a59e7878a646bd6cc30e26c8d94943551eb769024a79bbf", + "docs/requirements/handoff-HEL-896.json": "9b3f7533e4ead1ffbca2b502c3be35223648314e74aac907f29c38cb5b8b4955", + "docs/requirements/handoff-HEL-897.json": "56cf0e97b5035ccfaaff3f7dd219ca30352df600fa4c9684eb690e4e44731bc9", + "docs/requirements/handoff-HEL-898.json": "af31e5dca084900b4b8e61adaeeb6868a377f74ba1a5afb57219c4ff783adb31", + "docs/requirements/implementation-verification.md": "874a6a712fb1c5781c4b9e0dd31f831d42cd44603dec61c67c6d502d3c833819", + "docs/requirements/mature-reuse-handoff.json": "7b3a78495ff71b3dbc7498552b086d19c5f1b774388f02be78e7a0b225e40989", + "docs/requirements/source-manifest.json": "9dc69b281f7a7b05b481d0604cde80756d9d499a60d9eb49e138d551f5e076f3", + "docs/requirements/sources/.gitattributes": "1e1b7f6dd91450faec143a53cdc10ad708093a09d0ee5660ed1436abeed33e6b", + "docs/requirements/sources/approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8", + "docs/requirements/sources/comment-evidence-dynamic-sample-20261023.json": "fe7528a2b68aca99968734b200d9d27a17d85f5110e60c6f52d7c569755c260d", + "docs/requirements/sources/comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff", + "docs/requirements/sources/comment-evidence-mature-reuse-20261025.json": "38ca076e26477e89cd161eaadb0fb00b0ee7bb5a26de2bd6ab41de52faaae79e", + "docs/requirements/sources/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d", + "docs/requirements/sources/comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797", + "docs/requirements/sources/comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370", + "docs/requirements/sources/design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12", + "docs/requirements/sources/dynamic-sample-effect-acceptance-20261023.md": "f5e9fde65a58ebc365de47569053ad7c30ce77ae82bb78773cc070e3cbffd90e", + "docs/requirements/sources/frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86", + "docs/requirements/sources/governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09", + "docs/requirements/sources/integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169", + "docs/requirements/sources/mature-reuse-principle-20261025.md": "a2a75e071d3af5db722727bfaf8083bb60d07dcdd74e90d1e1374c1aafd2064d", + "docs/requirements/sources/other-agent-discussion-20261025.txt": "42cd97029d73eace110e49e0566343dfeb25fe4f0633a10b78b126289938aaeb", + "docs/requirements/sources/product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab", + "docs/requirements/sources/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9", + "docs/requirements/sources/visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a", + "docs/requirements/task-handoff-template.md": "8ee4d6d4c237e8a2c134ba751426975b0e24f03bfd6ac048b80642c69b59e445", + "docs/requirements/task-migration.json": "a5131adbdf14a79df01992d2cbbc4b7aea5b8b377c7115a80cedb04073f9bc7d", + "docs/requirements/verification.json": "7da6c2732cf28d46f8deaafc7ec571b8fc37bbd7efec0126ba339e51bbe12ace", + "docs/rework/hel879/unified-review/seq-job-analysis.md": "cf621542cf609083f8ce4597d98727c1eecc5b6d3442a8dfafa44bdf0d0ed7d3", + "docs/rework/hel879/voice-seam-interfaces.md": "12a107bf05cb17262bb5c9a24614092c7967aa43c0c4c7ff10d43444536a3293", + "docs/voice-subtitle-contract-v2.md": "fbe77f71b6a6efc69c24d0e4bdbf74d49df13d076870ee24ba1085faf19a2ca4", + "src/cybergril/voice/__init__.py": "d550b2c9008681594edd34fd25ae05b31387332e3d4960f2323b6b21ec9cea9e", + "src/cybergril/voice/chat_api_client.py": "702cb60e5640d236421da842df48142a118f8353852d975238702a7839c2f8cd", + "src/cybergril/voice/livekit_wire.py": "8aead2faa0b50677a7d8bccfea52fe17f55a8c53b090a2bb9bd868e450ce9d32", + "src/cybergril/voice/llm_chat_api.py": "b6cae28d22b68a6de61273e160daddb21da12f5924dff2d3c00f4636a345a007", + "src/cybergril/voice/receipt.py": "cf4a2296fc38539dd0d6e6ac17f0a894b0a82da89e6e8ace110265b84d75f2b9", + "src/cybergril/voice/router.py": "6d2afadeed807b442bbfda9f894097fda7df691539afad9cdfb2f8620039107e", + "src/cybergril/voice/seam.py": "ea8ebde885453728b36bfcf46eec1bac3d14944e8549dceb3c27c329aab715fa", + "src/cybergril/voice/session.py": "59ea140ec55bc637531f62d6a298b201772d6991d6190f10556e3ebbf25a11c6", + "src/cybergril/voice/stt_funasr.py": "e9c25aafe344c3432a13f17770d905c0239d72ce164b58443aacccc7013584b2", + "src/cybergril/voice/tts_cosyvoice.py": "7fa61424fb3ae5382dc9f27e881138953b69726c181084a6f693c141e7f6b5ba", + "src/cybergril/voice/worker.py": "dee6994fce5fef70f6ea0fb4659cda2e7d46d6b889485d5d2697426dddd949ae", + "web/.gitignore": "f99ead27b34cbc6756d707e7e7ee7340eb9a71e02dfec2b40d5fb848dd3f4d4f", + "web/README.md": "f596492a443e7ae9972496a40db2f40c79f8667d4552628afa4718efbd29c726", + "web/VENDOR.md": "e11c139b5c7a6f86264439b03997bd89c79e40c994ac1db57f31717e83b660b6", + "web/eslint.config.js": "c59a6a7e9f0dbeb5c8b90a66d1405606a5b0f909fafae47ca8e6737a9a386982", + "web/index.html": "ecf71d805fa2d20a6e244ac1f6fc729a55a44dc24c4a7ac75f966179a51cf61d", + "web/package-lock.json": "f950de58b5e86380705d979029894e62b39aba451cdb349922b97b4177677a27", + "web/package.json": "f58c320d8b4d35c586016dc002d5e83a26ceeb05d8729552c831c39d0791336e", + "web/public/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a", + "web/public/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33", + "web/public/assets/icons.svg": "8630691c7349dd33c1accf2331cb4c844ffd9b70465021b1c18bf8f5aea0588e", + "web/public/demo-material.json": "78fd67e8e4a680e8b3277db4a922360a83fa0be1ceee0cb257ba1c74bc6ede37", + "web/public/vendor/livekit-client-2.22.4.umd.min.js": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626", + "web/src/adapters/demo.ts": "be4593f70337e87dd4b3c0cb499cd73cb4dda1ee921d579d8bafe0b6cea53083", + "web/src/adapters/inbox.ts": "8e145b914af1247f066ebb3e99574d43303a915173e6206950657e462461c85c", + "web/src/adapters/real.ts": "5aa12ec2367c8505f663e52885138adf33ceeef4cfe2ae02d7d6402ff42864ee", + "web/src/adapters/receipt.ts": "cdd7f41d79c843886167947f05bdbed159919e288c6196c6d94d5080b821876d", + "web/src/adapters/sha256.ts": "c65b58368858ed13bff0fcb0710dc72c9cc5b112fd443dc6820fc66e62e08f22", + "web/src/adapters/storage.ts": "e06afb3f8f801c2a4ff5a5ea109f27f59ad2d3187c915d48d50fba3f68cc08be", + "web/src/app/App.tsx": "6d266a532c832fd8d93f447bf516158c17f517363155a47df7642cbc62ae1b90", + "web/src/app/RealApp.tsx": "8afb63b8a6b196e29ced1a8bcef541f1f80a10811f1dc0f965631a9f0c201f5e", + "web/src/chat/ChatPanel.tsx": "38c4912f43c01b7399f3d687e022b14135f5ad648cfd005ecc0b93637b055900", + "web/src/chat/lineStage.ts": "687835a7e3f6f3a9bfbff863e2cd7e1ea96c463d1bc35b9e9be2833cd3252da2", + "web/src/chat/reading.ts": "b35aa4b7c433f660929b2bb46f85d06ebbbde942621df5c2df8da6b3312f4dfb", + "web/src/demo/fixtures.ts": "17e3525a155a4d07f37f4dc4e557a259788e943e39ee7ceca1e6fbf6a272e81c", + "web/src/domain/id.ts": "b03e2c6a7120d1af5065a61103a0aa0679bdfedf3437ce31374d63df1fba8137", + "web/src/domain/model.ts": "88d76909fc771ca4572fd6097ead70beb347e6ba17b97eab913757a1bc193ec5", + "web/src/domain/privacy.ts": "3bb318fae0704f09d1bb657bf54c10dea6ce8b0847daaee36a8dbda22d9c00db", + "web/src/life/LifePanel.tsx": "a91fe08695a856c8e02b9a08b0c6c89427155243b52c300e7d6ce2ded3272192", + "web/src/main.tsx": "4af93f56bbcb00d8d3327ed9f64ab57288234838ed311a1a8c3e825d4ada46b6", + "web/src/manage/CreationWizard.tsx": "7c4e8945b3baf705c9fbc88ec8a50d3b4be289bd892789427061bbe97d396d5c", + "web/src/manage/ManagePanel.tsx": "df0c968ec91c571124f9707c853a17587569448195745b4757d7afd753cfef3c", + "web/src/shared/Viewport.tsx": "2302c18e00019134184a70d038bb0bcaea4e9230ae8c83ee9da0b7c285590bc0", + "web/src/shared/motion.ts": "fb397e28411c59349efd75cb5aac1a2722f047ece2e9b7b305803a523576c9f6", + "web/src/shared/navigation.ts": "f4820f6fad24bcb5e89dc497562aacd52ef264608f0a77718cade3a9efc4cb25", + "web/src/shared/space.ts": "5d32075facc0a4e3a56577a126942cee85f724185afd4a43de5f1f85979a1fca", + "web/src/shared/ui.tsx": "6900ffc625d4db2e8ef4772f9371927f85bd47c7464257a36c78e2c49449183f", + "web/src/stage/Background.tsx": "0e36869e3b0600238fc380941ab355429996966fb1aa8bebfc7c5ef9cf807706", + "web/src/stage/Presenter.tsx": "d4cf57bd990bfcf94ca567a1a681339c31474696fe2a92dbc6af218781522a7c", + "web/src/stage/ambient.ts": "076a02ec6152d245d8be5a40cda364f298518cfd7e4a4c3315b4f245afe3a59f", + "web/src/stage/camera.ts": "f3fd15fc4546d0572e03c2f82ae39bb7d2bcc78f65e1d7a3646e297f28dc8128", + "web/src/stage/roomScene.ts": "f01befa8068d88f0f62b845bd4a3cc4421fd7a28d4f5e5ade9c11d0df95488e3", + "web/src/styles/components.css": "f93b875f6918d402aaaefeffdc52fdd908328fb8ac70766704e528c6a8bc154d", + "web/src/styles/tokens.css": "f44fd4f1bfa1183e19a819256bd402403cd0bf3f5ac09a65bb10879ea3cf3475", + "web/src/voice/livekit.ts": "0148fabff55c4d6112aca2b412c53280104442601d6f5e39349279298761293e", + "web/src/voice/session.ts": "2a9a8cd5f1b32fabc68151fee19561e88983b4f5b739da40806dbfeb7698d6e5", + "web/tests/browser_check.py": "b3f9dee4246717950304a49a3086dd3c64ae293c18f2a5e273bd3f576bba0656", + "web/tests/domain.test.ts": "649cedf11049ca2be64569a9b727b53baf4aa7824aede9234cb343511f96cc45", + "web/tests/fake-livekit.js": "fe6489d393aa686cbb527f04d397b3bea19b9e663a665b74369310719c12a917", + "web/tests/fixtures/hel879/adversarial-gates.jsonl": "febd098b07af47fed78b94cd69e7bcf5d6f173c84c2cb4b241dee25bbb8f8671", + "web/tests/fixtures/hel879/sidecar.json": "6793b779e4469943f9ddaca78cb21eab1e8f5466521896b4f397792e863f78b4", + "web/tests/fixtures/hel879/worker-interrupt.jsonl": "4263617779fb7b8467a8cda9eec17e021591edfa7606a7bcf739e4f004940e84", + "web/tests/fixtures/hel879/worker-no_receipt.jsonl": "2acd5b70da10062f6ddceb02f6648e16b4676f15fed3a5a3f0902291fdb7634c", + "web/tests/fixtures/hel879/worker-normal.jsonl": "bcba7ad2a07a63d3b444cc6cec2d0a5371d96c8567e3e5b9d5c290efe07757a1", + "web/tests/fixtures/hel879/worker-phi.jsonl": "6716f9b8a8556b5b8ff9fac024760838595314d5f00c786fb54862e59136b008", + "web/tests/fixtures/hel879/worker-proactive.jsonl": "9f4b5bfaf9ad76dce8546c470f80dd6fbe9d36248079e822e5323a1d30079a85", + "web/tests/hel825_shot.py": "75f6075ae3d3c493360797e7e2f592fa5f4701f99e99981b8b9674ce70e01c92", + "web/tests/hel825_tag_probe.py": "f8c2f51a0403adba71ac4c925700d248efad5b7ad8c3eda14ec81ff98ca3aca0", + "web/tests/hel834_mock.mjs": "303079e4811f7ea62d52ea95c95b1b225259db1e19bbba914e9c70de31ffbe5f", + "web/tests/hel834_shots.mjs": "cb1984c50bae5da46f370292efbba6bb04f7d861bda3af572f2b45733843bc2c", + "web/tests/hel875_mock.mjs": "00b17de26604c7ca0999da12139fd4006468ced3e0536b60d239ccae0f949cc6", + "web/tests/hel875_shots.mjs": "41b04fb9b9ee9569f39dd2dc57f694f5efcd2890124fbc6ced14e6d546878aa2", + "web/tests/hel878_mock.mjs": "4c8cf85102a0c5cecab47ad493c7a698073d4aa25f0d1c0ff598c032640a8d76", + "web/tests/hel878_shots.mjs": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7", + "web/tests/hel879_frames.test.ts": "86b9cbb402e28661f37e7ad4e292ed4a6f8a3212921930bfc246fe26475d6fe8", + "web/tests/hel879_unified_shots.mjs": "1678eb9548b9427d1746aacb913c7f52246f9e0d2a87006f9d6fc411a862f82f", + "web/tests/hel895_real.mjs": "404f28bdb80abbbc2283c7fafa869728a36ab1677e32eb9c3fd6287ce92cde0b", + "web/tests/hel898_agent_left.test.ts": "727752e2cfa56618353ca9a95cb4f4a308fa3020a7fe6a210eb3f021a6654c9b", + "web/tests/hel898_shots.mjs": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393", + "web/tests/import_check.py": "a7eea222bfdf823a2bcc2fe139780d1d44279b2629e81f2ef8c5c5353abab990", + "web/tests/inbox.test.ts": "649bc6e09c93427369aca63ce214ee3d288197d0f886bfced1696f41228b0ffb", + "web/tests/r2wiring.test.ts": "d497adceb2c5187d980acb8801dcedbb1b5c991148048b9138bcb33820642b6f", + "web/tests/r3memory.test.ts": "166b963bafed1ac00d76506f74dc554c4b9e24c3719a055e02b94e72e95f609e", + "web/tests/reading.test.ts": "55573c3cfeb613fb5c2990f8b5274f0c8c75fb25d7f723e05fec95761e6389b4", + "web/tests/real.test.ts": "f63d377c148c901f97d3d5104bc648d72f0301fc2f28652d39693e3ce2836cfd", + "web/tests/real_browser_check.py": "0f716011d3b25d9c9578aba4e2d4e10e5f38ddc572e54d4528fdaa8645e84ed0", + "web/tests/review878_late_cleanup.test.ts": "65261cd249a36693d0c6b06ce279de8a60aa74dd7baafad6999d2fec790f9935", + "web/tests/rework_browser_check.py": "aee2600a4672b6052ab9372a20d9342a47be637a7271af7aed421b73256567d5", + "web/tests/voice.test.ts": "360ac94641a64fe6dd9d83a9243d037afc789b034bcfbc2fb558bc604262e4a5", + "web/tsconfig.json": "08c0f187ee66aed52b329aa4a50ea298839b02c1aed4deb9387c81473cc5d157" + } +} diff --git a/docs/rework/hel898/f92a86f-rework/README.md b/docs/rework/hel898/f92a86f-rework/README.md new file mode 100644 index 0000000..6cac95e --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/README.md @@ -0,0 +1,13 @@ +## HEL-898 f92a86f 有限返修交付 + +- 基点:hel-879-assembly @ f92a86f5d8f839338311cdee08179049de34eb20(输入包 166 文件 SHA-256 全部核对一致) +- `HEL-898-f92a86f-rework.patch`:相对 f92a86f 的统一补丁,路径为 a/ b/,共 12 个文件。已在内存中应用到原树,12 个文件逐字节一致。 +- `MANIFEST-HEL-898-f92a86f.json`:`changed` 列出改前/改后 SHA-256,`files` 列出改后全树哈希(不含 node_modules/dist)。 +- `source/`:12 个改动文件全文。 +- `evidence/after/`:修后截图、seq 重编号映射,以及 8 段 WebM。 + - WebM 用 CDP screencast 录下页面真实绘制的帧,按原时间戳交给浏览器自带的 MediaRecorder 编码(本机没有 ffmpeg)。 + - 每段都有 `*-frames/timing.json`;v1 附全部原始 JPEG 帧。 +- `evidence/before/`:同一脚本在未修 f92a86f 构建上跑出的截图。 +- `logs/`:tsc、eslint、vitest(修前/修后)、build、浏览器(统一回放修前/修后,hel898、hel878 回归)。 + +全部使用假 LiveKit、假音频设备、无头 Edge 和合成 mock,不是真实通话;没有任何真实模型、ASR、TTS 或麦克风采集。 diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/before-fix-vitest.log b/docs/rework/hel898/f92a86f-rework/author-logs/before-fix-vitest.log new file mode 100644 index 0000000..0ca2582 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/before-fix-vitest.log @@ -0,0 +1,249 @@ + + RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-898-a8c1036322cf/workdir/before/web + + ✓ tests/inbox.test.ts (13 tests) 40ms + ❯ tests/hel879_frames.test.ts (10 tests | 1 failed) 60ms + ✓ actual-worker frames (fc905fe on the real SDK) through the page > normal turn: draft, growing reply, stored final, ack — and the record takes over the stage line 19ms + ✓ actual-worker frames (fc905fe on the real SDK) through the page > barge-in: the cut turn never gets a final; the next turn lands clean with its own receipt 5ms + ✓ actual-worker frames (fc905fe on the real SDK) through the page > phi: her failed line is heard (spoken), not stored; the speech closes without a final 3ms + ✓ actual-worker frames (fc905fe on the real SDK) through the page > no legal receipt: cancel only — the fc905fe gate keeps assistant_final off the wire 2ms + ✓ actual-worker frames (fc905fe on the real SDK) through the page > proactive: start is published while the handle is still playing (sidecar), end closes the row-bound line 8ms + ✓ page guards against bad frames (adversarial, hand-built) > replays, late frames of a closed speech and out-of-order frames are all dropped 8ms + × page guards against bad frames (adversarial, hand-built) > a retired speech stays retired for the whole call, however many speeches closed after it (was: re-entered after 64) 7ms + → expected { speech: 'speech_A', …(3) } to be null + ✓ page guards against bad frames (adversarial, hand-built) > the retired set is per call: a new call starts clean, and an old call's retired ids never leak or block 2ms + ✓ page guards against bad frames (adversarial, hand-built) > your interim leaves her open line to the server's cancel; your final closes it at once 1ms + ✓ page guards against bad frames (adversarial, hand-built) > text takeover while she speaks: her line and its audio go at once; a drop clears everything and a new call ignores the old room 3ms + ✓ tests/reading.test.ts (11 tests) 50ms + ❯ tests/hel898_agent_left.test.ts (7 tests | 4 failed) 83ms + × her worker leaves while the room stays open (real adapter, SDK event names) > ends the call honestly: her line, microphone, audio, room and server room all go at once 26ms + → expected { phase: 'thinking', …(5) } to deeply equal { phase: 'off', heard: '', …(4) } + × her worker leaves while the room stays open (real adapter, SDK event names) > a DELETE that could not get through is owed and sent again, as any failure 7ms + → expected '' to be undefined // Object.is equality + ✓ her worker leaves while the room stays open (real adapter, SDK event names) > anyone else leaving does not hang up; nor does a second worker leaving while hers stays 16ms + ✓ her worker leaves while the room stays open (real adapter, SDK event names) > your own hang-up says nothing more: the room closing takes her worker with it silently 15ms + ✓ her worker leaves while the room stays open (real adapter, SDK event names) > an old room's worker leaving late never ends the new call 15ms + × controller: agentLeft only for this call, only once she was there > before her worker ever joined, a leave is not hers to report (the join wait says it honestly) 1ms + → r.list[0].on.agentLeft is not a function + × controller: agentLeft only for this call, only once she was there > a stale call's agentLeft changes nothing in the new call 1ms + → r.list[0].on.agentLeft is not a function + ❯ tests/voice.test.ts (45 tests | 2 failed) 419ms + ✓ honest switch > reads status only: no room, no microphone 16ms + ✓ honest switch > configured && tts_ok never means "verified": voice:false is not read as connected 1ms + ✓ honest switch > refuses honestly before touching the microphone when the server cannot speak 1ms + ✓ honest switch > an insecure page is told why, without asking for the microphone 0ms + ✓ lifecycle > opens, joins, publishes the microphone, follows her state; ending stops everything 2ms + ✓ lifecycle > permission denied: nothing opened on the server, nothing left running 1ms + ✓ lifecycle > server refusal shows its own words and releases the microphone 0ms + ✓ lifecycle > a failed join releases the microphone and gives the room back 0ms + ✓ lifecycle > ending while the microphone prompt is open: the late microphone is stopped, no room is asked for 23ms + ✓ lifecycle > ending while the room is being opened: the late grant is given back, never joined 31ms + ✓ lifecycle > ending while joining: the join is aborted, a late room is closed, the server room deleted 32ms + ✓ lifecycle > rapid on/off/on: only the last call stays, every earlier microphone and room is released 15ms + ✓ lifecycle > late events from an ended call never bring it back 16ms + ✓ lifecycle > the room dropping on its own (network, server ended it) cleans up and says so 20ms + ✓ lifecycle > microphone permission revoked mid-call ends the call 18ms + ✓ lifecycle > her worker never joining is a failure, not an endless wait 3ms + ✓ lifecycle > offline ends with its own words; unload releases at once and the DELETE survives the page 13ms + ✓ lifecycle > a DELETE that cannot get through (offline) is owed, survives a new page, and is sent once back online 1ms + ✓ lifecycle > an owed room already gone on the server (404) counts as settled 0ms + ✓ lifecycle > never confirms a receipt or posts chat itself: the worker owns both 3ms + ✓ a late end of an older call > the room dropped, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 47ms + ✓ a late end of an older call > the microphone was taken away, its DELETE (and the settle retry) answering after a new call is up, leaves the new call alone 46ms + ✓ a late end of an older call > her worker never joined, the DELETE is slow, and a new call is started before it answers 2ms + ✓ a late end of an older call > a normal end whose DELETE is slow, then a new call, then the old answer arrives 3ms + ✓ a late end of an older call > starting again while the previous start still waits for the old room to be given back: only the newest call survives 58ms + ✓ a late end of an older call > a microphone granted late to an older start is stopped without touching the new call 15ms + ✓ subtitles and the two channels > interim is a draft; user_final cancels a text reply and asks for the record; φ is heard but not recorded 1ms + ✓ subtitles and the two channels > without the agent state attribute, subtitles still move the state honestly 0ms + ✓ subtitles and the two channels > typing over her spoken answer silences what is left of it until she is listening again 0ms + ✓ her voice line (cg.subtitle v2) > rejects anything that is not exactly a v2 frame 1ms + ✓ her voice line (cg.subtitle v2) > normal turn: your draft and final, her growing reply replaced in place, then her stored final 1ms + ✓ her voice line (cg.subtitle v2) > interrupted: no final, her line goes at once, its late frames never reach the new turn 2ms + ✓ her voice line (cg.subtitle v2) > frames that overtook each other: a lower seq is stale; an older speech never touches or closes the new one 0ms + ✓ her voice line (cg.subtitle v2) > reach-out read aloud: shown whole with its stored message id, interruptible, nothing marked or stored 1ms + ✓ her voice line (cg.subtitle v2) > a reach-out cut by your voice: the cancel clears it, your words follow 0ms + ✓ her voice line (cg.subtitle v2) > text taking over, hanging up and starting again each clear her line at once 1ms + ✓ her voice line (cg.subtitle v2) > the room dropping mid-sentence clears her line with the call 8ms + × her voice line on the stage > grows in her slot after your voice turn; the stored row takes over under the same line keys 14ms + → expected { id: 'voice-s', …(5) } to match object { role: 'companion', live: 'speaking' } +(4 matching properties omitted from actual) + × her voice line on the stage > a short spoken reply is on the stage while she says it, not only once it is stored (HEL-898 f92a86f) 12ms + → expected [ 'T:0:0=你在听吗?' ] to deeply equal [ 'T:0:0=你在听吗?', …(1) ] + ✓ her voice line on the stage > a long spoken reply still reads one or two sentences at a time; a text stream keeps holding its newest line 1ms + ✓ her voice line on the stage > a finished line never stands in front of a newer turn; an open one waits for its own end 0ms + ✓ her voice line on the stage > a reach-out read aloud keeps its stored row identity and gives way when that row is the newest line 1ms + ✓ record re-read > adds voice turns once, in id order; leaves reach-outs to the inbox and text turns to the stream 0ms + ✓ record re-read > a pending voice row settles in place (sent or cancelled), never duplicated 0ms + ✓ record re-read > reads newest pages down to what the page holds, bounded 3ms + ✓ tests/domain.test.ts (12 tests) 11ms + ✓ tests/r2wiring.test.ts (11 tests) 56ms + ✓ tests/r3memory.test.ts (5 tests) 21ms + ✓ tests/real.test.ts (3 tests) 25ms + ✓ tests/review878_late_cleanup.test.ts (1 test) 16ms +node.exe : +At line:1 char:886 ++ ... n before\web; & $node node_modules\vitest\vitest.mjs run 2>&1 | Out-F ... ++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : NotSpecified: (:String) [], RemoteException + + FullyQualifiedErrorId : NativeCommandError + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 7 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/hel879_frames.test.ts > page guards against bad frames (adversarial, hand-built) > a retired speech stays +retired for the whole call, however many speeches closed after it (was: re-entered after 64) +AssertionError: expected { speech: 'speech_A', …(3) } to be null + +- Expected: +null + ++ Received: +{ + "speech": "speech_A", + "state": "done", + "text": "被挤出后的重放终稿", + "turnId": "gate-turn-1", +} + + ❯ tests/hel879_frames.test.ts:180:30 + 178| const views = r.views.length; + 179| play(room, frames.filter(f => f.seq >= dupStart.seq)); + 180| expect(r.view().her).toBeNull(); + | ^ + 181| expect(r.views.slice(views).some(v => v.her || v.phase !== 'li… + 182| // The seq gate is still strict on its own: nothing at or belo… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/7]⎯ + + FAIL tests/hel898_agent_left.test.ts > her worker leaves while the room stays open (real adapter, SDK event names) > +ends the call honestly: her line, microphone, audio, room and server room all go at once +AssertionError: expected { phase: 'thinking', …(5) } to deeply equal { phase: 'off', heard: '', …(4) } + +- Expected ++ Received + + { + "blocked": false, +- "heard": "", +- "her": null, +- "notice": undefined, +- "phase": "off", ++ "heard": "你在听吗", ++ "her": { ++ "speech": "sp", ++ "state": "open", ++ "text": "在听呢。", ++ }, ++ "notice": "", ++ "phase": "thinking", + "spoken": "", + } + + ❯ tests/hel898_agent_left.test.ts:65:26 + 63| expect(r.view()).toMatchObject({ phase: 'thinking', her: { spe… + 64| room.leave(worker.identity); await flush(); + 65| expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: ''… + | ^ + 66| expect(mics[0].stopped).toBeGreaterThan(0); + 67| expect(room.disconnects).toBe(1); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/7]⎯ + + FAIL tests/hel898_agent_left.test.ts > her worker leaves while the room stays open (real adapter, SDK event names) > +a DELETE that could not get through is owed and sent again, as any failure +AssertionError: expected '' to be undefined // Object.is equality + +- Expected: +undefined + ++ Received: +"" + + ❯ tests/hel898_agent_left.test.ts:79:33 + 77| const r = rig(503); await r.c.start(); const room = rooms[0]; + 78| room.enter(worker); room.leave(worker.identity); await flush(); + 79| expect(r.view().notice).toBe(WORDS.agentLeft); + | ^ + 80| r.setDelete(404); await r.c.settle(); + 81| expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/7]⎯ + + FAIL tests/hel898_agent_left.test.ts > controller: agentLeft only for this call, only once she was there > before +her worker ever joined, a leave is not hers to report (the join wait says it honestly) +TypeError: r.list[0].on.agentLeft is not a function + ❯ tests/hel898_agent_left.test.ts:123:22 + 121| it('before her worker ever joined, a leave is not hers to report (… + 122| const r = make(); await r.c.start(); + 123| r.list[0].on.agentLeft(); await flush(); + | ^ + 124| expect(r.view()).toMatchObject({ phase: 'joining', notice: '' … + 125| await r.c.end(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/7]⎯ + + FAIL tests/hel898_agent_left.test.ts > controller: agentLeft only for this call, only once she was there > a stale +call's agentLeft changes nothing in the new call +TypeError: r.list[0].on.agentLeft is not a function + ❯ tests/hel898_agent_left.test.ts:130:22 + 128| const r = make(); await r.c.start(); r.list[0].on.agentJoined(… + 129| await r.c.end(); await r.c.start(); r.list[1].on.agentJoined(); + 130| r.list[0].on.agentLeft(); await flush(); + | ^ + 131| expect(r.view()).toMatchObject({ phase: 'listening', notice: '… + 132| expect(r.list[1].closed).toBe(0); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/7]⎯ + + FAIL tests/voice.test.ts > her voice line on the stage > grows in her slot after your voice turn; the stored row +takes over under the same line keys +AssertionError: expected { id: 'voice-s', …(5) } to match object { role: 'companion', live: 'speaking' } +(4 matching properties omitted from actual) + +- Expected ++ Received + + { +- "live": "speaking", ++ "live": "generating", + "role": "companion", + } + + ❯ tests/voice.test.ts:538:25 + 536| const open: HerLine = { speech: 's', text: '在听呢,你刚才说到一半的那件事我一直… + 537| const growing = voiceLine(open, [you], 1)!; + 538| expect(growing).toMatchObject({ role: 'companion', live: 'spea… + | ^ + 539| // While she speaks, the sentence still forming waits; what sh… + 540| expect(stage([you, growing])).toEqual(['T:0:0=你在听吗?', 'T:1:0=在… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/7]⎯ + + FAIL tests/voice.test.ts > her voice line on the stage > a short spoken reply is on the stage while she says it, not +only once it is stored (HEL-898 f92a86f) +AssertionError: expected [ 'T:0:0=你在听吗?' ] to deeply equal [ 'T:0:0=你在听吗?', …(1) ] + +- Expected + + Test Files 3 failed | 7 passed (10) + Tests 7 failed | 111 passed (118) + Start at 18:42:23 + Duration 1.22s (transform 324ms, setup 0ms, collect 845ms, tests 781ms, environment 1ms, prepare 709ms) + ++ Received + + [ + "T:0:0=你在听吗?", +- "T:1:0=在听呢,你刚才说到一半的那件事我还在想。", + ] + + ❯ tests/voice.test.ts:555:44 + 553| expect(at('在听呢,')).toEqual(['T:0:0=你在听吗?']); + 554| expect(at('在听呢,你刚才说到一半的那件事')).toEqual(['T:0:0=你在听吗?']); + 555| expect(at('在听呢,你刚才说到一半的那件事我还在想。')).toEqual(['T:0:0=你在听吗?', 'T:… + | ^ + 556| // Her final and her stored row keep exactly that line under t… + 557| expect(stage([you, voiceLine({ speech: 's', text: '在听呢,你刚才说到一半… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/7]⎯ + diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/browser-hel878.log b/docs/rework/hel898/f92a86f-rework/author-logs/browser-hel878.log new file mode 100644 index 0000000..cc19376 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/browser-hel878.log @@ -0,0 +1,52 @@ +PASS status read on arrival only: no session, no microphone, no LiveKit client — GET /api/voice/status +PASS voice button offers to start, honestly labelled +PASS not configured: refusal in her own words, no dialog, no session, no microphone — 语音还没有在这台服务上配置,文字仍可使用。 +PASS TTS down: says her voice service is away — 她的声音服务这会儿不在,稍后再试试。文字仍可使用。 +PASS the dialog explains microphone, destination, same memory, how to stop, and the unverified path — 开始和栖语音? 浏览器会请求麦克风权限。你的声音只送到这台服务器上栖的语音服务,她用同一份记忆、同一段聊天回应你;你说的话和她的回答照常存进聊天记录。直接开口就能打断她。结束语音、离开交谈或关上页面时,麦克风立即关闭。 语音通路还没有经过真机验收,效果可能不稳定。 取消 开始语音 +PASS cancel: no microphone request, no session +PASS waiting for her is shown as waiting, not as connected — 在等栖接起… +结束语音 +PASS one session opened with a server token; one microphone; mic published to that room +PASS her worker joined: listening, wave in your colour, her audio attached — 在听你说 +结束语音 +PASS interim words show as a draft line beside the state +PASS your spoken words enter the stage from the server record (not from the subtitle) — 你:今天下班的路上看到一只猫 +PASS speaking: her name, and that you can cut in by speaking +PASS her spoken answer reaches the stage once it is in the record — 栖:是橘色的那只吗?它最近总在桥下晒太阳。 +PASS the record holds the voice turn once, same conversation as text +PASS φ line shown as heard, marked not recorded; not in the record +PASS text over voice: her remaining audio is muted +PASS …and unmuted once she is listening again +PASS text reply streaming +PASS voice over text: no failure notice, no retry, draft not refilled +PASS …the cut text turn is marked cancelled in the record; the voice words are there once — 下雨天的时候,你会想听什么? 栖 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳。 你 等等,我先打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 你 再给我讲讲那只猫 已取消 栖 我在。今天的雨停了没有?要是还在 已停止 · 这是未完成片段,未存入聊天记录 你 算了先不说了 +PASS blocked audio offers a tap +PASS …and the offer goes away after the tap +PASS 结束语音: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS ended: says the microphone is closed; DELETE sent — 语音已结束,麦克风已关闭。 +PASS late events after the end do not revive anything +PASS permission refused: told plainly, no session opened, text still there — 没有拿到麦克风权限,语音没有开始。文字仍可使用。 +PASS microphone revoked mid-call: call ends, said so — 麦克风被关掉了,语音已结束。 +PASS revoked: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS room dropped: call ends, said so — 通话断开了,麦克风已经关闭。文字仍可使用。 +PASS dropped: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS slow DELETE: dropped call says so and its microphone stops before the server answers — 通话断开了,麦克风已经关闭。文字仍可使用。 +PASS …a new call is up meanwhile: listening +PASS …the old DELETE answers late: the new call stays listening, no old notice, one live microphone, her audio, only the new room open — phase=listening status= open=sess-5 +PASS new call after the late old DELETE: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS server refusal shows its detail; microphone released — 她的声音服务这会儿不在,稍后再试试。 +PASS rapid off/on: exactly one open session and one live microphone — {"open":["sess-7"],"made":7} +PASS rapid toggle: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS nav away: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS Esc: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS 结束交谈: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS network lost: call ends with its own words — 网络断开了,语音已结束,麦克风已关闭。 +PASS network lost: microphone and her audio stop at once; the server room is owed, not pretended deleted — mics=0 audio=0 open=1 +PASS offline, after the network is back: microphone stopped, her audio and room gone, server room deleted — mics=0 audio=0 holder=false open=0 +PASS …the owed DELETE is sent once back online and forgotten +PASS page unload: the server room is deleted (keepalive) — DELETE /api/voice/session/sess-12 +PASS moon/phone: voice line fits the screen +PASS fog/phone: voice line fits the screen +PASS real livekit-client 2.22.4: every name the adapter uses exists — {"events":true,"values":["participantAttributesChanged","dataReceived","audioPlaybackChanged"],"mic":"microphone","agent":4,"create":"function","methods":true,"playback":"boolean","remote":true,"publish":"function","version":"2.22.4"} +PASS demo entry makes no voice requests +49/49 passed diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/browser-hel898.log b/docs/rework/hel898/f92a86f-rework/author-logs/browser-hel898.log new file mode 100644 index 0000000..2aff930 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/browser-hel898.log @@ -0,0 +1,284 @@ +PASS moon-desktop: her reply shows on the stage while she speaks, before it is stored — 你 + +今天下班的路上看到一只猫 + +你 + +今天下班的路上看到一只猫 + +栖 + +是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。 +PASS moon-desktop: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断 +「今天下班的路上看到一只猫」 +结束语音 +PASS moon-desktop: pause while she speaks +PASS moon-desktop: once stored, the record holds her reply exactly once (re-read, not written by the page) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 你 下雨天的时候,你会想听什么? 栖 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。 +PASS moon-desktop: paused before speaking +PASS moon-desktop: speaking resumes reading for the new turn +PASS moon-desktop: second reply growing — 你 + +你今天去哪儿了 + +你 + +你今天去哪儿了 + +栖 +还在说 + +对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。 +PASS moon-desktop: interrupted: her unfinished line is gone at once — 你 + +你今天去哪儿了 +PASS moon-desktop: late frames of the cut turn stay out +PASS moon-desktop: her line before you type — 你 + +我们聊点别的吧 + +你 + +我们聊点别的吧 + +栖 +还在说 + +那我们换个话题吧,你今天晚饭吃了什么,好不好吃? +PASS moon-desktop: text over voice: her line is gone, your words are on the stage — 你 + +先等等,我打字说 +PASS moon-desktop: reach-out shown in her place, word for word — 栖 +还在说 + +刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS moon-desktop: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message) +PASS moon-desktop: her line before hanging up — 你 + +我先去睡了 + +你 + +我先去睡了 + +栖 +还在说 + +晚安,明天见。今天说了好多好多话,你要早点睡哦。 +PASS moon-desktop: hung up: her line gone, voice off — 你 + +我先去睡了 +PASS fog-desktop: her reply shows on the stage while she speaks, before it is stored — 你 + +今天下班的路上看到一只猫 + +你 + +今天下班的路上看到一只猫 + +栖 + +是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。 +PASS fog-desktop: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断 +「今天下班的路上看到一只猫」 +结束语音 +PASS fog-desktop: pause while she speaks +PASS fog-desktop: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。 +PASS fog-desktop: paused before speaking +PASS fog-desktop: speaking resumes reading for the new turn +PASS fog-desktop: second reply growing — 你 + +你今天去哪儿了 + +你 + +你今天去哪儿了 + +栖 +还在说 + +对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。 +PASS fog-desktop: interrupted: her unfinished line is gone at once — 你 + +你今天去哪儿了 +PASS fog-desktop: late frames of the cut turn stay out +PASS fog-desktop: her line before you type — 你 + +我们聊点别的吧 + +你 + +我们聊点别的吧 + +栖 +还在说 + +那我们换个话题吧,你今天晚饭吃了什么,好不好吃? +PASS fog-desktop: text over voice: her line is gone, your words are on the stage — 你 + +先等等,我打字说 +PASS fog-desktop: reach-out shown in her place, word for word — 栖 +还在说 + +刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS fog-desktop: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message) +PASS fog-desktop: her line before hanging up — 你 + +我先去睡了 + +你 + +我先去睡了 + +栖 +还在说 + +晚安,明天见。今天说了好多好多话,你要早点睡哦。 +PASS fog-desktop: hung up: her line gone, voice off — 你 + +我先去睡了 +PASS moon-phone: her reply shows on the stage while she speaks, before it is stored — 你 + +今天下班的路上看到一只猫 + +你 + +今天下班的路上看到一只猫 + +栖 + +是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。 +PASS moon-phone: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断 +「今天下班的路上看到一只猫」 +结束语音 +PASS moon-phone: pause while she speaks +PASS moon-phone: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。 +PASS moon-phone: paused before speaking +PASS moon-phone: speaking resumes reading for the new turn +PASS moon-phone: second reply growing — 你 + +你今天去哪儿了 + +你 + +你今天去哪儿了 + +栖 +还在说 + +对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。 +PASS moon-phone: interrupted: her unfinished line is gone at once — 你 + +你今天去哪儿了 +PASS moon-phone: late frames of the cut turn stay out +PASS moon-phone: her line before you type — 你 + +我们聊点别的吧 + +你 + +我们聊点别的吧 + +栖 +还在说 + +那我们换个话题吧,你今天晚饭吃了什么,好不好吃? +PASS moon-phone: text over voice: her line is gone, your words are on the stage — 你 + +先等等,我打字说 +PASS moon-phone: reach-out shown in her place, word for word — 栖 +还在说 + +刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS moon-phone: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message) +PASS moon-phone: her line before hanging up — 你 + +我先去睡了 + +你 + +我先去睡了 + +栖 +还在说 + +晚安,明天见。今天说了好多好多话,你要早点睡哦。 +PASS moon-phone: hung up: her line gone, voice off — 你 + +我先去睡了 +PASS fog-phone: her reply shows on the stage while she speaks, before it is stored — 你 + +今天下班的路上看到一只猫 + +你 + +今天下班的路上看到一只猫 + +栖 + +是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。 +PASS fog-phone: your draft stays in the voice line, her words never there — 栖在说 · 开口就能打断 +「今天下班的路上看到一只猫」 +结束语音 +PASS fog-phone: pause while she speaks +PASS fog-phone: once stored, the record holds her reply exactly once (re-read, not written by the page) — 你 先等等,我打字说 栖 我在。今天的雨停了没有?要是还在下,就慢点回家。 栖 她主动说的 刚才路过花店,想起你说过喜欢白色的洋桔梗。 你 我先去睡了 你 今天下班的路上看到一只猫 栖 是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。你下次路过可以叫它一声,它会回头看你。 +PASS fog-phone: paused before speaking +PASS fog-phone: speaking resumes reading for the new turn +PASS fog-phone: second reply growing — 你 + +你今天去哪儿了 + +你 + +你今天去哪儿了 + +栖 +还在说 + +对了,我今天下午去了河边,看到一群白鹭从水面上飞过去了。 +PASS fog-phone: interrupted: her unfinished line is gone at once — 你 + +你今天去哪儿了 +PASS fog-phone: late frames of the cut turn stay out +PASS fog-phone: her line before you type — 你 + +我们聊点别的吧 + +你 + +我们聊点别的吧 + +栖 +还在说 + +那我们换个话题吧,你今天晚饭吃了什么,好不好吃? +PASS fog-phone: text over voice: her line is gone, your words are on the stage — 你 + +先等等,我打字说 +PASS fog-phone: reach-out shown in her place, word for word — 栖 +还在说 + +刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS fog-phone: reach-out cut: cleared; the page wrote nothing (no receipt, no read mark, no message) +PASS fog-phone: her line before hanging up — 你 + +我先去睡了 + +你 + +我先去睡了 + +栖 +还在说 + +晚安,明天见。今天说了好多好多话,你要早点睡哦。 +PASS fog-phone: hung up: her line gone, voice off — 你 + +我先去睡了 +PASS reduced motion: her words arrive — 你 + +慢一点说给我听 + +栖 + +是橘色的那只吗?它最近总在桥下晒太阳,尾巴一甩一甩的,好像整座桥都是它的。 +61/61 passed diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-after.log b/docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-after.log new file mode 100644 index 0000000..18c12ae --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-after.log @@ -0,0 +1,63 @@ +mock pid 11604 listening on 8879; CDP on 9499 +PASS fix 3: while the sentence is still forming, her half-said words wait (one or two whole sentences, as before) — 你 / / 你在听吗 +PASS fix 3: her short reply is on the stage while she speaks it, before anything is stored — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 (stored rows of hers then: 0) +PASS fix 3: it stays through the playout (seconds before the final) — 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fix 3: her final and her stored row take over the very same line (no re-made line, no replayed ink) — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS worker frames: your final is your line, her words never in the voice line — 在听你说 / 结束语音 +PASS worker frames: the stored row (same turn id) takes over exactly once — re-read, never written by the page — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fix 3: the next sentence joins the same line in place (same node, first ink untouched; the unfinished part waited) — 栖 / 还在说 / / 嗯。我在这儿,一直都在。 +PASS fix 3 negative: a long sentence shows its finished lines only, never the whole text at once — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 栖 / 还在说 / / 我下午去了河边,看到一群白鹭从水面上飞过去了,它们飞得很低,几乎贴着水,翅膀尖在水面上划出一道一道细细的白线, +PASS a cancelled reply leaves at once — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS worker frames: interrupted — her unfinished line is gone at once, no final exists for the cut turn — 你 / / 讲讲你下午做了什么 +PASS negative (raw): late frames of the cut speech, fresh seqs — refused by the speech gate — 你 / 本轮已取消 / / 讲讲你下午做了什么 +PASS negative (raw): a replayed seq and out-of-order lower seqs — refused by the seq gate — 你 / 本轮已取消 / / 讲讲你下午做了什么 +PASS worker frames: the next turn lands clean (one stored row of its own) — 么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 你 / / 讲讲你下午做了什么 / / 已取消 / 你 / / 那后来呢 / / 栖 / / 后来我在桥上站了一会儿,风把水面吹皱了。 +PASS worker frames: phi is heard in the voice line, not stored as her reply — 在听你说 / 栖:(她顿了一下)这会儿我有点说不出来话……未记入聊天 / 结束语音 +PASS worker frames: no legal receipt — nothing of that reply stays (cancel only, never a final) — 你 / / 再讲讲那座桥 +PASS worker frames: reach-out read aloud is shown word for word while she speaks it — 栖 / 还在说 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS worker frames: an interrupted reach-out clears; the page still wrote nothing (no receipt, no read mark) +PASS text over voice: her worker line is gone at once, your typed words are on the stage — 你 / / 先等等,我打字跟你说 +PASS fix 2 (raw probes): a long-retired speech re-sent with fresh seqs never comes back (watched 12 s) — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS fix 2: a new speech after the probes still comes in — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / 还在说 / / 还在呢。 +PASS dropped mid-sentence: her line and the call clear together — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS a new call is a new room and a new sequence (from 1): her words flow again, nothing of the old call remains — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / 还在说 / / 又见面了。 +PASS moon-desktop fix 1 negative: someone else leaving does not hang up — listening +PASS moon-desktop fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS moon-desktop fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-3 +PASS moon-desktop fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS fix 1 negative: an old room's late worker leave does not end the new call — listening +PASS fix 1 negative: your own hang-up — one DELETE, no "she left" notice — DELETE /api/voice/session/sess-4 | GET /api/messages?limit=50 +PASS fog-desktop: her short reply is on the stage while she speaks it (before the final), approved fog desktop — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-desktop: the stored row takes over once on fog desktop (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-desktop fix 1 negative: someone else leaving does not hang up — listening +PASS fog-desktop fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS fog-desktop fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-5 +PASS fog-desktop fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS moon-phone: her short reply is on the stage while she speaks it (before the final), approved moon phone — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS moon-phone: the stored row takes over once on moon phone (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS moon-phone fix 1 negative: someone else leaving does not hang up — listening +PASS moon-phone fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS moon-phone fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-6 +PASS moon-phone fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS fog-phone: her short reply is on the stage while she speaks it (before the final), approved fog phone — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-phone: the stored row takes over once on fog phone (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-phone fix 1 negative: someone else leaving does not hang up — listening +PASS fog-phone fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS fog-phone fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-7 +PASS fog-phone fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS reduced-motion: her short reply is on the stage while she speaks it (before the final), approved moon desktop — 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS reduced motion: her words arrive without ink or slide +PASS reduced-motion: the stored row takes over once on moon desktop (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS reduced-motion fix 1 negative: someone else leaving does not hang up — listening +PASS reduced-motion fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS reduced-motion fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-8 +PASS reduced-motion fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +video v1-moon-desktop-short-reply-while-speaking.webm: 129 real frames, 8.1 s +video v2-moon-desktop-worker-leaves.webm: 58 real frames, 3.1 s +video v2-fog-desktop-worker-leaves.webm: 64 real frames, 3.2 s +video v3-moon-phone-short-reply-while-speaking.webm: 259 real frames, 4.3 s +video v2-moon-phone-worker-leaves.webm: 199 real frames, 3.3 s +video v2-fog-phone-worker-leaves.webm: 200 real frames, 3.3 s +video v3-reduced-motion-short-reply-while-speaking.webm: 74 real frames, 4.2 s +video v2-reduced-motion-worker-leaves.webm: 62 real frames, 3.3 s +53/53 passed diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-before.log b/docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-before.log new file mode 100644 index 0000000..4c0e244 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-before.log @@ -0,0 +1,63 @@ +mock pid 10368 listening on 8879; CDP on 9499 +PASS fix 3: while the sentence is still forming, her half-said words wait (one or two whole sentences, as before) — 你 / / 你在听吗 +FAIL fix 3: her short reply is on the stage while she speaks it, before anything is stored — 你 / / 你在听吗 (stored rows of hers then: 0) +FAIL fix 3: it stays through the playout (seconds before the final) — 你 / / 你在听吗 +FAIL fix 3: her final and her stored row take over the very same line (no re-made line, no replayed ink) — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS worker frames: your final is your line, her words never in the voice line — 在听你说 / 结束语音 +PASS worker frames: the stored row (same turn id) takes over exactly once — re-read, never written by the page — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +FAIL fix 3: the next sentence joins the same line in place (same node, first ink untouched; the unfinished part waited) — 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fix 3 negative: a long sentence shows its finished lines only, never the whole text at once — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 栖 / 还在说 / / 我下午去了河边,看到一群白鹭从水面上飞过去了,它们飞得很低,几乎贴着水,翅膀尖在水面上划出一道一道细细的白线, +PASS a cancelled reply leaves at once — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS worker frames: interrupted — her unfinished line is gone at once, no final exists for the cut turn — 你 / / 讲讲你下午做了什么 +PASS negative (raw): late frames of the cut speech, fresh seqs — refused by the speech gate — 你 / 本轮已取消 / / 讲讲你下午做了什么 +PASS negative (raw): a replayed seq and out-of-order lower seqs — refused by the seq gate — 你 / 本轮已取消 / / 讲讲你下午做了什么 +PASS worker frames: the next turn lands clean (one stored row of its own) — 么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 你 / / 讲讲你下午做了什么 / / 已取消 / 你 / / 那后来呢 / / 栖 / / 后来我在桥上站了一会儿,风把水面吹皱了。 +PASS worker frames: phi is heard in the voice line, not stored as her reply — 在听你说 / 栖:(她顿了一下)这会儿我有点说不出来话……未记入聊天 / 结束语音 +PASS worker frames: no legal receipt — nothing of that reply stays (cancel only, never a final) — 你 / / 再讲讲那座桥 +PASS worker frames: reach-out read aloud is shown word for word while she speaks it — 栖 / 还在说 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS worker frames: an interrupted reach-out clears; the page still wrote nothing (no receipt, no read mark) +PASS text over voice: her worker line is gone at once, your typed words are on the stage — 你 / / 先等等,我打字跟你说 +FAIL fix 2 (raw probes): a long-retired speech re-sent with fresh seqs never comes back (watched 12 s) — 你 / / 先等等,我打字跟你说 / / 栖 / 还在说 / / 我在。今天的雨停了没有?要是还在下,就慢点回家。 / / 栖 / / 见到了,它还在桥下晒太阳。 / / 你 / / 先等等,我打字跟你说 / / 栖 / / 被挤出后的重放终稿 +FAIL fix 2: a new speech after the probes still comes in — 你 / / 先等等,我打字跟你说 / / 栖 / 还在说 / / 我在。今天的雨停了没有?要是还在下,就慢点回家。 +PASS dropped mid-sentence: her line and the call clear together — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +FAIL a new call is a new room and a new sequence (from 1): her words flow again, nothing of the old call remains — 栖 / 还在说 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS moon-desktop fix 1 negative: someone else leaving does not hang up — listening +FAIL moon-desktop fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — mic=live holder=true +FAIL moon-desktop fix 1: this call's server room is deleted (once) — GET /api/messages?limit=50 +PASS moon-desktop fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) +FAIL fix 1 negative: an old room's late worker leave does not end the new call — off 语音已结束,麦克风已关闭。 +FAIL fix 1 negative: your own hang-up — one DELETE, no "she left" notice — GET /api/messages?limit=50 +FAIL fog-desktop: her short reply is on the stage while she speaks it (before the final), approved fog desktop — 你 / / 你在听吗 +PASS fog-desktop: the stored row takes over once on fog desktop (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-desktop fix 1 negative: someone else leaving does not hang up — listening +FAIL fog-desktop fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — mic=live holder=true +FAIL fog-desktop fix 1: this call's server room is deleted (once) — GET /api/messages?limit=50 +PASS fog-desktop fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) +FAIL moon-phone: her short reply is on the stage while she speaks it (before the final), approved moon phone — 你 / / 你在听吗 +PASS moon-phone: the stored row takes over once on moon phone (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS moon-phone fix 1 negative: someone else leaving does not hang up — listening +FAIL moon-phone fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — mic=live holder=true +FAIL moon-phone fix 1: this call's server room is deleted (once) — GET /api/messages?limit=50 +PASS moon-phone fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) +FAIL fog-phone: her short reply is on the stage while she speaks it (before the final), approved fog phone — 你 / / 你在听吗 +PASS fog-phone: the stored row takes over once on fog phone (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-phone fix 1 negative: someone else leaving does not hang up — listening +FAIL fog-phone fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — mic=live holder=true +FAIL fog-phone fix 1: this call's server room is deleted (once) — GET /api/messages?limit=50 +PASS fog-phone fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) +FAIL reduced-motion: her short reply is on the stage while she speaks it (before the final), approved moon desktop — 你 / / 你在听吗 +PASS reduced motion: her words arrive without ink or slide +PASS reduced-motion: the stored row takes over once on moon desktop (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS reduced-motion fix 1 negative: someone else leaving does not hang up — listening +FAIL reduced-motion fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — mic=live holder=true +FAIL reduced-motion fix 1: this call's server room is deleted (once) — GET /api/messages?limit=50 +PASS reduced-motion fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) +video v1-moon-desktop-short-reply-while-speaking.webm: 229 real frames, 14.0 s +video v2-moon-desktop-worker-leaves.webm: 221 real frames, 12.3 s +video v2-fog-desktop-worker-leaves.webm: 226 real frames, 12.2 s +video v3-moon-phone-short-reply-while-speaking.webm: 744 real frames, 12.4 s +video v2-moon-phone-worker-leaves.webm: 739 real frames, 12.3 s +video v2-fog-phone-worker-leaves.webm: 742 real frames, 12.3 s +video v3-reduced-motion-short-reply-while-speaking.webm: 31 real frames, 1.9 s +video v2-reduced-motion-worker-leaves.webm: 51 real frames, 2.8 s +30/53 passed diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/build.log b/docs/rework/hel898/f92a86f-rework/author-logs/build.log new file mode 100644 index 0000000..023bf83 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/build.log @@ -0,0 +1,9 @@ +vite v6.3.6 building for production... +transforming... +✓ 57 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.49 kB │ gzip: 0.35 kB +dist/assets/index-BpZaybfD.css 83.54 kB │ gzip: 17.53 kB +dist/assets/index-C23OdPgW.js 311.71 kB │ gzip: 103.75 kB +✓ built in 710ms diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/lint.log b/docs/rework/hel898/f92a86f-rework/author-logs/lint.log new file mode 100644 index 0000000..722aac5 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/lint.log @@ -0,0 +1,6 @@ + +C:\Users\Lucian\multica_workspaces_desktop-api.solsum.cn\hello-af23464d222e\hel-898-a8c1036322cf\workdir\src\web\src\chat\ChatPanel.tsx + 240:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps + +✖ 1 problem (0 errors, 1 warning) + diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.err b/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.err new file mode 100644 index 0000000..e69de29 diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.out b/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.out new file mode 100644 index 0000000..c2b1e46 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.out @@ -0,0 +1 @@ +hel878 mock on 8878 pid 10808 diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.err b/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.err new file mode 100644 index 0000000..e69de29 diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.out b/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.out new file mode 100644 index 0000000..fd1d45b --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.out @@ -0,0 +1 @@ +hel878 mock on 8898 pid 11512 diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/typecheck.log b/docs/rework/hel898/f92a86f-rework/author-logs/typecheck.log new file mode 100644 index 0000000..e69de29 diff --git a/docs/rework/hel898/f92a86f-rework/author-logs/vitest.log b/docs/rework/hel898/f92a86f-rework/author-logs/vitest.log new file mode 100644 index 0000000..ebe9d20 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/author-logs/vitest.log @@ -0,0 +1,19 @@ + + RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-898-a8c1036322cf/workdir/src/web + + ✓ tests/hel879_frames.test.ts (10 tests) 36ms + ✓ tests/inbox.test.ts (13 tests) 40ms + ✓ tests/reading.test.ts (11 tests) 50ms + ✓ tests/hel898_agent_left.test.ts (7 tests) 159ms + ✓ tests/voice.test.ts (45 tests) 436ms + ✓ tests/r2wiring.test.ts (11 tests) 44ms + ✓ tests/domain.test.ts (12 tests) 11ms + ✓ tests/r3memory.test.ts (5 tests) 19ms + ✓ tests/real.test.ts (3 tests) 23ms + ✓ tests/review878_late_cleanup.test.ts (1 test) 15ms + + Test Files 10 passed (10) + Tests 118 passed (118) + Start at 18:42:34 + Duration 1.16s (transform 268ms, setup 0ms, collect 727ms, tests 833ms, environment 1ms, prepare 641ms) + diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a-handoff.log b/docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a-handoff.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a-handoff.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a.log b/docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/browser-launch-failed.log b/docs/rework/hel898/f92a86f-rework/manager-checks/browser-launch-failed.log new file mode 100644 index 0000000..6334c21 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/browser-launch-failed.log @@ -0,0 +1,9 @@ +mock pid 1662067 listening on 48929; CDP on 59079 +file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-898-733218c495b1/workdir/CyberGril/web/tests/hel879_unified_shots.mjs:48 +const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl); + ^ + +TypeError: Cannot read properties of undefined (reading 'find') + at file:///home/xiaomo/multica_workspaces/hello-af23464d222e/hel-898-733218c495b1/workdir/CyberGril/web/tests/hel879_unified_shots.mjs:48:34 + +Node.js v24.16.0 diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/browser.log b/docs/rework/hel898/f92a86f-rework/manager-checks/browser.log new file mode 100644 index 0000000..bd0e65f --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/browser.log @@ -0,0 +1,63 @@ +mock pid 1662635 listening on 52819; CDP on 59361 +PASS fix 3: while the sentence is still forming, her half-said words wait (one or two whole sentences, as before) — 你 / / 你在听吗 +PASS fix 3: her short reply is on the stage while she speaks it, before anything is stored — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 (stored rows of hers then: 0) +PASS fix 3: it stays through the playout (seconds before the final) — 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fix 3: her final and her stored row take over the very same line (no re-made line, no replayed ink) — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS worker frames: your final is your line, her words never in the voice line — 在听你说 / 结束语音 +PASS worker frames: the stored row (same turn id) takes over exactly once — re-read, never written by the page — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fix 3: the next sentence joins the same line in place (same node, first ink untouched; the unfinished part waited) — 栖 / 还在说 / / 嗯。我在这儿,一直都在。 +PASS fix 3 negative: a long sentence shows its finished lines only, never the whole text at once — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 栖 / 还在说 / / 我下午去了河边,看到一群白鹭从水面上飞过去了,它们飞得很低,几乎贴着水,翅膀尖在水面上划出一道一道细细的白线, +PASS a cancelled reply leaves at once — 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS worker frames: interrupted — her unfinished line is gone at once, no final exists for the cut turn — 你 / 本轮已取消 / / 讲讲你下午做了什么 +PASS negative (raw): late frames of the cut speech, fresh seqs — refused by the speech gate — 你 / 本轮已取消 / / 讲讲你下午做了什么 +PASS negative (raw): a replayed seq and out-of-order lower seqs — refused by the seq gate — 你 / 本轮已取消 / / 讲讲你下午做了什么 +PASS worker frames: the next turn lands clean (one stored row of its own) — 么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 / / 你 / / 讲讲你下午做了什么 / / 已取消 / 你 / / 那后来呢 / / 栖 / / 后来我在桥上站了一会儿,风把水面吹皱了。 +PASS worker frames: phi is heard in the voice line, not stored as her reply — 在听你说 / 栖:(她顿了一下)这会儿我有点说不出来话……未记入聊天 / 结束语音 +PASS worker frames: no legal receipt — nothing of that reply stays (cancel only, never a final) — 你 / / 再讲讲那座桥 +PASS worker frames: reach-out read aloud is shown word for word while she speaks it — 栖 / 还在说 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS worker frames: an interrupted reach-out clears; the page still wrote nothing (no receipt, no read mark) +PASS text over voice: her worker line is gone at once, your typed words are on the stage — 你 / / 先等等,我打字跟你说 +PASS fix 2 (raw probes): a long-retired speech re-sent with fresh seqs never comes back (watched 12 s) — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS fix 2: a new speech after the probes still comes in — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / 还在说 / / 还在呢。 +PASS dropped mid-sentence: her line and the call clear together — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 +PASS a new call is a new room and a new sequence (from 1): her words flow again, nothing of the old call remains — 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / / 刚才路过花店,想起你说过喜欢白色的洋桔梗。 / / 栖 / 还在说 / / 又见面了。 +PASS moon-desktop fix 1 negative: someone else leaving does not hang up — listening +PASS moon-desktop fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS moon-desktop fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-3 +PASS moon-desktop fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS fix 1 negative: an old room's late worker leave does not end the new call — listening +PASS fix 1 negative: your own hang-up — one DELETE, no "she left" notice — DELETE /api/voice/session/sess-4 | GET /api/messages?limit=50 +PASS fog-desktop: her short reply is on the stage while she speaks it (before the final), approved fog desktop — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-desktop: the stored row takes over once on fog desktop (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-desktop fix 1 negative: someone else leaving does not hang up — listening +PASS fog-desktop fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS fog-desktop fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-5 +PASS fog-desktop fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS moon-phone: her short reply is on the stage while she speaks it (before the final), approved moon phone — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS moon-phone: the stored row takes over once on moon phone (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS moon-phone fix 1 negative: someone else leaving does not hang up — listening +PASS moon-phone fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS moon-phone fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-6 +PASS moon-phone fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS fog-phone: her short reply is on the stage while she speaks it (before the final), approved fog phone — 你 / / 你在听吗 / / 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-phone: the stored row takes over once on fog phone (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS fog-phone fix 1 negative: someone else leaving does not hang up — listening +PASS fog-phone fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS fog-phone fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-7 +PASS fog-phone fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +PASS reduced-motion: her short reply is on the stage while she speaks it (before the final), approved moon desktop — 你 / / 你在听吗 / / 栖 / 还在说 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS reduced motion: her words arrive without ink or slide +PASS reduced-motion: the stored row takes over once on moon desktop (isolated record) — 内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。 / / 你 / / 下雨天的时候,你会想听什么? / / 栖 / / 会想把窗开一条缝,听雨落在铁皮檐上。你呢,今天累不累? / / 你 / / 你在听吗 / / 栖 / / 在听呢,你刚才说到一半的那件事我还在想。 +PASS reduced-motion fix 1 negative: someone else leaving does not hang up — listening +PASS reduced-motion fix 1: the call ends at once — her line cleared, honest notice, microphone off, her audio gone — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 mic=ended holder=false +PASS reduced-motion fix 1: this call's server room is deleted (once) — DELETE /api/voice/session/sess-8 +PASS reduced-motion fix 1: a late leave of that room changes nothing more (no second notice, no second DELETE) — 她那边的语音中断了,这次通话已结束,麦克风已经关闭。文字仍可使用。 +video v1-moon-desktop-short-reply-while-speaking.webm: 161 real frames, 8.1 s +video v2-moon-desktop-worker-leaves.webm: 72 real frames, 3.1 s +video v2-fog-desktop-worker-leaves.webm: 70 real frames, 3.4 s +video v3-moon-phone-short-reply-while-speaking.webm: 258 real frames, 4.3 s +video v2-moon-phone-worker-leaves.webm: 198 real frames, 3.3 s +video v2-fog-phone-worker-leaves.webm: 199 real frames, 3.3 s +video v3-reduced-motion-short-reply-while-speaking.webm: 91 real frames, 4.2 s +video v2-reduced-motion-worker-leaves.webm: 79 real frames, 3.3 s +53/53 passed diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/build.log b/docs/rework/hel898/f92a86f-rework/manager-checks/build.log new file mode 100644 index 0000000..bd49dd7 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/build.log @@ -0,0 +1,13 @@ + +> cybergril-web@0.1.0 build +> tsc --noEmit && vite build + +vite v6.3.6 building for production... +transforming... +✓ 57 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.49 kB │ gzip: 0.35 kB +dist/assets/index-BpZaybfD.css 83.54 kB │ gzip: 17.53 kB +dist/assets/index-C23OdPgW.js 311.71 kB │ gzip: 103.75 kB +✓ built in 637ms diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20-handoff.log b/docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20-handoff.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20-handoff.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20.log b/docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/lint.log b/docs/rework/hel898/f92a86f-rework/manager-checks/lint.log new file mode 100644 index 0000000..5e13c2f --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/lint.log @@ -0,0 +1,10 @@ + +> cybergril-web@0.1.0 lint +> eslint src + + +/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-898-733218c495b1/workdir/CyberGril/web/src/chat/ChatPanel.tsx + 240:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps + +✖ 1 problem (0 errors, 1 warning) + diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/origin-main-handoff.log b/docs/rework/hel898/f92a86f-rework/manager-checks/origin-main-handoff.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/origin-main-handoff.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/origin-main.log b/docs/rework/hel898/f92a86f-rework/manager-checks/origin-main.log new file mode 100644 index 0000000..8d7e4a5 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/origin-main.log @@ -0,0 +1 @@ +需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。 diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/seq-renumbering.json b/docs/rework/hel898/f92a86f-rework/manager-checks/seq-renumbering.json new file mode 100644 index 0000000..153af04 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/seq-renumbering.json @@ -0,0 +1,724 @@ +[ + { + "call": 1, + "from": "worker-normal", + "orig_seq": 1, + "seq": 1, + "kind": "interim" + }, + { + "call": 1, + "from": "worker-normal", + "orig_seq": 2, + "seq": 2, + "kind": "user_final" + }, + { + "call": 1, + "from": "worker-normal", + "orig_seq": 3, + "seq": 3, + "kind": "turn_start", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 1, + "from": "worker-normal", + "orig_seq": 4, + "seq": 4, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 1, + "from": "worker-normal", + "orig_seq": 5, + "seq": 5, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 1, + "from": "worker-normal", + "orig_seq": 6, + "seq": 6, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 1, + "from": "worker-normal", + "orig_seq": 7, + "seq": 7, + "kind": "assistant_final", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 1, + "from": "worker-normal", + "orig_seq": 8, + "seq": 8, + "kind": "turn_end", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 9, + "kind": "turn_start", + "speech_id": "speech_grow" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 10, + "kind": "assistant_interim", + "speech_id": "speech_grow" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 11, + "kind": "assistant_interim", + "speech_id": "speech_grow" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 12, + "kind": "assistant_interim", + "speech_id": "speech_grow" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 13, + "kind": "turn_cancel", + "speech_id": "speech_grow" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 14, + "kind": "turn_start", + "speech_id": "speech_long" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 15, + "kind": "assistant_interim", + "speech_id": "speech_long" + }, + { + "call": 1, + "from": "driver", + "orig_seq": 0, + "seq": 16, + "kind": "turn_cancel", + "speech_id": "speech_long" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 1, + "seq": 17, + "kind": "user_final" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 2, + "seq": 18, + "kind": "turn_start", + "speech_id": "speech_dd1c4c382512" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 3, + "seq": 19, + "kind": "assistant_interim", + "speech_id": "speech_dd1c4c382512" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 4, + "seq": 20, + "kind": "assistant_interim", + "speech_id": "speech_dd1c4c382512" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 5, + "seq": 21, + "kind": "turn_cancel", + "speech_id": "speech_dd1c4c382512" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 6, + "seq": 24, + "kind": "user_final" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 7, + "seq": 25, + "kind": "turn_start", + "speech_id": "speech_55ef57a410cf" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 8, + "seq": 26, + "kind": "assistant_interim", + "speech_id": "speech_55ef57a410cf" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 9, + "seq": 27, + "kind": "assistant_interim", + "speech_id": "speech_55ef57a410cf" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 10, + "seq": 28, + "kind": "assistant_final", + "speech_id": "speech_55ef57a410cf" + }, + { + "call": 1, + "from": "worker-interrupt", + "orig_seq": 11, + "seq": 29, + "kind": "turn_end", + "speech_id": "speech_55ef57a410cf" + }, + { + "call": 1, + "from": "worker-phi", + "orig_seq": 1, + "seq": 30, + "kind": "user_final" + }, + { + "call": 1, + "from": "worker-phi", + "orig_seq": 2, + "seq": 31, + "kind": "turn_start", + "speech_id": "speech_7a5630ba8cc7" + }, + { + "call": 1, + "from": "worker-phi", + "orig_seq": 3, + "seq": 32, + "kind": "assistant_interim", + "speech_id": "speech_7a5630ba8cc7" + }, + { + "call": 1, + "from": "worker-phi", + "orig_seq": 4, + "seq": 33, + "kind": "assistant_interim", + "speech_id": "speech_7a5630ba8cc7" + }, + { + "call": 1, + "from": "worker-phi", + "orig_seq": 5, + "seq": 34, + "kind": "assistant_interim", + "speech_id": "speech_7a5630ba8cc7" + }, + { + "call": 1, + "from": "worker-phi", + "orig_seq": 6, + "seq": 35, + "kind": "error", + "speech_id": "speech_7a5630ba8cc7" + }, + { + "call": 1, + "from": "worker-phi", + "orig_seq": 7, + "seq": 36, + "kind": "turn_cancel", + "speech_id": "speech_7a5630ba8cc7" + }, + { + "call": 1, + "from": "worker-no_receipt", + "orig_seq": 1, + "seq": 37, + "kind": "user_final" + }, + { + "call": 1, + "from": "worker-no_receipt", + "orig_seq": 2, + "seq": 38, + "kind": "turn_start", + "speech_id": "speech_20d31bfb084a" + }, + { + "call": 1, + "from": "worker-no_receipt", + "orig_seq": 3, + "seq": 39, + "kind": "assistant_interim", + "speech_id": "speech_20d31bfb084a" + }, + { + "call": 1, + "from": "worker-no_receipt", + "orig_seq": 4, + "seq": 40, + "kind": "assistant_interim", + "speech_id": "speech_20d31bfb084a" + }, + { + "call": 1, + "from": "worker-no_receipt", + "orig_seq": 5, + "seq": 41, + "kind": "turn_cancel", + "speech_id": "speech_20d31bfb084a" + }, + { + "call": 1, + "from": "worker-proactive", + "orig_seq": 1, + "seq": 42, + "kind": "proactive_start", + "speech_id": "speech_172abc64e23c" + }, + { + "call": 1, + "from": "worker-proactive", + "orig_seq": 2, + "seq": 43, + "kind": "proactive_end", + "speech_id": "speech_172abc64e23c" + }, + { + "call": 1, + "from": "worker-proactive", + "orig_seq": 3, + "seq": 44, + "kind": "proactive_start", + "speech_id": "speech_835f928b989d" + }, + { + "call": 1, + "from": "worker-proactive", + "orig_seq": 4, + "seq": 45, + "kind": "proactive_cancel", + "speech_id": "speech_835f928b989d" + }, + { + "call": 1, + "from": "worker-normal (speech id renamed)", + "orig_seq": 3, + "seq": 46, + "kind": "turn_start", + "speech_id": "speech_text_takeover" + }, + { + "call": 1, + "from": "worker-normal (speech id renamed)", + "orig_seq": 6, + "seq": 47, + "kind": "assistant_interim", + "speech_id": "speech_text_takeover" + }, + { + "call": 2, + "from": "driver", + "orig_seq": 0, + "seq": 149, + "kind": "turn_start", + "speech_id": "speech_drop" + }, + { + "call": 2, + "from": "driver", + "orig_seq": 0, + "seq": 150, + "kind": "assistant_interim", + "speech_id": "speech_drop" + }, + { + "call": 3, + "from": "driver", + "orig_seq": 0, + "seq": 1, + "kind": "turn_start", + "speech_id": "speech_fresh" + }, + { + "call": 3, + "from": "driver", + "orig_seq": 0, + "seq": 2, + "kind": "assistant_interim", + "speech_id": "speech_fresh" + }, + { + "call": 3, + "from": "driver", + "orig_seq": 0, + "seq": 3, + "kind": "turn_end", + "speech_id": "speech_fresh" + }, + { + "call": 3, + "from": "driver", + "orig_seq": 0, + "seq": 4, + "kind": "turn_start", + "speech_id": "speech_moon-desktop_left" + }, + { + "call": 3, + "from": "driver", + "orig_seq": 0, + "seq": 5, + "kind": "assistant_interim", + "speech_id": "speech_moon-desktop_left" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 1, + "seq": 1, + "kind": "interim" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 2, + "seq": 2, + "kind": "user_final" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 3, + "seq": 3, + "kind": "turn_start", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 4, + "seq": 4, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 5, + "seq": 5, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 6, + "seq": 6, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 7, + "seq": 7, + "kind": "assistant_final", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 5, + "from": "worker-normal", + "orig_seq": 8, + "seq": 8, + "kind": "turn_end", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 5, + "from": "driver", + "orig_seq": 0, + "seq": 9, + "kind": "turn_start", + "speech_id": "speech_fog-desktop_left" + }, + { + "call": 5, + "from": "driver", + "orig_seq": 0, + "seq": 10, + "kind": "assistant_interim", + "speech_id": "speech_fog-desktop_left" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 1, + "seq": 1, + "kind": "interim" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 2, + "seq": 2, + "kind": "user_final" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 3, + "seq": 3, + "kind": "turn_start", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 4, + "seq": 4, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 5, + "seq": 5, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 6, + "seq": 6, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 7, + "seq": 7, + "kind": "assistant_final", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 6, + "from": "worker-normal", + "orig_seq": 8, + "seq": 8, + "kind": "turn_end", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 6, + "from": "driver", + "orig_seq": 0, + "seq": 9, + "kind": "turn_start", + "speech_id": "speech_moon-phone_left" + }, + { + "call": 6, + "from": "driver", + "orig_seq": 0, + "seq": 10, + "kind": "assistant_interim", + "speech_id": "speech_moon-phone_left" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 1, + "seq": 1, + "kind": "interim" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 2, + "seq": 2, + "kind": "user_final" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 3, + "seq": 3, + "kind": "turn_start", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 4, + "seq": 4, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 5, + "seq": 5, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 6, + "seq": 6, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 7, + "seq": 7, + "kind": "assistant_final", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 7, + "from": "worker-normal", + "orig_seq": 8, + "seq": 8, + "kind": "turn_end", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 7, + "from": "driver", + "orig_seq": 0, + "seq": 9, + "kind": "turn_start", + "speech_id": "speech_fog-phone_left" + }, + { + "call": 7, + "from": "driver", + "orig_seq": 0, + "seq": 10, + "kind": "assistant_interim", + "speech_id": "speech_fog-phone_left" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 1, + "seq": 1, + "kind": "interim" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 2, + "seq": 2, + "kind": "user_final" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 3, + "seq": 3, + "kind": "turn_start", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 4, + "seq": 4, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 5, + "seq": 5, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 6, + "seq": 6, + "kind": "assistant_interim", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 7, + "seq": 7, + "kind": "assistant_final", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 8, + "from": "worker-normal", + "orig_seq": 8, + "seq": 8, + "kind": "turn_end", + "speech_id": "speech_17d892eb3dc3" + }, + { + "call": 8, + "from": "driver", + "orig_seq": 0, + "seq": 9, + "kind": "turn_start", + "speech_id": "speech_reduced-motion_left" + }, + { + "call": 8, + "from": "driver", + "orig_seq": 0, + "seq": 10, + "kind": "assistant_interim", + "speech_id": "speech_reduced-motion_left" + } +] \ No newline at end of file diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/test.log b/docs/rework/hel898/f92a86f-rework/manager-checks/test.log new file mode 100644 index 0000000..c0784ca --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/test.log @@ -0,0 +1,23 @@ + +> cybergril-web@0.1.0 test +> vitest run + + + RUN v3.1.2 /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-898-733218c495b1/workdir/CyberGril/web + + ✓ tests/r3memory.test.ts (5 tests) 9ms + ✓ tests/review878_late_cleanup.test.ts (1 test) 5ms + ✓ tests/inbox.test.ts (13 tests) 26ms + ✓ tests/hel879_frames.test.ts (10 tests) 22ms + ✓ tests/real.test.ts (3 tests) 12ms + ✓ tests/hel898_agent_left.test.ts (7 tests) 29ms + ✓ tests/reading.test.ts (11 tests) 35ms + ✓ tests/r2wiring.test.ts (11 tests) 33ms + ✓ tests/domain.test.ts (12 tests) 9ms + ✓ tests/voice.test.ts (45 tests) 77ms + + Test Files 10 passed (10) + Tests 118 passed (118) + Start at 07:08:52 + Duration 455ms (transform 906ms, setup 0ms, collect 1.83s, tests 258ms, environment 1ms, prepare 537ms) + diff --git a/docs/rework/hel898/f92a86f-rework/manager-checks/typecheck.log b/docs/rework/hel898/f92a86f-rework/manager-checks/typecheck.log new file mode 100644 index 0000000..5781d36 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/manager-checks/typecheck.log @@ -0,0 +1,4 @@ + +> cybergril-web@0.1.0 typecheck +> tsc --noEmit + diff --git a/docs/rework/hel898/f92a86f-rework/preservation-review.md b/docs/rework/hel898/f92a86f-rework/preservation-review.md new file mode 100644 index 0000000..86cbbaa --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/preservation-review.md @@ -0,0 +1,26 @@ +HEL-898 三项返修保全与独立复测(总管,2026-10-27) + +本次作者来源:HEL-898评论01a17a78-52d4-7a1e-b677-3137dc15801b,附件01a17a77-e455-775a-a835-ba21ad5fb9bf。ZIP SHA256为18501ab5c9c88ec91ede97818e7098eb166792344fed272929a4493bb8abb355;补丁SHA256为ba1aa445ff7e3be6c4f1ae60866c38a793f06d564f2edc39c21d1fa1142b4c14。原包、源码与MANIFEST已核对,12文件逐字节相等。 + +作者输入f92a86f5d8f839338311cdee08179049de34eb20;本次实际追加目标为b0dcb17e1acd970f706763efe0fcb7062596039a,保留后续11fd0e2/9756db3/b0dcb17材料勘误,不覆盖它们。12个改前哈希与最新目标全部相等,git apply --check通过,应用结果逐字节等于附件源码。只含12文件的代码保全提交9254c1731674f4c5f16acba59f06c5f4bca41171;后续记录提交不改作者产品源码或历史handoff。总管没有修改hel-879-assembly或PR11,隔离保全分支供原总工正常快进整合,本轮不另建PR。 + +需求仍为v1.11/60项,入口及全部原始来源相对上次完整读取逐字节未变(已对照AGENTS.md、README/current/coverage/changes与sources);R5/D1/D3、认可视觉及作者分工、成熟复用、完整体验委托继续有效。关联CG-004/006/010/011/018/020、VIS-001–013、UX-001/002/005/006、AM-001、GOV-001–006、DEL-001;无需求变更。 + +独立工程检查:118/118 vitest(10文件)、typecheck、build通过;lint0错1条既有fullButton ref警告。原版Python需求检查对origin/main、作者输入f92a86f、实际追加目标b0dcb17,各带/不带handoff-HEL-898.json,共6/6通过。使用原有Node与同锁既有node_modules,没有下载安装。未改后端/协议、视觉布局/样式、配置或绑定,未重跑已通过且零差异的后台内核。 + +依emil-skills的emil-design-eng核对本次动效接线(既有视觉规范继续优先,不借复测重设动效参数): + +| Before | After | Why | +| --- | --- | --- | +| 短语音整句在播放中被generating的最后块规则压住,final后才出现 | 语音使用speaking态,成句部分播放中可见;文字generating原规则保留 | 字幕先行,未成句部分仍等待,当前一两句不铺满 | +| 同键内容变化只比较行键,后续短句可能不更新 | 已显示语音行原位增长,保留DOM与既有字,final/落库接替保留同键 | 高频增量不重启动画,不重复演字 | +| worker参与者离场时页面仍在听,麦克风未停 | 实际transport监听ParticipantDisconnected,仅最后agent离场进入既有fail清理 | 立即停麦与清残影,普通参与者/旧房间/主动挂断不误触发 | +| 退休speech仅保留64项,窗口挤出后可重进 | 每通话Set保留,随代次释放,seq门不变 | 长会话拒绝高seq旧轮,不以放宽乱序保护解决问题 | + +独立浏览器运行使用本机既有Chromium156与既有browser-libs,完整重跑作者hel879_unified_shots.mjs,假LiveKit/假音频、实际worker录制帧;最终退出0、53/53通过;8段WebM生成成功,正例92帧重编号映射经核对在各通话内严格递增。结果与seq重编号见manager-checks/browser.log及browser/seq-renumbering.json。初次启动时CDP未就绪,之后沿已有浏览器库与默认临时目录重跑;初次失败日志保留,不冒充产品失败。正例重编号映射可审,负例原样送入,mock与CDP端口独立且pid核对,退出收回本次进程组。两主题电脑/手机、减少动态与实际舞台DOM均在此次运行范围。本轮主卡附代表性截图/视频与报告;完整作者媒体继续由源交付包保留,不向仓库堆大媒体。 + +作者修前7失败/修后118通过、统一浏览器修前30/53与HEL898的61/61、HEL878的49/49为作者证据,本轮没有把这些修前或回归结果冒称独立复跑。作者提供8段WebM,来自CDP真实绘制帧按时间戳经MediaRecorder编码,不是桌面录屏软件,也不是真实媒体通话;原始帧及时间戳在其交付包保留。 + +本次只证明三项有限返修及离线回放。真实LiveKit/WebRTC与SDK离场时序、真实麦克风/ASR/TTS/LLM、三轮/打断停声/停麦、HTTPS+WSS设备信任、真机/Safari、实际API重启释放等仍未验收。完整CG-011会话中主动表达、完整mem0体验/R6/R7继续承接。真实cap=0;未下载、安装、部署、合并、选音色或修改任何旧服务/档案/绑定。 + +下一步由原总工在HEL-879对同一统一候选完成一次复审/收口,保留已过预算/备份/回执与材料;无需另开审核卡或重做后台。离线通过后把已有音色参考录音整理成可试听交付(只读取既有文件,零合成,不动共享音色),与有限预算/独立部署/TTS窗口/必要依赖下载的准确授权包一起交总管,才能在HEL-797集中请用户决定。未获批准不得执行真实测试或发布。 diff --git a/docs/rework/hel898/f92a86f-rework/preservation.json b/docs/rework/hel898/f92a86f-rework/preservation.json new file mode 100644 index 0000000..1fc2d79 --- /dev/null +++ b/docs/rework/hel898/f92a86f-rework/preservation.json @@ -0,0 +1,131 @@ +{ + "baseline_version": "1.11", + "count": 60, + "author_input": "f92a86f5d8f839338311cdee08179049de34eb20", + "actual_parent": "b0dcb17e1acd970f706763efe0fcb7062596039a", + "preserved_code_commit": "9254c1731674f4c5f16acba59f06c5f4bca41171", + "source_comment": "01a17a78-52d4-7a1e-b677-3137dc15801b", + "source_attachment": "01a17a77-e455-775a-a835-ba21ad5fb9bf", + "zip_sha256": "18501ab5c9c88ec91ede97818e7098eb166792344fed272929a4493bb8abb355", + "patch_sha256": "ba1aa445ff7e3be6c4f1ae60866c38a793f06d564f2edc39c21d1fa1142b4c14", + "files": [ + { + "path": "docs/requirements/handoff-HEL-898.json", + "before": "42c39b5e8eb4bcc7c9eb3b216957b5dfe0503873e56e123d5ccee25858eadd2d", + "after": "af31e5dca084900b4b8e61adaeeb6868a377f74ba1a5afb57219c4ff783adb31", + "exact_source": true + }, + { + "path": "web/src/chat/ChatPanel.tsx", + "before": "d8f2f9e186372d3ba02ae6ae958b298f7b27a75053bb21a1338ea2728b49b669", + "after": "38c4912f43c01b7399f3d687e022b14135f5ad648cfd005ecc0b93637b055900", + "exact_source": true + }, + { + "path": "web/src/chat/lineStage.ts", + "before": "21ddd34964237f537262f557e56dced2fd72a51c8a24577d867bc3933b1d40f7", + "after": "687835a7e3f6f3a9bfbff863e2cd7e1ea96c463d1bc35b9e9be2833cd3252da2", + "exact_source": true + }, + { + "path": "web/src/chat/reading.ts", + "before": "c00aa08141d00047bc9097a50197e5223772baa7af8b1c747bb259c9a5ada16b", + "after": "b35aa4b7c433f660929b2bb46f85d06ebbbde942621df5c2df8da6b3312f4dfb", + "exact_source": true + }, + { + "path": "web/src/voice/livekit.ts", + "before": "c42475d37f7eef7abe0903b66656aedc5379bc9de3dcba11c1b88450a470ab04", + "after": "0148fabff55c4d6112aca2b412c53280104442601d6f5e39349279298761293e", + "exact_source": true + }, + { + "path": "web/src/voice/session.ts", + "before": "a4036aa04a370b258a38b84a3370f82e2dc6707b6bda4d8e26c713669f660b14", + "after": "2a9a8cd5f1b32fabc68151fee19561e88983b4f5b739da40806dbfeb7698d6e5", + "exact_source": true + }, + { + "path": "web/tests/fake-livekit.js", + "before": "d6309571c7f03a0eef158354d95c9d9637e362cd4312e4c118031057b13efddc", + "after": "fe6489d393aa686cbb527f04d397b3bea19b9e663a665b74369310719c12a917", + "exact_source": true + }, + { + "path": "web/tests/hel878_mock.mjs", + "before": "fb687d17549c9aaeb3f61d79d78ba722092502ae7b48ff764bf83415d5c6fee0", + "after": "4c8cf85102a0c5cecab47ad493c7a698073d4aa25f0d1c0ff598c032640a8d76", + "exact_source": true + }, + { + "path": "web/tests/hel879_frames.test.ts", + "before": "f6c723aa4f2aae736ceeac164e298f80a7da96be4a1899bdd051c8d3c7a90c52", + "after": "86b9cbb402e28661f37e7ad4e292ed4a6f8a3212921930bfc246fe26475d6fe8", + "exact_source": true + }, + { + "path": "web/tests/hel879_unified_shots.mjs", + "before": "ee624f14e050b4e2156e1ddddc7dc525062b119a6c9140e69f765d1c26fb52b4", + "after": "1678eb9548b9427d1746aacb913c7f52246f9e0d2a87006f9d6fc411a862f82f", + "exact_source": true + }, + { + "path": "web/tests/hel898_agent_left.test.ts", + "before": null, + "after": "727752e2cfa56618353ca9a95cb4f4a308fa3020a7fe6a210eb3f021a6654c9b", + "exact_source": true + }, + { + "path": "web/tests/voice.test.ts", + "before": "bcda0c1f622bd08a26c74c20bc2a85e75075c6ea2de1ef22eafd8834adf16f73", + "after": "360ac94641a64fe6dd9d83a9243d037afc789b034bcfbc2fb558bc604262e4a5", + "exact_source": true + } + ], + "independent": { + "requirements": "6/6 (main,f92a86f,b0dcb17, each with/without handoff)", + "vitest": "118/118 in 10 files", + "typecheck": "pass", + "lint": "0 errors, 1 existing fullButton warning", + "build": "pass", + "browser": "53/53; Chromium 156, fake LiveKit and fake audio, real-worker fixture replay, two themes desktop/phone plus reduced motion" + }, + "calls": 0, + "download_install": false, + "deployed_merged": false, + "requirement_changes": [], + "media": { + "v3-reduced-motion-short-reply-while-speaking.webm": { + "sha256": "54c1ff8206d77c9e61f228f48b1cdce18a433da68a2e8f3ac3067d6097a1a8fb", + "size": 972203 + }, + "v2-moon-phone-worker-leaves.webm": { + "sha256": "d2892d55c55d4a1af1e81711ea6bd2ea23c8bf01e49c82533ed123c3337caabf", + "size": 423026 + }, + "v1-moon-desktop-short-reply-while-speaking.webm": { + "sha256": "bd2afc2582c226666e60181aa1a75ba70a43e96063f47d2b49c58a1c14df7ba1", + "size": 2975970 + }, + "v2-fog-desktop-worker-leaves.webm": { + "sha256": "a47737f813368be0de329edca07454b0a65e2db7aa7645d25aa334e61397ab5e", + "size": 1226681 + }, + "v2-fog-phone-worker-leaves.webm": { + "sha256": "8d184d06ad5a1c2da6b074acfa23ab1d41288bd3050fe4c82cceacbb32dd8d3a", + "size": 447638 + }, + "v2-reduced-motion-worker-leaves.webm": { + "sha256": "39083866ede000cd67c6b31bb6b82f939b468848c24ce8d4790deb66c46d2c62", + "size": 825797 + }, + "v2-moon-desktop-worker-leaves.webm": { + "sha256": "7053c2d03f014e204363f4ac4f3df49f63bff96b1a61ddec5ce63d0a64078a3b", + "size": 1118688 + }, + "v3-moon-phone-short-reply-while-speaking.webm": { + "sha256": "9fff3cf6128b6e3f941267c6f49c62dec0677e73abe6cb01229c56e37f556669", + "size": 618550 + } + } +}