From c79ae499cb9c4fe47a2f3a7061df06e829148895 Mon Sep 17 00:00:00 2001 From: pinzza Date: Thu, 24 Sep 2026 14:22:21 +0800 Subject: [PATCH 1/2] fix: resolve current session via public read face (issue #64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DSH 0.1.6-alpha.2 (kernel 6830e1460d) removed sessions.list.current, so attachAndSend/submitAttached/watchInputDraft/pending-restore all silently missed: current === undefined hit an early return before the try/catch, with no toast or log. Introduce a 3-tier currentSessionId() helper inside apply(): 1) public sessions.currentProvideInfo.getSnapshot().sessionId (ISessions stable read face, present on old and new hosts, subscribable on session switch — the normal path); 2) legacy sessions.list.getSnapshot().current (pre-0.1.6 hosts and old test doubles); 3) private persistence key dsh.sessions.current (last resort only; normal path never reaches it). Session-switch detection subscribes to both list and currentProvideInfo plus a 1s poller (list.subscribe no longer fires on switch since the selection moved out of the list store). Missing-session failures now warn + toast (new toast.noSession) instead of silent return; sidebar source filtering is permissive when the id is truly unknown. Tests: harness injects the helper and mocks provideInfo; add new-kernel (no list.current) and legacy (list.current only) regression cases plus a resolver-order guard. --- client.js | 105 ++++++++++++++++++++++++++++++------ test/message-block.test.mjs | 39 ++++++++++++-- test/pending-clear.test.mjs | 32 +++++++++-- 3 files changed, 153 insertions(+), 23 deletions(-) diff --git a/client.js b/client.js index ab37786..ada5997 100644 --- a/client.js +++ b/client.js @@ -191,6 +191,7 @@ window.__ModuleLoader__.load({ toast: { attachFail: '批注拼稿失败,消息将不带批注发送:', skipCommand: '本条是斜杠命令,未拼入批注;批注已保留,将随下一条消息发送', + noSession: '无法确定当前会话,批注未拼入(已保留,将随下一条消息重试)', }, block: { head: '我批注了以下 {n} 处内容(编号与原文对应),请针对它们回答我的问题:', @@ -228,6 +229,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:', @@ -904,6 +906,34 @@ window.__ModuleLoader__.load({ function apply(ctx) { var sessions = ctx.sessions + // ---- 当前会话 id 解析(兼容 DSH 0.1.6-alpha.2+,内核 commit 6830e1460d 移除了 sessions.list.current)---- + // 优先级:① 公开的 currentProvideInfo(ISessions 稳定读面,新旧内核均有,会话切换可订阅); + // ② 旧的 list.current(0.1.6-alpha.2 之前的宿主与旧测试夹具); + // ③ 私有持久化键 dsh.sessions.current(最后兜底:新内核的 selection store 仍以该键持久化, + // 键名再变即失效,正常路径不应走到这里)。 + function currentSessionId() { + try { + var provide = sessions.currentProvideInfo + if (provide !== undefined && provide !== null + && typeof provide.getSnapshot === 'function') { + var info = provide.getSnapshot() + if (info !== undefined && info !== null && typeof info.sessionId === 'string') return info.sessionId + } + } catch (_) { /* 落到下一级 */ } + try { + var snap = sessions.list.getSnapshot() + if (snap !== undefined && snap !== null && typeof snap.current === 'string') return snap.current + } catch (_) { /* 落到下一级 */ } + try { + if (typeof localStorage === 'undefined') return undefined + var raw = localStorage.getItem('dsh.sessions.current') + if (raw === null) return undefined + var parsed = JSON.parse(raw) + return parsed !== null && typeof parsed === 'object' + && typeof parsed.sessionId === 'string' ? parsed.sessionId : undefined + } catch (_) { return undefined } + } + var host = document.createElement('div') host.setAttribute('data-annotation-for-dsh', '') document.body.appendChild(host) @@ -948,7 +978,7 @@ window.__ModuleLoader__.load({ } function writeCurrentPendingQuotes() { - writePendingQuotes(sessions.list.getSnapshot().current) + writePendingQuotes(currentSessionId()) } var ignoreUntil = 0 @@ -1050,7 +1080,8 @@ window.__ModuleLoader__.load({ var key = selectionKey(sel) var rootEl = annotationRootOf(range.commonAncestorContainer) var source = documentSourceOf(range.commonAncestorContainer) - if (source !== null && source.sessionId !== sessions.list.getSnapshot().current) rootEl = null + var selSessionId = currentSessionId() + 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 } @@ -1063,8 +1094,9 @@ window.__ModuleLoader__.load({ var r = s.getRangeAt(0) if (annotationRootOf(r.commonAncestorContainer) !== rootEl) return var currentSource = documentSourceOf(r.commonAncestorContainer) + var settleSessionId = currentSessionId() if ((currentSource && currentSource.sourceUrl) !== (source && source.sourceUrl) - || (source !== null && source.sessionId !== sessions.list.getSnapshot().current)) 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) @@ -1224,10 +1256,16 @@ window.__ModuleLoader__.load({ document.addEventListener('keydown', onKeyDown, true) function submitAttached() { - var current = sessions.list.getSnapshot().current - if (current === undefined) return + var current = currentSessionId() + 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) { @@ -1604,11 +1642,19 @@ window.__ModuleLoader__.load({ /** 提交前把批注块拼进 composer 草稿(随回车一起发送)。 * 返回 true 表示批注块已在草稿中(本次刚拼入,或之前已拼入未发送)。 */ function attachAndSend(e) { - var current = sessions.list.getSnapshot().current - if (current === undefined) return false + var current = currentSessionId() + 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 || '' @@ -1886,10 +1932,14 @@ window.__ModuleLoader__.load({ function watchInputDraft() { if (inputWatchTimer !== null) { clearInterval(inputWatchTimer); inputWatchTimer = null } if (typeof inputUnsub === 'function') { inputUnsub(); inputUnsub = null } - var id = sessions.list.getSnapshot().current + // 会话切换时旧 scope 的订阅由上层先释放,这里只负责挂上当前会话; + // 切换后若 scope 尚不可用,仍靠下方的 1s 重试补齐(同 watchInputDraft 原有语义)。 + // 注意:切换后必须重新订阅新会话的草稿,否则“草稿有→空”的发送清空权威会 + // 继续监听旧会话,新会话发送后待发送批注永远不清(每次 Enter 重复拼稿)。 + var id = currentSessionId() if (id !== undefined && tryWatchInputDraft(id)) return inputWatchTimer = setInterval(function () { - var cur = sessions.list.getSnapshot().current + var cur = currentSessionId() if (cur !== undefined && tryWatchInputDraft(cur)) { clearInterval(inputWatchTimer) inputWatchTimer = null @@ -2292,10 +2342,12 @@ window.__ModuleLoader__.load({ } // ---------- 待发送批注按会话恢复 ---------- - var lastSessionId = sessions.list.getSnapshot().current + // 0.1.6-alpha.2 起选择态已移出 list store:list.subscribe 不再于会话切换时触发, + // 故同时订阅公开的 currentProvideInfo,并加 1s 轮询兜底(三者任一触发切换即恢复)。 + var lastSessionId = currentSessionId() ui.quotes = readPendingQuotes(lastSessionId) - var unsub = sessions.list.subscribe(function () { - var cur = sessions.list.getSnapshot().current + function onSessionSwitch() { + var cur = currentSessionId() if (cur === lastSessionId) return writePendingQuotes(lastSessionId) lastSessionId = cur @@ -2310,7 +2362,30 @@ window.__ModuleLoader__.load({ updateChip() watchInputDraft() renderMarkers() - }) + } + var unsubList = null + var unsubProvide = 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 provideSource = sessions.currentProvideInfo + if (provideSource !== undefined && provideSource !== null + && typeof provideSource.subscribe === 'function') { + unsubProvide = provideSource.subscribe(onSessionSwitch) + } + } catch (_) { unsubProvide = null } + switchTimer = setInterval(onSessionSwitch, 1000) + function unsubSessionSwitch() { + try { if (typeof unsubList === 'function') unsubList() } catch (_) { /* ignore */ } + try { if (typeof unsubProvide === 'function') unsubProvide() } catch (_) { /* ignore */ } + if (switchTimer !== null) { clearInterval(switchTimer); switchTimer = null } + } + var unsub = unsubSessionSwitch watchInputDraft() kickDecorate() diff --git a/test/message-block.test.mjs b/test/message-block.test.mjs index ca1deb0..5213563 100644 --- a/test/message-block.test.mjs +++ b/test/message-block.test.mjs @@ -9,24 +9,28 @@ function fn(name) { assert.ok(match, name) return match[0] } -function harness(lang, draft, sourcePath) { +function harness(lang, draft, sourcePath, sessionsOverride) { 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' }) }, + currentProvideInfo: { getSnapshot: () => ({ sessionId: 'session' }), subscribe: () => () => {} }, + scope: () => ({}), + } + const api = Function('shell', 'document', 'NodeFilter', 'sourcePath', 'sessions', ` ${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 } } } function showToast() {} - ${['buildBlock', 'shouldAttachForEnter', 'isCommandDraft', 'attachAndSend', 'hideAnnotationBlock', 'parseItemsFromBubble'].map(fn).join('\n')} + ${['currentSessionId', '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) api.setLang(lang) return { api, row, bubble, shell, render(value) { nodes.length = 0 @@ -78,3 +82,28 @@ 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,插件须经 +// currentProvideInfo 取到当前会话(list 快照无 current 的真实新内核形状)。 +for (const lang of ['zh', 'en']) { + test(`${lang}: 新内核形状(list 无 current)下批注仍随消息发送(issue #64)`, () => { + const newKernelSessions = { + list: { getSnapshot: () => ({ ids: ['session-a'], byId: {}, phase: 'ready' }) }, + currentProvideInfo: { getSnapshot: () => ({ sessionId: 'session-a' }), subscribe: () => () => {} }, + scope: (id) => (id === 'session-a' ? {} : undefined), + } + const h = harness(lang, '我的问题', undefined, newKernelSessions) + 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 bd7135a..81aade5 100644 --- a/test/pending-clear.test.mjs +++ b/test/pending-clear.test.mjs @@ -26,10 +26,21 @@ test('watchInputDraft 订阅失败时每秒重试(初始化时序洞由重试 }) test('会话切换作废旧会话未消费的发送暂存数据', () => { - const sw = source.match(/var unsub = sessions\.list\.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.subscribe 不再于切换时触发,改经 + // onSessionSwitch 统一处理(双订阅 + 轮询兜底),旧暂存仍须作废。 + const sw = fnOf('onSessionSwitch') + assert.match(sw, /currentSessionId\(\)/, + 'session-switch handler must resolve the id via currentSessionId (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, /currentProvideInfo[\s\S]*?subscribe\(onSessionSwitch\)/, + 'must subscribe to the public currentProvideInfo for new hosts') + assert.match(source, /setInterval\(onSessionSwitch, 1000\)/, + 'must poll as fallback where neither subscription fires') + assert.match(source, /clearInterval\(switchTimer\)/, + 'dispose must stop the switch poller') }) test('发送暂存数据在隐藏手术成功后才消费(peek → shift,不提前丢失)', () => { @@ -37,3 +48,18 @@ test('发送暂存数据在隐藏手术成功后才消费(peek → shift,不 assert.doesNotMatch(source, /pendingDeco\.pop\(\)/, 'popping before hideAnnotationBlock succeeds loses the staged items when content is not rendered yet') }) + +test('currentSessionId 三级解析:公开读面优先,私有键仅兜底(issue #64)', () => { + const helper = fnOf('currentSessionId') + assert.match(helper, /currentProvideInfo/, + 'must prefer the public currentProvideInfo read face (stable across the list.current removal)') + assert.match(helper, /list\.getSnapshot/, + 'must keep the legacy list.current fallback for old hosts') + assert.match(helper, /dsh\.sessions\.current/, + 'private persistence key is allowed only as last resort') + assert.ok(helper.indexOf('currentProvideInfo') < helper.indexOf('list.getSnapshot') + && helper.indexOf('list.getSnapshot') < helper.indexOf('dsh.sessions.current'), + 'resolution order must be provide → list → localStorage') + assert.doesNotMatch(source, /getSnapshot\(\)\.current/, + 'no direct sessions.list.getSnapshot().current reads may remain — all must go through currentSessionId') +}) From 7a9f958c0c62eb0d8a946ee28ce39e4acd723d86 Mon Sep 17 00:00:00 2001 From: pinzza Date: Mon, 28 Sep 2026 18:28:57 +0800 Subject: [PATCH 2/2] fix: resolve current session without the removed read face (issue #64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up for #68. Drop the sessions.currentProvideInfo tier: it exists only in the stale 2026-08-19 source line (packages/client/runtime), while the current upstream contract (packages/api/session-controller) and every published host (0.1.6-alpha.2 ~ 0.1.7-rc.2) have no such member — tree-wide search returns 0 hits, and upstream master code search returns 0 for currentProvide against 73/60/25 for retainInfo/SessionListState/list.getSnapshot. Resolution order is now: 1) localStorage['dsh.sessions.current'].sessionId — the selection the kernel workspace persists (createSnapshotStore persist name, shape { sessionId }, cleared by clearArchivedCurrent); the only path that works on published hosts; 2) list snapshot `current` — the exact selection on <= 0.1.6-alpha.1 hosts; 3) SessionSummary.retainedBy.mainView > 0 — public member, fallback only: it is main-view retention, not the selection, and the kernel itself (dsh-client-ui-session:283) uses it only as a repair path. Session-switch detection keeps list.subscribe plus the 1s poll; the currentProvideInfo subscription is gone with the tier. Tests: the message-block fixture can inject localStorage and covers the persisted-selection and retainedBy-fallback shapes; pending-clear pins the new resolution order and forbids any currentProvideInfo usage. npm test 28/28. --- client.js | 63 ++++++++++++++++++++----------------- test/message-block.test.mjs | 34 ++++++++++++-------- test/pending-clear.test.mjs | 30 +++++++++--------- 3 files changed, 72 insertions(+), 55 deletions(-) diff --git a/client.js b/client.js index ada5997..a7290f4 100644 --- a/client.js +++ b/client.js @@ -907,31 +907,46 @@ window.__ModuleLoader__.load({ var sessions = ctx.sessions // ---- 当前会话 id 解析(兼容 DSH 0.1.6-alpha.2+,内核 commit 6830e1460d 移除了 sessions.list.current)---- - // 优先级:① 公开的 currentProvideInfo(ISessions 稳定读面,新旧内核均有,会话切换可订阅); - // ② 旧的 list.current(0.1.6-alpha.2 之前的宿主与旧测试夹具); - // ③ 私有持久化键 dsh.sessions.current(最后兜底:新内核的 selection store 仍以该键持久化, - // 键名再变即失效,正常路径不应走到这里)。 + // 优先级: + // ① `localStorage['dsh.sessions.current'].sessionId` —— 内核 workspace 服务持久化的选择态 + // (`createSnapshotStore({}, { persist: { name: 'dsh.sessions.current' } })`, + // 写入形状 `{ sessionId }`,会话归档时由内核 clearArchivedCurrent 自行清理)。 + // 0.1.6-alpha.2 ~ 0.1.7-rc.2 上这是唯一真实可用的路径。 + // ② list 快照上的 `current` —— 0.1.6-alpha.2 之前的宿主,该字段是精确选中值。 + // ③ list 行上的 `retainedBy.mainView > 0` —— 公开成员,兜底"尚未落盘 / 存储被清"的场景。 + // 它是"主视图 retain 的会话"而非选中值:内核自己(dsh-client-ui-session:283)也只在 + // id 未知时才用它猜,故排在精确值之后。 + // 曾作为 ① 的 `currentProvideInfo`(挂在 sessions 服务上)只存在于旧源码线 + // (packages/client/runtime),上游现契约(packages/api/session-controller) + // 已无此成员,故整条移除。 function currentSessionId() { try { - var provide = sessions.currentProvideInfo - if (provide !== undefined && provide !== null - && typeof provide.getSnapshot === 'function') { - var info = provide.getSnapshot() - if (info !== undefined && info !== null && typeof info.sessionId === 'string') return info.sessionId + 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() - if (snap !== undefined && snap !== null && typeof snap.current === 'string') return snap.current + 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 (_) { /* 落到下一级 */ } - try { - if (typeof localStorage === 'undefined') return undefined - var raw = localStorage.getItem('dsh.sessions.current') - if (raw === null) return undefined - var parsed = JSON.parse(raw) - return parsed !== null && typeof parsed === 'object' - && typeof parsed.sessionId === 'string' ? parsed.sessionId : undefined - } catch (_) { return undefined } + return undefined } var host = document.createElement('div') @@ -2343,7 +2358,8 @@ window.__ModuleLoader__.load({ // ---------- 待发送批注按会话恢复 ---------- // 0.1.6-alpha.2 起选择态已移出 list store:list.subscribe 不再于会话切换时触发, - // 故同时订阅公开的 currentProvideInfo,并加 1s 轮询兜底(三者任一触发切换即恢复)。 + // 新宿主上本 helper 读的是持久化键 / list 行,都没有可订阅的选中态来源, + // 故保留 list.subscribe(老宿主精确)+ 1s 轮询兜底(新宿主唯一触发点)。 var lastSessionId = currentSessionId() ui.quotes = readPendingQuotes(lastSessionId) function onSessionSwitch() { @@ -2364,7 +2380,6 @@ window.__ModuleLoader__.load({ renderMarkers() } var unsubList = null - var unsubProvide = null var switchTimer = null try { if (sessions.list !== undefined && sessions.list !== null @@ -2372,17 +2387,9 @@ window.__ModuleLoader__.load({ unsubList = sessions.list.subscribe(onSessionSwitch) } } catch (_) { unsubList = null } - try { - var provideSource = sessions.currentProvideInfo - if (provideSource !== undefined && provideSource !== null - && typeof provideSource.subscribe === 'function') { - unsubProvide = provideSource.subscribe(onSessionSwitch) - } - } catch (_) { unsubProvide = null } switchTimer = setInterval(onSessionSwitch, 1000) function unsubSessionSwitch() { try { if (typeof unsubList === 'function') unsubList() } catch (_) { /* ignore */ } - try { if (typeof unsubProvide === 'function') unsubProvide() } catch (_) { /* ignore */ } if (switchTimer !== null) { clearInterval(switchTimer); switchTimer = null } } var unsub = unsubSessionSwitch diff --git a/test/message-block.test.mjs b/test/message-block.test.mjs index 5213563..8871898 100644 --- a/test/message-block.test.mjs +++ b/test/message-block.test.mjs @@ -9,7 +9,7 @@ function fn(name) { assert.ok(match, name) return match[0] } -function harness(lang, draft, sourcePath, sessionsOverride) { +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 } @@ -17,10 +17,9 @@ function harness(lang, draft, sourcePath, sessionsOverride) { const document = { createTreeWalker: () => { let i = 0; return { nextNode: () => nodes[i++] ?? null } } } const sessions = sessionsOverride ?? { list: { getSnapshot: () => ({ current: 'session' }) }, - currentProvideInfo: { getSnapshot: () => ({ sessionId: 'session' }), subscribe: () => () => {} }, scope: () => ({}), } - const api = Function('shell', 'document', 'NodeFilter', 'sourcePath', 'sessions', ` + 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: '解释一下' }] } @@ -30,7 +29,7 @@ function harness(lang, draft, sourcePath, sessionsOverride) { function showToast() {} ${['currentSessionId', 'buildBlock', 'shouldAttachForEnter', 'isCommandDraft', 'attachAndSend', 'hideAnnotationBlock', 'parseItemsFromBubble'].map(fn).join('\n')} return { setLang, attachAndSend, hideAnnotationBlock, parseItemsFromBubble } - `)(shell, document, { SHOW_TEXT: 4 }, sourcePath, sessions) + `)(shell, document, { SHOW_TEXT: 4 }, sourcePath, sessions, storageOverride) api.setLang(lang) return { api, row, bubble, shell, render(value) { nodes.length = 0 @@ -83,16 +82,25 @@ for (const lang of ['zh', 'en']) { }) } -// issue #64:DSH 0.1.6-alpha.2 移除了 sessions.list.current,插件须经 -// currentProvideInfo 取到当前会话(list 快照无 current 的真实新内核形状)。 +// 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 newKernelSessions = { - list: { getSnapshot: () => ({ ids: ['session-a'], byId: {}, phase: 'ready' }) }, - currentProvideInfo: { getSnapshot: () => ({ sessionId: 'session-a' }), subscribe: () => () => {} }, - scope: (id) => (id === 'session-a' ? {} : undefined), - } - const h = harness(lang, '我的问题', undefined, newKernelSessions) + 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/) }) diff --git a/test/pending-clear.test.mjs b/test/pending-clear.test.mjs index 81aade5..863ab7b 100644 --- a/test/pending-clear.test.mjs +++ b/test/pending-clear.test.mjs @@ -26,8 +26,8 @@ test('watchInputDraft 订阅失败时每秒重试(初始化时序洞由重试 }) test('会话切换作废旧会话未消费的发送暂存数据', () => { - // issue #64:0.1.6-alpha.2 起 list.subscribe 不再于切换时触发,改经 - // onSessionSwitch 统一处理(双订阅 + 轮询兜底),旧暂存仍须作废。 + // issue #64:0.1.6-alpha.2 起选择态移出 list store,list.subscribe 不再于切换时触发; + // 新宿主没有可订阅的选中态来源,故由 onSessionSwitch + 1s 轮询兜底(旧暂存仍须作废)。 const sw = fnOf('onSessionSwitch') assert.match(sw, /currentSessionId\(\)/, 'session-switch handler must resolve the id via currentSessionId (list.current is gone on new hosts)') @@ -35,12 +35,12 @@ test('会话切换作废旧会话未消费的发送暂存数据', () => { '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, /currentProvideInfo[\s\S]*?subscribe\(onSessionSwitch\)/, - 'must subscribe to the public currentProvideInfo for new hosts') assert.match(source, /setInterval\(onSessionSwitch, 1000\)/, - 'must poll as fallback where neither subscription fires') + '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('发送暂存数据在隐藏手术成功后才消费(peek → shift,不提前丢失)', () => { @@ -49,17 +49,19 @@ test('发送暂存数据在隐藏手术成功后才消费(peek → shift,不 'popping before hideAnnotationBlock succeeds loses the staged items when content is not rendered yet') }) -test('currentSessionId 三级解析:公开读面优先,私有键仅兜底(issue #64)', () => { +test('currentSessionId 三级解析:持久化选择态 → 旧 list.current → retainedBy 公开面(issue #64)', () => { const helper = fnOf('currentSessionId') - assert.match(helper, /currentProvideInfo/, - 'must prefer the public currentProvideInfo read face (stable across the list.current removal)') - assert.match(helper, /list\.getSnapshot/, - 'must keep the legacy list.current fallback for old hosts') assert.match(helper, /dsh\.sessions\.current/, - 'private persistence key is allowed only as last resort') - assert.ok(helper.indexOf('currentProvideInfo') < helper.indexOf('list.getSnapshot') - && helper.indexOf('list.getSnapshot') < helper.indexOf('dsh.sessions.current'), - 'resolution order must be provide → list → localStorage') + 'must read the kernel-persisted selection first — on 0.1.6-alpha.2 ~ 0.1.7-rc.2 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('dsh.sessions.current') < helper.indexOf('snap.current') + && helper.indexOf('snap.current') < helper.indexOf('retainedBy'), + 'resolution order must be 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 currentSessionId') })