diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fea571..b86d3e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 修复 - **修复「插件一运行客户端就卡死」**:装饰动作会改写宿主 DOM 并再次触发 `MutationObserver`,而越界编号在「不生成芯片、保留原文」的分支里仍然无条件 `replaceChild`——等于用一个内容相同的新节点替换原节点,mutation 永不收敛,装饰与观察者互相触发成自激,把主线程打满(表现为整个客户端失去响应)。现在一个可替换项都没有时**完全不碰 DOM**;另外给全量装饰加了 120ms 合并调度(首拍仍立即执行,保住「绘制前隐藏、零闪烁」),避免长会话里装饰自身滚成高频全量扫描。 - 适配 DSH 0.1.7:`sessions.list` 快照不再暴露 `current`,当前会话 id 改经 `uiSession.adapter.current`(`binding.key`)读取,旧内核保留原字段兜底,会话切换订阅同步跟随。修复 0.1.7+ 上「批注标签在、但回车/发送按钮都没反应」——拼稿前取不到会话 id 会直接返回,空草稿时宿主又禁用发送,只有手动输入正文才发得出去。 +- 会话识别合成完整四级回退链(#68 与上条方案的合并,覆盖 DSH 0.1.1 ~ 0.2.0-rc.2,已对照 0.2.0-rc.2 内核源码逐项核实):`uiSession.adapter.current`(0.1.7+)→ `localStorage['dsh.sessions.current']`(0.1.6-alpha.2 ~ 0.1.6 上唯一可用路径)→ 旧 list 快照 `current`(≤0.1.6-alpha.1)→ `retainedBy.mainView > 0` 公开成员兜底。取不到会话 id 时 toast 提示且批注保留待下一条重试(不再静默丢弃);会话切换检测三层并行(uiSession source 订阅 / list.subscribe / 1s 轮询兜底),切换后重挂新会话草稿订阅,待发送批注跨会话、跨刷新正确恢复。方案与考证来自 @pinzza(#68),与 @fengbinmov 的 uiSession 读取(#65)合并。 ### 交互 - 浮层底色改成「默认半透明、鼠标移入变实」:默认半透明,悬浮在正文上时能看穿下面的文字;鼠标移进去后**带 0.15s 过渡**换成不透明底色,方便专心读批注内容。半透明**不再依赖宿主变量自带 alpha**(Windows 上 `--dsw-specific-menu` 可能缺失或本身不透明),改用 `color-mix(in srgb, <主题色> 94%, transparent)` 自行生成半透明底,并做多级回退(`--dsw-alias-bg-layer-2` → `--dsw-alias-bg-base` → 深灰),保证明暗主题都能跟随。覆盖回复芯片、批注 ×N 标签、各类 hover 浮窗(回复芯片面板 / 批注清单 / 气泡标签面板)、选区工具条、编辑卡片与 toast。 diff --git a/client.js b/client.js index 371bd37..adf323d 100644 --- a/client.js +++ b/client.js @@ -210,6 +210,7 @@ window.__ModuleLoader__.load({ toast: { attachFail: '批注拼稿失败,消息将不带批注发送:', skipCommand: '本条是斜杠命令,未拼入批注;批注已保留,将随下一条消息发送', + noSession: '无法确定当前会话,批注未拼入(已保留,将随下一条消息重试)', }, block: { head: '我批注了以下 {n} 处内容(编号与原文对应),请针对它们回答我的问题:', @@ -246,6 +247,7 @@ window.__ModuleLoader__.load({ toast: { attachFail: 'Failed to attach annotations; the message will be sent without them: ', skipCommand: 'Slash command detected — annotations stay pending and will attach to your next message', + noSession: 'Cannot determine the current session; annotations kept pending and will retry with your next message', }, block: { head: 'I annotated the following {n} passage(s) (the numbers match the quotes below); please respond to them when answering my question:', @@ -922,13 +924,22 @@ window.__ModuleLoader__.load({ function apply(ctx) { var sessions = ctx.sessions - // ---------- 当前会话 id 的跨版本读取 ---------- - // DSH 0.1.7 起 `sessions.list` 快照不再暴露 `current`(selection 收进 - // ClientSessions 私有 selection),改由 ui-session 服务的 - // `adapter.current`(HostObservable,binding.key = sessionId) - // 发布。旧版两条路都在,故这里以 uiSession 优先、list.current 兜底: - // 若只认旧字段,0.1.7+ 上 current 恒为 undefined,attachAndSend 第一行 - // 就 return false —— 表现为「有批注、标签也在,但回车/发送按钮都没反应」。 + // ---------- 当前会话 id 的跨版本解析(覆盖 DSH 0.1.1 ~ 0.2.0-rc.2)---------- + // 优先级(自上而下,命中即返回): + // ① uiSession 服务的 `adapter.current`(HostObservable,binding.key = + // sessionId)—— 0.1.7 起的公开选中态:selection 收进 ClientSessions 私有 + // selection,list 快照不再带 current。已对照 0.1.7-rc.2 与 0.2.0-rc.2 内核 + // 源码核实(packages/client/ui-session/src/client/index.ts:312 起暴露)。 + // ② `localStorage['dsh.sessions.current'].sessionId` —— 内核 workspace 服务 + // 持久化的选择态(`createSnapshotStore({}, { persist: { name: ... } })`, + // 写入形状 `{ sessionId }`,会话归档时由内核 clearArchivedCurrent 自行清理)。 + // 0.1.6-alpha.2 ~ 0.1.6 上这是唯一真实可用的路径(#68 的考证)。 + // ③ list 快照上的 `current` —— 0.1.6-alpha.2 之前的宿主,该字段是精确选中值。 + // ④ list 行上的 `retainedBy.mainView > 0` —— 公开成员,兜底「尚未落盘 / 存储 + // 被清」的场景。它是「主视图 retain 的会话」而非选中值:内核自己也只在 + // id 未知时才用它猜,故排在精确值之后。 + // (曾挂在 sessions 服务上的 `currentProvideInfo` 只存在于旧源码线 + // packages/client/runtime,上游现契约已无此成员,不再尝试。) var uiSessionService = null /** ui-session 服务(惰性探测:apply 时机早于它注册时下次再取)。 */ @@ -939,6 +950,7 @@ window.__ModuleLoader__.load({ return uiSessionService } + /** ① 命中时返回可订阅的选中态源(HostObservable),否则 null。 */ function currentSessionSource() { try { var face = uiSessionFace() @@ -946,24 +958,45 @@ window.__ModuleLoader__.load({ var source = adapter !== undefined && adapter !== null ? adapter.current : undefined if (source !== undefined && source !== null && typeof source.getSnapshot === 'function' && typeof source.subscribe === 'function') return source - } catch (_) { /* 旧版内核:退回 list store */ } - return sessions.list + } catch (_) { /* 旧版内核:无 uiSession */ } + return null } function readCurrentSessionId() { try { - var face = uiSessionFace() - var adapter = face !== null ? face.adapter : undefined - var source = adapter !== undefined && adapter !== null ? adapter.current : undefined - if (source !== undefined && source !== null && typeof source.getSnapshot === 'function') { + var source = currentSessionSource() + if (source !== null) { var binding = source.getSnapshot() if (binding !== undefined && binding !== null && binding.key !== undefined) return binding.key } - } catch (_) { /* 旧版内核:退回 list store */ } + } catch (_) { /* 落到下一级 */ } + try { + if (typeof localStorage !== 'undefined') { + var raw = localStorage.getItem('dsh.sessions.current') + if (raw !== null) { + var parsed = JSON.parse(raw) + if (parsed !== null && typeof parsed === 'object' + && typeof parsed.sessionId === 'string' && parsed.sessionId !== '') return parsed.sessionId + } + } + } catch (_) { /* 落到下一级 */ } try { var snap = sessions.list.getSnapshot() - return snap !== undefined && snap !== null ? snap.current : undefined - } catch (_) { return undefined } + if (snap !== undefined && snap !== null) { + if (typeof snap.current === 'string' && snap.current !== '') return snap.current + var byId = snap.byId + if (byId !== undefined && byId !== null && typeof byId === 'object') { + var ids = Array.isArray(snap.ids) ? snap.ids : Object.keys(byId) + for (var i = 0; i < ids.length; i++) { + var retained = byId[ids[i]] !== undefined && byId[ids[i]] !== null + ? byId[ids[i]].retainedBy : null + if (retained !== null && typeof retained === 'object' + && typeof retained.mainView === 'number' && retained.mainView > 0) return ids[i] + } + } + } + } catch (_) { /* 落到下一级 */ } + return undefined } var host = document.createElement('div') @@ -1123,7 +1156,8 @@ window.__ModuleLoader__.load({ var key = selectionKey(sel) var rootEl = annotationRootOf(range.commonAncestorContainer) var source = documentSourceOf(range.commonAncestorContainer) - if (source !== null && source.sessionId !== readCurrentSessionId()) rootEl = null + var selSessionId = readCurrentSessionId() + if (source !== null && selSessionId !== undefined && source.sessionId !== selSessionId) rootEl = null if (rootEl === null) { clearSettle(); closeToolbar(); return } if (ui.mode === 'actions' && key === ui.lastKey && text === ui.quote && rootEl === ui.selectionRoot && (ui.source && ui.source.sourceUrl) === (source && source.sourceUrl)) { clearSettle(); return } @@ -1136,8 +1170,9 @@ window.__ModuleLoader__.load({ var r = s.getRangeAt(0) if (annotationRootOf(r.commonAncestorContainer) !== rootEl) return var currentSource = documentSourceOf(r.commonAncestorContainer) + var settleSessionId = readCurrentSessionId() if ((currentSource && currentSource.sourceUrl) !== (source && source.sourceUrl) - || (source !== null && source.sessionId !== readCurrentSessionId())) return + || (source !== null && settleSessionId !== undefined && source.sessionId !== settleSessionId)) return var rect = r.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) return var p = placeAbove(rect, 40) @@ -1299,9 +1334,15 @@ window.__ModuleLoader__.load({ function submitAttached() { var current = readCurrentSessionId() - if (current === undefined) return + if (current === undefined) { + console.warn('[annotation] 无法确定当前会话,批注直接提交已跳过') + return + } var scoped = sessions.scope(current) - if (scoped === undefined) return + if (scoped === undefined) { + console.warn('[annotation] 当前会话 scope 不可用,批注直接提交已跳过') + return + } try { ctx.conversation.input.for(scoped).submit('queue') } catch (err) { @@ -1765,10 +1806,18 @@ window.__ModuleLoader__.load({ * 返回 true 表示批注块已在草稿中(本次刚拼入,或之前已拼入未发送)。 */ function attachAndSend(e) { var current = readCurrentSessionId() - if (current === undefined) return false + if (current === undefined) { + console.warn('[annotation] 无法确定当前会话,批注未拼入草稿') + showToast(t('toast.noSession')) + return false + } try { var scoped = sessions.scope(current) - if (scoped === undefined) return false + if (scoped === undefined) { + console.warn('[annotation] 当前会话 scope 不可用,批注未拼入草稿') + showToast(t('toast.noSession')) + return false + } var shell = ctx.conversation.input.for(scoped) var st = shell.state.getSnapshot() var draft = st.draft || '' @@ -2081,6 +2130,10 @@ window.__ModuleLoader__.load({ function watchInputDraft() { if (inputWatchTimer !== null) { clearInterval(inputWatchTimer); inputWatchTimer = null } if (typeof inputUnsub === 'function') { inputUnsub(); inputUnsub = null } + // 会话切换时旧 scope 的订阅由上层先释放,这里只负责挂上当前会话; + // 切换后若 scope 尚不可用,仍靠下方的 1s 重试补齐(同 watchInputDraft 原有语义)。 + // 注意:切换后必须重新订阅新会话的草稿,否则“草稿有→空”的发送清空权威会 + // 继续监听旧会话,新会话发送后待发送批注永远不清(每次 Enter 重复拼稿)。 var id = readCurrentSessionId() if (id !== undefined && tryWatchInputDraft(id)) return inputWatchTimer = setInterval(function () { @@ -2503,11 +2556,15 @@ window.__ModuleLoader__.load({ } // ---------- 待发送批注按会话恢复 ---------- + // 0.1.6-alpha.2 起选择态已移出 list store:list.subscribe 不再于会话切换时触发。 + // 切换检测三层并行(onSessionSwitch 幂等,重复触发无害): + // ① uiSession 的 current source(0.1.7+ 精确;uiSession 晚于插件注册时, + // onSessionSwitch 内的惰性探测仍能读到,订阅缺口由轮询兜底); + // ② list.subscribe(0.1.6-alpha.2 之前的宿主精确); + // ③ 1s 轮询(新宿主上 ② 永不触发、① 缺席时它是唯一触发点)。 var lastSessionId = readCurrentSessionId() ui.quotes = readPendingQuotes(lastSessionId) - // 会话切换:0.1.7 起 selection 变化不再写进 list 快照,必须订阅 - // ui-session 的 current source,否则切会话时待发送批注不会跟着切换。 - var unsub = currentSessionSource().subscribe(function () { + function onSessionSwitch() { var cur = readCurrentSessionId() if (cur === lastSessionId) return writePendingQuotes(lastSessionId) @@ -2523,7 +2580,27 @@ window.__ModuleLoader__.load({ updateChip() watchInputDraft() renderMarkers() - }) + } + var unsubList = null + var unsubSession = null + var switchTimer = null + try { + if (sessions.list !== undefined && sessions.list !== null + && typeof sessions.list.subscribe === 'function') { + unsubList = sessions.list.subscribe(onSessionSwitch) + } + } catch (_) { unsubList = null } + try { + var uiSource = currentSessionSource() + if (uiSource !== null) unsubSession = uiSource.subscribe(onSessionSwitch) + } catch (_) { unsubSession = null } + switchTimer = setInterval(onSessionSwitch, 1000) + function unsubSessionSwitch() { + try { if (typeof unsubList === 'function') unsubList() } catch (_) { /* ignore */ } + try { if (typeof unsubSession === 'function') unsubSession() } catch (_) { /* ignore */ } + if (switchTimer !== null) { clearInterval(switchTimer); switchTimer = null } + } + var unsub = unsubSessionSwitch watchInputDraft() kickDecorate() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dffbd6e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "@changfenhuang/dsh-annotation", + "version": "1.4.11-preview.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@changfenhuang/dsh-annotation", + "version": "1.4.11-preview.1", + "license": "MIT", + "devDependencies": { + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "cordis": "^4.0.0-rc.7 || ^4.0.1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cordis": { + "version": "4.0.0-rc.10", + "resolved": "https://registry.npmjs.org/cordis/-/cordis-4.0.0-rc.10.tgz", + "integrity": "sha512-xG90nPNQxR272cC4lR/m5LHevegIJvdddQBlKdEAdGz3n+zgH5lsgkg8o9fc2P3T/f+pO5D7FN1HZvkNBiABnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "cosmokit": "^1.8.1" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@cordisjs/plugin-include": "^1.1.0", + "@cordisjs/plugin-loader": "^1.0.0-rc.7" + }, + "peerDependenciesMeta": { + "@cordisjs/plugin-include": { + "optional": true + }, + "@cordisjs/plugin-loader": { + "optional": true + } + } + }, + "node_modules/cosmokit": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/cosmokit/-/cosmokit-1.8.1.tgz", + "integrity": "sha512-PDBv4l90xZKrUsZ0vtoycgZpO/j4iFsqJXrAxsyBDsnQRI7ZMJXIjgDJsKNjd5L8jnVnnlrDCdhkFbTncgCVjQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/test/message-block.test.mjs b/test/message-block.test.mjs index cf0978c..a1a016f 100644 --- a/test/message-block.test.mjs +++ b/test/message-block.test.mjs @@ -9,26 +9,29 @@ function fn(name) { assert.ok(match, name) return match[0] } -function harness(lang, draft, sourcePath) { +function harness(lang, draft, sourcePath, sessionsOverride, storageOverride) { const nodes = [] const bubble = { querySelectorAll: () => [], get textContent() { return nodes.map(n => n.nodeValue).join('') } } const row = { querySelector: () => bubble } const shell = { state: { getSnapshot: () => ({ draft }) }, setDraft(value) { draft = value } } const document = { createTreeWalker: () => { let i = 0; return { nextNode: () => nodes[i++] ?? null } } } - const api = Function('shell', 'document', 'NodeFilter', 'sourcePath', ` + const sessions = sessionsOverride ?? { + list: { getSnapshot: () => ({ current: 'session' }) }, + scope: () => ({}), + } + const api = Function('shell', 'document', 'NodeFilter', 'sourcePath', 'sessions', 'localStorage', ` ${protocol} ${source.slice(source.indexOf(' function quoteWithSource('), source.indexOf(' function assistantRows('))} var ui = { quotes: [{ text: '原文包含提问:这个词', note: '解释一下' }] } ui.quotes[0].sourcePath = sourcePath var annotationAttached = false - var sessions = { list: { getSnapshot: () => ({ current: 'session' }) }, scope: () => ({}) } var ctx = { conversation: { input: { for: () => shell } } } var uiSessionService = null function showToast() {} ${['uiSessionFace', 'currentSessionSource', 'readCurrentSessionId'].map(fn).join('\n')} ${['buildBlock', 'shouldAttachForEnter', 'isCommandDraft', 'attachAndSend', 'hideAnnotationBlock', 'parseItemsFromBubble'].map(fn).join('\n')} return { setLang, attachAndSend, hideAnnotationBlock, parseItemsFromBubble } - `)(shell, document, { SHOW_TEXT: 4 }, sourcePath) + `)(shell, document, { SHOW_TEXT: 4 }, sourcePath, sessions, storageOverride) api.setLang(lang) return { api, row, bubble, shell, render(value) { nodes.length = 0 @@ -80,3 +83,37 @@ for (const lang of ['zh', 'en']) { assert.equal(h.api.hideAnnotationBlock(h.row), true) }) } + +// issue #64:DSH 0.1.6-alpha.2 移除了 sessions.list.current。已发布宿主上 +// `sessions.currentProvideInfo` 并不存在(上游契约已无此成员),真实可用路径是 +// ① localStorage['dsh.sessions.current'] ② 旧 list.current ③ list 行 retainedBy.mainView。 +const newKernelList = (rows, ids) => ({ + list: { getSnapshot: () => ({ ids: ids ?? Object.keys(rows), byId: rows, phase: 'ready' }) }, + scope: (id) => (Object.prototype.hasOwnProperty.call(rows, id) ? {} : undefined), +}) +for (const lang of ['zh', 'en']) { + test(`${lang}: 新内核形状(list 无 current)+ 持久化选择态下批注仍随消息发送(issue #64)`, () => { + const kernel = newKernelList({ 'session-a': { retainedBy: {} } }) + const storage = { getItem: (k) => (k === 'dsh.sessions.current' ? JSON.stringify({ sessionId: 'session-a' }) : null) } + const h = harness(lang, '我的问题', undefined, kernel, storage) + assert.equal(h.api.attachAndSend({}), true) + assert.match(h.shell.state.getSnapshot().draft, /Annotation/) + }) + + test(`${lang}: 无持久化键时退回 retainedBy.mainView 公开面`, () => { + const kernel = newKernelList({ 'session-b': { retainedBy: { mainView: 1 } } }) + const h = harness(lang, '我的问题', undefined, kernel) + assert.equal(h.api.attachAndSend({}), true) + assert.match(h.shell.state.getSnapshot().draft, /Annotation/) + }) + + test(`${lang}: 旧内核形状(仅 list.current)下保持兼容`, () => { + const legacySessions = { + list: { getSnapshot: () => ({ current: 'session' }) }, + scope: () => ({}), + } + const h = harness(lang, '', undefined, legacySessions) + assert.equal(h.api.attachAndSend({}), true) + assert.match(h.shell.state.getSnapshot().draft, /Annotation/) + }) +} diff --git a/test/pending-clear.test.mjs b/test/pending-clear.test.mjs index b8f37c7..6e81b49 100644 --- a/test/pending-clear.test.mjs +++ b/test/pending-clear.test.mjs @@ -26,10 +26,24 @@ test('watchInputDraft 订阅失败时每秒重试(初始化时序洞由重试 }) test('会话切换作废旧会话未消费的发送暂存数据', () => { - const sw = source.match(/var unsub = currentSessionSource\(\)\.subscribe\(function \(\) \{[\s\S]*?\n \}\)/) - assert.ok(sw, 'client.js should define the session-switch handler') - assert.match(sw[0], /pendingDeco\.length = 0/, + // issue #64:0.1.6-alpha.2 起选择态移出 list store,list.subscribe 不再于切换时触发; + // onSessionSwitch 统一经 readCurrentSessionId 解析(uiSession → localStorage → + // list.current → retainedBy),触发层面三层并行(uiSession source / list.subscribe / 1s 轮询)。 + const sw = fnOf('onSessionSwitch') + assert.match(sw, /readCurrentSessionId\(\)/, + 'session-switch handler must resolve the id via readCurrentSessionId (list.current is gone on new hosts)') + assert.match(sw, /pendingDeco\.length = 0/, 'stale send staging from the previous session must be dropped, not consumed by the new session history') + assert.match(source, /sessions\.list\.subscribe\(onSessionSwitch\)/, + 'must keep the legacy list subscription for old hosts') + assert.match(source, /unsubSession = uiSource\.subscribe\(onSessionSwitch\)/, + 'must subscribe the uiSession current source where it exists (0.1.7+ fires switches only there)') + assert.match(source, /setInterval\(onSessionSwitch, 1000\)/, + 'must poll as fallback where no selection source can be subscribed') + assert.match(source, /clearInterval\(switchTimer\)/, + 'dispose must stop the switch poller') + assert.doesNotMatch(source, /sessions\.currentProvideInfo/, + 'sessions.currentProvideInfo exists on no published host (current contract lives in packages/api/session-controller and has no such member) — it must not be read or subscribed') }) test('当前会话 id 兼容 DSH 0.1.7(list 快照不再暴露 current)', () => { @@ -44,3 +58,26 @@ test('发送暂存数据在隐藏手术成功后才消费(peek → shift,不 assert.doesNotMatch(source, /pendingDeco\.pop\(\)/, 'popping before hideAnnotationBlock succeeds loses the staged items when content is not rendered yet') }) + +test('readCurrentSessionId 四级回退链覆盖 0.1.1 ~ 0.2.0(issue #64,#68 + #65 合并方案)', () => { + const helper = fnOf('readCurrentSessionId') + const uiSource = fnOf('currentSessionSource') + assert.match(uiSource, /adapter\.current/, + 'must prefer uiSession.adapter.current — the public selection source on 0.1.7+ / 0.2.0 (binding.key = sessionId)') + assert.match(helper, /currentSessionSource\(\)/, + 'the resolver must consult the uiSession source first') + assert.match(helper, /dsh\.sessions\.current/, + 'must fall back to the kernel-persisted selection — on 0.1.6-alpha.2 ~ 0.1.6 it is the only path that works') + assert.match(helper, /snap\.current/, + 'must keep the legacy list.current for hosts <= 0.1.6-alpha.1, where it is the exact selection') + assert.match(helper, /retainedBy/, + 'must fall back to the public retainedBy.mainView>0 face (covers "no persisted key yet / storage cleared")') + assert.ok(helper.indexOf('currentSessionSource()') < helper.indexOf('dsh.sessions.current') + && helper.indexOf('dsh.sessions.current') < helper.indexOf('snap.current') + && helper.indexOf('snap.current') < helper.indexOf('retainedBy'), + 'resolution order must be uiSession → localStorage → list.current → retainedBy (retainedBy is the main-view heuristic the kernel itself only uses as a repair path)') + assert.doesNotMatch(source, /sessions\.currentProvideInfo/, + 'the stale-source read face must be gone: upstream contract has no currentProvideInfo') + assert.doesNotMatch(source, /getSnapshot\(\)\.current/, + 'no direct sessions.list.getSnapshot().current reads may remain — all must go through readCurrentSessionId') +})