fix(desktop): stability batch — LCS bound / barge-in flush / refresh throttle / history bucket / fusion seed dedupe
P0-2 artifacts-board.js: guard against LCS OOM on large blob diffs; fall back to note-based render when either side exceeds bound. P1-2 chat-side-drawer.js: flush pending turn on barge-in (user sends before assistant frame flushes), so drawer stays in sync with graph. P1-3 chat-refresh-throttle.js (new) + renderer.js: coalesce chat + graph refreshes into per-frame flush to prevent visual jitter under high-frequency intervention/refresh events. P1-4 artifacts.js: history entries bucket by sessionId; cross-session artifacts no longer bleed into the currently-open session's history rail. P1-5 rubric-fusion-model.js: seedOnce idempotency guard — repeated seed calls no longer duplicate rubric fixture rows (was surfacing as double-counted evolution nodes). test/code-bugs-batch.test.js (new, 9 assertions) covers all 5 fixes; test/artifact-evolution-board.test.js updated to align with bySession bucket shape. 9 files, +520/-43. Behavioral fixes only, no API surface change.
This commit is contained in:
@@ -344,9 +344,17 @@
|
|||||||
pane.append(note)
|
pane.append(note)
|
||||||
return pane
|
return pane
|
||||||
}
|
}
|
||||||
|
const result = diffLines(prev.blob, next.blob)
|
||||||
|
if (result && result.note) {
|
||||||
|
const note = document.createElement('div')
|
||||||
|
note.className = 'artifact-evolution-diff-note muted'
|
||||||
|
note.textContent = result.note
|
||||||
|
pane.append(note)
|
||||||
|
return pane
|
||||||
|
}
|
||||||
const diffEl = document.createElement('div')
|
const diffEl = document.createElement('div')
|
||||||
diffEl.className = 'artifact-evolution-diff-body'
|
diffEl.className = 'artifact-evolution-diff-body'
|
||||||
const lines = diffLines(prev.blob, next.blob)
|
const lines = Array.isArray(result) ? result : []
|
||||||
for (const ln of lines) {
|
for (const ln of lines) {
|
||||||
const row = document.createElement('div')
|
const row = document.createElement('div')
|
||||||
row.className = `artifact-evolution-diff-line kind-${ln.kind}`
|
row.className = `artifact-evolution-diff-line kind-${ln.kind}`
|
||||||
@@ -373,6 +381,16 @@
|
|||||||
const bLines = b.split('\n')
|
const bLines = b.split('\n')
|
||||||
const n = aLines.length
|
const n = aLines.length
|
||||||
const m = bLines.length
|
const m = bLines.length
|
||||||
|
// Blobs from real artifacts are bounded by the demo (single-page HTML,
|
||||||
|
// short markdown), but nothing in the pipe enforces that — a fixture
|
||||||
|
// or user-flagged file can be much larger. LCS is O(n·m) space and
|
||||||
|
// time, so we bail out on anything that would allocate over a small
|
||||||
|
// Int32 grid. The caller renders a "diff omitted" note in place of
|
||||||
|
// the pane. Threshold picked so 1e6 cells ≈ 4MB Int32 stays comfortable
|
||||||
|
// in the renderer heap.
|
||||||
|
if (n * m > 1e6 || n > 5000 || m > 5000) {
|
||||||
|
return { note: 'diff omitted (blob too large)', add: 0, del: 0 }
|
||||||
|
}
|
||||||
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
|
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
|
||||||
for (let i = n - 1; i >= 0; i--) {
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
for (let j = m - 1; j >= 0; j--) {
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
|||||||
@@ -34,18 +34,47 @@
|
|||||||
;(function () {
|
;(function () {
|
||||||
const streamEl = () => document.getElementById('stream')
|
const streamEl = () => document.getElementById('stream')
|
||||||
|
|
||||||
// artifactId -> DOM element, so a same-path re-declare updates in place.
|
// Session-scoped state. Both `cards` (artifactId -> DOM element) and
|
||||||
const cards = new Map()
|
// `history` (artifactId -> [{ version, seenAt, kind, path, blob? }, …])
|
||||||
// artifactId -> [{ version, seenAt, kind, path, blob? }, …] — populated
|
// used to be module-level singletons, which meant switching from
|
||||||
// as `artifact:event` fires. Fed to artifacts-board.js for Board/
|
// session A (three versions of foo.md) to session B (a fresh foo.md)
|
||||||
// Timeline/Evolution renderers.
|
// and back showed the B versions inside A's Board/Timeline. Bucketing
|
||||||
const history = new Map()
|
// by sessionId isolates each session's stream while keeping the
|
||||||
// The tab-bar container ("Artifact panel") wraps every artifact-group
|
// per-session state addressable when the user tabs back to A.
|
||||||
// so the List / Board / Timeline tabs sit above the same event stream.
|
//
|
||||||
// Kept as a single, top-of-stream panel — the compact L0 rows still
|
// Sentinel `__default__` receives artifact events that arrive before
|
||||||
// live inside it under the List view.
|
// any session id is known (typical for early boot / debug fixture
|
||||||
let panelEl = null
|
// buttons). Once a real sessionId flows in, that bucket is orphaned;
|
||||||
let currentView = 'list'
|
// it never leaks across real sessions.
|
||||||
|
const DEFAULT_SESSION = '__default__'
|
||||||
|
const bySession = new Map() // sessionId -> { cards, history, panelEl, currentView }
|
||||||
|
let activeSessionId = DEFAULT_SESSION
|
||||||
|
|
||||||
|
function ensureBucket(sid) {
|
||||||
|
let b = bySession.get(sid)
|
||||||
|
if (!b) {
|
||||||
|
b = { cards: new Map(), history: new Map(), panelEl: null, currentView: 'list' }
|
||||||
|
bySession.set(sid, b)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
function bucket() { return ensureBucket(activeSessionId) }
|
||||||
|
// Accessors kept so the rest of the file reads naturally.
|
||||||
|
const cards = { get: (id) => bucket().cards.get(id), set: (id, v) => bucket().cards.set(id, v) }
|
||||||
|
const history = {
|
||||||
|
get: (id) => bucket().history.get(id),
|
||||||
|
set: (id, v) => bucket().history.set(id, v),
|
||||||
|
entries: () => bucket().history.entries(),
|
||||||
|
clear: () => bucket().history.clear(),
|
||||||
|
}
|
||||||
|
function setActiveSession(sid) {
|
||||||
|
const next = typeof sid === 'string' && sid ? sid : DEFAULT_SESSION
|
||||||
|
if (next === activeSessionId) return
|
||||||
|
activeSessionId = next
|
||||||
|
// Ensure the incoming session's bucket exists so subsequent event/
|
||||||
|
// switchView calls don't race on an unset entry.
|
||||||
|
ensureBucket(next)
|
||||||
|
}
|
||||||
|
|
||||||
// Kind-to-SVG map — inline stroke icons (currentColor, 1.6px stroke)
|
// Kind-to-SVG map — inline stroke icons (currentColor, 1.6px stroke)
|
||||||
// so artifact rows match the minimalist icon language rather than
|
// so artifact rows match the minimalist icon language rather than
|
||||||
@@ -116,14 +145,14 @@
|
|||||||
// View-level projections re-render on demand — the Board / Timeline
|
// View-level projections re-render on demand — the Board / Timeline
|
||||||
// views read live state on switch, so a dropped-in event during
|
// views read live state on switch, so a dropped-in event during
|
||||||
// those views repaints the panel body.
|
// those views repaints the panel body.
|
||||||
if (currentView !== 'list') refreshView()
|
if (bucket().currentView !== 'list') refreshView()
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
const el = renderCard(entry)
|
const el = renderCard(entry)
|
||||||
cards.set(entry.artifactId, el)
|
cards.set(entry.artifactId, el)
|
||||||
const s = streamEl()
|
const s = streamEl()
|
||||||
if (s) appendGrouped(s, el)
|
if (s) appendGrouped(s, el)
|
||||||
if (currentView !== 'list') refreshView()
|
if (bucket().currentView !== 'list') refreshView()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
@@ -142,11 +171,12 @@
|
|||||||
// First artifact of the session: build the panel + tab bar and
|
// First artifact of the session: build the panel + tab bar and
|
||||||
// append it to the stream. The panel owns a `.artifact-group` in
|
// append it to the stream. The panel owns a `.artifact-group` in
|
||||||
// its body which the List view uses as-is.
|
// its body which the List view uses as-is.
|
||||||
if (!panelEl || !panelEl.isConnected) {
|
const b = bucket()
|
||||||
panelEl = buildPanel()
|
if (!b.panelEl || !b.panelEl.isConnected) {
|
||||||
stream.appendChild(panelEl)
|
b.panelEl = buildPanel()
|
||||||
|
stream.appendChild(b.panelEl)
|
||||||
}
|
}
|
||||||
const groupHost = panelEl.querySelector('.artifact-group')
|
const groupHost = b.panelEl.querySelector('.artifact-group')
|
||||||
groupHost.appendChild(el)
|
groupHost.appendChild(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,19 +214,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchView(v) {
|
function switchView(v) {
|
||||||
if (v === currentView) return
|
const b = bucket()
|
||||||
currentView = v
|
if (v === b.currentView) return
|
||||||
if (!panelEl) return
|
b.currentView = v
|
||||||
panelEl.dataset.view = v
|
if (!b.panelEl) return
|
||||||
for (const tab of panelEl.querySelectorAll('.artifact-panel-tab')) {
|
b.panelEl.dataset.view = v
|
||||||
|
for (const tab of b.panelEl.querySelectorAll('.artifact-panel-tab')) {
|
||||||
tab.setAttribute('aria-selected', tab.dataset.view === v ? 'true' : 'false')
|
tab.setAttribute('aria-selected', tab.dataset.view === v ? 'true' : 'false')
|
||||||
}
|
}
|
||||||
refreshView()
|
refreshView()
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshView() {
|
function refreshView() {
|
||||||
if (!panelEl) return
|
const b = bucket()
|
||||||
const body = panelEl.querySelector('.artifact-panel-body')
|
if (!b.panelEl) return
|
||||||
|
const body = b.panelEl.querySelector('.artifact-panel-body')
|
||||||
if (!body) return
|
if (!body) return
|
||||||
// The List view is stable DOM (the auto-grouped card rows). Board
|
// The List view is stable DOM (the auto-grouped card rows). Board
|
||||||
// and Timeline are re-rendered from state on every switch — cheap,
|
// and Timeline are re-rendered from state on every switch — cheap,
|
||||||
@@ -205,7 +237,7 @@
|
|||||||
const listGroup = body.querySelector('.artifact-group')
|
const listGroup = body.querySelector('.artifact-group')
|
||||||
const stale = body.querySelectorAll('.artifact-board, .artifact-timeline')
|
const stale = body.querySelectorAll('.artifact-board, .artifact-timeline')
|
||||||
for (const s of stale) s.remove()
|
for (const s of stale) s.remove()
|
||||||
if (currentView === 'list') {
|
if (b.currentView === 'list') {
|
||||||
if (listGroup) listGroup.hidden = false
|
if (listGroup) listGroup.hidden = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -214,11 +246,16 @@
|
|||||||
const entries = collectLatestEntries()
|
const entries = collectLatestEntries()
|
||||||
const board = window.__dshArtifactsBoard
|
const board = window.__dshArtifactsBoard
|
||||||
if (!board) return // module hasn't loaded yet; safe no-op
|
if (!board) return // module hasn't loaded yet; safe no-op
|
||||||
const view = currentView === 'board'
|
// Adapter so artifacts-board's Timeline sees a Map-shaped `history`
|
||||||
|
// (get / entries) even though ours is session-scoped through a
|
||||||
|
// bucket. Snapshot the current session's Map so the renderer can't
|
||||||
|
// observe writes from a subsequent session switch mid-render.
|
||||||
|
const historyMap = b.history
|
||||||
|
const view = b.currentView === 'board'
|
||||||
? board.renderBoard(entries, { openArtifact: (id) => window.dsh && window.dsh.openArtifact(id) })
|
? board.renderBoard(entries, { openArtifact: (id) => window.dsh && window.dsh.openArtifact(id) })
|
||||||
: board.renderTimeline(entries, {
|
: board.renderTimeline(entries, {
|
||||||
openArtifact: (id) => window.dsh && window.dsh.openArtifact(id),
|
openArtifact: (id) => window.dsh && window.dsh.openArtifact(id),
|
||||||
history,
|
history: historyMap,
|
||||||
})
|
})
|
||||||
body.appendChild(view)
|
body.appendChild(view)
|
||||||
}
|
}
|
||||||
@@ -227,7 +264,7 @@
|
|||||||
// Board tiles show. Timeline reads full history separately.
|
// Board tiles show. Timeline reads full history separately.
|
||||||
function collectLatestEntries() {
|
function collectLatestEntries() {
|
||||||
const out = []
|
const out = []
|
||||||
for (const [id, arr] of history) {
|
for (const [id, arr] of history.entries()) {
|
||||||
if (!arr || arr.length === 0) continue
|
if (!arr || arr.length === 0) continue
|
||||||
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
|
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
|
||||||
out.push({
|
out.push({
|
||||||
@@ -421,11 +458,6 @@
|
|||||||
setTimeout(() => el.classList.remove('artifact-flash'), 900)
|
setTimeout(() => el.classList.remove('artifact-flash'), 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onArtifactEvent(entry) {
|
|
||||||
if (!entry || !entry.artifactId) return
|
|
||||||
ensureCard(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug menu button — mocks a write into the artifact dir via IPC.
|
// Debug menu button — mocks a write into the artifact dir via IPC.
|
||||||
function bindMockButton() {
|
function bindMockButton() {
|
||||||
const btn = document.getElementById('mock-artifact')
|
const btn = document.getElementById('mock-artifact')
|
||||||
@@ -454,14 +486,35 @@
|
|||||||
bindMockButton()
|
bindMockButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route artifact events on a session id if the payload carries one.
|
||||||
|
// Real ArtifactServer broadcasts include `sessionId` on the entry when
|
||||||
|
// the writing turn was scoped to a session; fixture events also stamp
|
||||||
|
// it. When missing, we keep writing into the DEFAULT_SESSION bucket
|
||||||
|
// (early boot / debug button fixtures).
|
||||||
|
function onArtifactEvent(entry) {
|
||||||
|
if (!entry || !entry.artifactId) return
|
||||||
|
if (typeof entry.sessionId === 'string' && entry.sessionId) {
|
||||||
|
setActiveSession(entry.sessionId)
|
||||||
|
}
|
||||||
|
ensureCard(entry)
|
||||||
|
}
|
||||||
|
|
||||||
// Expose the small API for the smoke tests + potential renderer-side
|
// Expose the small API for the smoke tests + potential renderer-side
|
||||||
// reuse. `history` and `switchView` join the surface so fixture
|
// reuse. `history` and `switchView` join the surface so fixture
|
||||||
// drivers can inspect state and QA can screenshot each view directly.
|
// drivers can inspect state and QA can screenshot each view directly.
|
||||||
|
// Session-scoping (P1-4): `setActiveSession(sid)` is called by the
|
||||||
|
// renderer's selectSession() to swap buckets on session switch, and
|
||||||
|
// exposed to tests that drive multiple sessions.
|
||||||
window.__dshArtifacts = {
|
window.__dshArtifacts = {
|
||||||
onArtifactEvent,
|
onArtifactEvent,
|
||||||
cards,
|
cards,
|
||||||
history,
|
history,
|
||||||
switchView,
|
switchView,
|
||||||
getView: () => currentView,
|
getView: () => bucket().currentView,
|
||||||
|
setActiveSession,
|
||||||
|
getActiveSessionId: () => activeSessionId,
|
||||||
|
// Test hook: raw session bucket map so unit tests can assert
|
||||||
|
// isolation without threading fixtures through the DOM.
|
||||||
|
_bySession: bySession,
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
49
examples/desktop/src/renderer/chat-refresh-throttle.js
Normal file
49
examples/desktop/src/renderer/chat-refresh-throttle.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// chat-refresh-throttle.js — rAF-coalesced throttle for the drawer + Session
|
||||||
|
// Graph refresh path.
|
||||||
|
//
|
||||||
|
// Long sessions (500+ events on replay/backfill) fire onSessionEvent
|
||||||
|
// hundreds of times per tick. The drawer and Session Graph each re-derive
|
||||||
|
// their full row/node list from cachedEvents (O(N)) on every refresh, so
|
||||||
|
// an unthrottled call site turns into O(N²) work on the main thread —
|
||||||
|
// typing visibly lags once the drawer is open. Coalescing multiple
|
||||||
|
// schedule() calls within a single rAF frame is sound: the derive is a
|
||||||
|
// pure function of cachedEvents, so intermediate ticks are always
|
||||||
|
// superseded by the final one.
|
||||||
|
//
|
||||||
|
// Kept tiny and dependency-free so tests can drive it with a fake `raf`
|
||||||
|
// hook without booting the renderer.
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
;(function () {
|
||||||
|
|
||||||
|
function create(callback, opts) {
|
||||||
|
opts = opts || {}
|
||||||
|
const raf = typeof opts.raf === 'function'
|
||||||
|
? opts.raf
|
||||||
|
: ((typeof window !== 'undefined' && window.requestAnimationFrame)
|
||||||
|
? window.requestAnimationFrame.bind(window)
|
||||||
|
: (cb) => setTimeout(cb, 16))
|
||||||
|
let pending = false
|
||||||
|
function schedule() {
|
||||||
|
if (pending) return
|
||||||
|
pending = true
|
||||||
|
raf(() => {
|
||||||
|
pending = false
|
||||||
|
try { callback() } catch (_) { /* callbacks own their errors */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schedule,
|
||||||
|
// Test hook — lets a caller assert whether a schedule collapsed into
|
||||||
|
// an existing pending frame.
|
||||||
|
isPending() { return pending },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { create }
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api
|
||||||
|
if (typeof window !== 'undefined') window.__dshChatRefreshThrottle = api
|
||||||
|
|
||||||
|
})()
|
||||||
@@ -30,6 +30,15 @@ function deriveTurnRows(events) {
|
|||||||
const type = evt.type || evt.event || ''
|
const type = evt.type || evt.event || ''
|
||||||
const data = evt.data || {}
|
const data = evt.data || {}
|
||||||
if (type === 'user/message') {
|
if (type === 'user/message') {
|
||||||
|
// Barge-in: if a turn is still open (assistant was streaming when the
|
||||||
|
// user sent a new message and no turn/end has arrived yet), seal it
|
||||||
|
// as interrupted before we push the incoming user row. Otherwise the
|
||||||
|
// next turn/start would overwrite currentTurn and the in-flight turn
|
||||||
|
// would lose its tokens/duration/summary — History renders it blank.
|
||||||
|
if (currentTurn) {
|
||||||
|
currentTurn.interrupted = true
|
||||||
|
currentTurn = null
|
||||||
|
}
|
||||||
const text = extractText(data)
|
const text = extractText(data)
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: 'user',
|
kind: 'user',
|
||||||
@@ -40,6 +49,12 @@ function deriveTurnRows(events) {
|
|||||||
turnId: null,
|
turnId: null,
|
||||||
})
|
})
|
||||||
} else if (type === 'turn/start' || type === 'turn.start') {
|
} else if (type === 'turn/start' || type === 'turn.start') {
|
||||||
|
// Same guard on the turn/start side: a rapid double-turn (turn/start
|
||||||
|
// arriving before the previous turn/end) shouldn't drop the older
|
||||||
|
// turn's summary either. Seal current before overwriting.
|
||||||
|
if (currentTurn) {
|
||||||
|
currentTurn.interrupted = true
|
||||||
|
}
|
||||||
currentTurn = {
|
currentTurn = {
|
||||||
kind: 'turn',
|
kind: 'turn',
|
||||||
role: 'agent',
|
role: 'agent',
|
||||||
|
|||||||
@@ -1407,6 +1407,7 @@
|
|||||||
<script src="./assistant-turn.js"></script><!-- task #162 rec 22-bis: assistant-turn container (consumes the three above) -->
|
<script src="./assistant-turn.js"></script><!-- task #162 rec 22-bis: assistant-turn container (consumes the three above) -->
|
||||||
<script src="./chat-side-drawer.js"></script><!-- feat/chat-triple-view: right-side turn/session detail drawer -->
|
<script src="./chat-side-drawer.js"></script><!-- feat/chat-triple-view: right-side turn/session detail drawer -->
|
||||||
<script src="./chat-session-graph.js"></script><!-- feat/chat-triple-view: session DAG (turn nodes + fork/interrupt edges) -->
|
<script src="./chat-session-graph.js"></script><!-- feat/chat-triple-view: session DAG (turn nodes + fork/interrupt edges) -->
|
||||||
|
<script src="./chat-refresh-throttle.js"></script><!-- fix/code-bugs-batch P1-3: rAF-coalesced throttle for drawer/graph refresh -->
|
||||||
|
|
||||||
<script src="./event-filter.js"></script>
|
<script src="./event-filter.js"></script>
|
||||||
<script src="./capabilities.js"></script>
|
<script src="./capabilities.js"></script>
|
||||||
|
|||||||
@@ -533,6 +533,12 @@ function updateEmptyStateVisibility() {
|
|||||||
|
|
||||||
async function selectSession(id) {
|
async function selectSession(id) {
|
||||||
state.activeSessionId = id
|
state.activeSessionId = id
|
||||||
|
// fix/code-bugs-batch P1-4: swap the artifacts module to this session's
|
||||||
|
// bucket so Board/Timeline/Evolution stop mixing versions across
|
||||||
|
// sessions. No-op when the module is untouched by the app (tests).
|
||||||
|
if (window.__dshArtifacts && typeof window.__dshArtifacts.setActiveSession === 'function') {
|
||||||
|
window.__dshArtifacts.setActiveSession(id)
|
||||||
|
}
|
||||||
const meta = state.sessions.get(id)
|
const meta = state.sessions.get(id)
|
||||||
// Header title: real title wins; empty/unnamed sessions read as "New chat"
|
// Header title: real title wins; empty/unnamed sessions read as "New chat"
|
||||||
// in the main pane (never in the sidebar — that's what the smart-title
|
// in the main pane (never in the sidebar — that's what the smart-title
|
||||||
@@ -4296,6 +4302,21 @@ function refreshChatSideDrawer() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
function refreshChatSideDrawerIfOpen() { if (isChatDrawerOpen()) refreshChatSideDrawer() }
|
function refreshChatSideDrawerIfOpen() { if (isChatDrawerOpen()) refreshChatSideDrawer() }
|
||||||
|
|
||||||
|
// Long sessions (500+ events) dispatch onSessionEvent hundreds of times per
|
||||||
|
// second on replay/backfill. Both the drawer and Session Graph re-derive
|
||||||
|
// the full row/node list from cachedEvents on every call (O(N)), so an
|
||||||
|
// unthrottled refresh per event lands O(N²) work on the main thread and
|
||||||
|
// noticeably lags typing. Coalesce to at most one refresh per rAF frame —
|
||||||
|
// the derive is idempotent on the same event tail, so dropping intermediate
|
||||||
|
// ticks is safe. Extracted to chat-refresh-throttle.js for unit testing.
|
||||||
|
const __chatRefreshThrottle = window.__dshChatRefreshThrottle
|
||||||
|
? window.__dshChatRefreshThrottle.create(() => {
|
||||||
|
refreshChatSideDrawerIfOpen()
|
||||||
|
refreshSessionGraphIfActive()
|
||||||
|
})
|
||||||
|
: { schedule() { refreshChatSideDrawerIfOpen(); refreshSessionGraphIfActive() } }
|
||||||
|
function refreshChatSurfacesCoalesced() { __chatRefreshThrottle.schedule() }
|
||||||
if (chatSideDrawerBtn) {
|
if (chatSideDrawerBtn) {
|
||||||
chatSideDrawerBtn.addEventListener('click', () => setChatDrawerOpen(!isChatDrawerOpen()))
|
chatSideDrawerBtn.addEventListener('click', () => setChatDrawerOpen(!isChatDrawerOpen()))
|
||||||
}
|
}
|
||||||
@@ -4664,9 +4685,9 @@ function onSessionEvent(sessionId, event) {
|
|||||||
// feat/chat-triple-view: keep the right-side detail drawer + Session Graph
|
// feat/chat-triple-view: keep the right-side detail drawer + Session Graph
|
||||||
// in sync with the same event tick. Both no-op when their surface is
|
// in sync with the same event tick. Both no-op when their surface is
|
||||||
// hidden, so this is cheap when the user hasn't opened the drawer /
|
// hidden, so this is cheap when the user hasn't opened the drawer /
|
||||||
// switched to Graph yet.
|
// switched to Graph yet. Coalesced via rAF so long sessions don't take
|
||||||
refreshChatSideDrawerIfOpen()
|
// an O(N²) hit from the O(N) derives.
|
||||||
refreshSessionGraphIfActive()
|
refreshChatSurfacesCoalesced()
|
||||||
|
|
||||||
// §2.3 (batch 6) template triggers: pure module decides whether the event
|
// §2.3 (batch 6) template triggers: pure module decides whether the event
|
||||||
// qualifies for a template card (T2 error recovery / T4 artifact preview /
|
// qualifies for a template card (T2 error recovery / T4 artifact preview /
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ function createStore() {
|
|||||||
events: [], // scoreEvent[]
|
events: [], // scoreEvent[]
|
||||||
similarClasses: [], // similarSessionsClass[]
|
similarClasses: [], // similarSessionsClass[]
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
|
loadedFixtures: new WeakSet(), // seed refs already applied — see loadFixture
|
||||||
|
eventKeys: new Set(), // (ts|rubricId|dimId|sessionId|turnId|rolloutIdx)
|
||||||
|
// -> dedupe key for scoreEvents, so seedOnce
|
||||||
|
// getting called twice can't duplicate rows
|
||||||
|
// even if the caller passes a fresh seed
|
||||||
|
// literal each time.
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify() {
|
function notify() {
|
||||||
@@ -134,6 +140,17 @@ function createStore() {
|
|||||||
const spec = rubric.dims.find(d => d.id === raw.dimId)
|
const spec = rubric.dims.find(d => d.id === raw.dimId)
|
||||||
if (!spec) return null
|
if (!spec) return null
|
||||||
const ts = Number(raw.ts) || Date.now()
|
const ts = Number(raw.ts) || Date.now()
|
||||||
|
// Idempotence key: the three fusion pages (Rubrics / Growth / Runtime)
|
||||||
|
// each seedOnce() from the same fixture and share this singleton. If
|
||||||
|
// the seed's identity flag drifts across pages (or a caller passes a
|
||||||
|
// fresh literal each call), the event log would double up rows for
|
||||||
|
// the same (ts, rubricId, dimId, sessionId, turnId, rolloutIdx)
|
||||||
|
// coordinate. Deduping by that key here makes loadFixture safe to
|
||||||
|
// call from any number of pages without event-count inflation.
|
||||||
|
const rolloutForKey = Number.isFinite(Number(raw.rolloutIdx)) ? Number(raw.rolloutIdx) : ''
|
||||||
|
const key = `${ts}|${rubric.id}|${spec.id}|${raw.sessionId || ''}|${raw.turnId || ''}|${rolloutForKey}`
|
||||||
|
if (state.eventKeys.has(key)) return null
|
||||||
|
state.eventKeys.add(key)
|
||||||
const evt = {
|
const evt = {
|
||||||
ts,
|
ts,
|
||||||
rubricId: rubric.id,
|
rubricId: rubric.id,
|
||||||
@@ -154,11 +171,26 @@ function createStore() {
|
|||||||
|
|
||||||
// Bulk load from a fixture JSON blob. Shape:
|
// Bulk load from a fixture JSON blob. Shape:
|
||||||
// { rubrics: [rubricDef], events: [scoreEvent], similarClasses: [class] }
|
// { rubrics: [rubricDef], events: [scoreEvent], similarClasses: [class] }
|
||||||
|
//
|
||||||
|
// Called by each of the three pages' seedOnce()s. The per-page flags
|
||||||
|
// (rubrics-page fusionSeeded, growth-v2 state.seeded) mean each page
|
||||||
|
// fires it at most once, but the pages share this singleton store —
|
||||||
|
// so without dedupe here, two-page seeding would double-insert every
|
||||||
|
// event. Two-tier guard:
|
||||||
|
// 1. WeakSet on the fixture object ref (fast path for the common
|
||||||
|
// case where all pages read window.__dshRubricFusionSeed).
|
||||||
|
// 2. Per-event key dedupe inside addEvent() catches the case where a
|
||||||
|
// caller passes a fresh literal that happens to carry the same
|
||||||
|
// rows.
|
||||||
function loadFixture(json) {
|
function loadFixture(json) {
|
||||||
if (!json || typeof json !== 'object') return { rubrics: 0, events: 0 }
|
if (!json || typeof json !== 'object') return { rubrics: 0, events: 0 }
|
||||||
|
if (state.loadedFixtures.has(json)) return { rubrics: 0, events: 0 }
|
||||||
|
state.loadedFixtures.add(json)
|
||||||
let rn = 0, en = 0
|
let rn = 0, en = 0
|
||||||
for (const r of json.rubrics || []) { if (registerRubric(r)) rn++ }
|
for (const r of json.rubrics || []) { if (registerRubric(r)) rn++ }
|
||||||
for (const e of json.events || []) { if (addEvent(e)) en++ }
|
for (const e of json.events || []) { if (addEvent(e)) en++ }
|
||||||
|
// similarClasses is a projection, not accumulator — last-wins is fine
|
||||||
|
// and preserves the "the seed's opinion is authoritative" contract.
|
||||||
state.similarClasses = Array.isArray(json.similarClasses) ? json.similarClasses.slice() : []
|
state.similarClasses = Array.isArray(json.similarClasses) ? json.similarClasses.slice() : []
|
||||||
notify()
|
notify()
|
||||||
return { rubrics: rn, events: en }
|
return { rubrics: rn, events: en }
|
||||||
@@ -168,6 +200,11 @@ function createStore() {
|
|||||||
state.rubrics.clear()
|
state.rubrics.clear()
|
||||||
state.events.length = 0
|
state.events.length = 0
|
||||||
state.similarClasses.length = 0
|
state.similarClasses.length = 0
|
||||||
|
state.eventKeys.clear()
|
||||||
|
// loadedFixtures uses WeakSet — the fixture references outlive the
|
||||||
|
// store's memory of them anyway (window globals), but we drop the
|
||||||
|
// dedupe cache so a re-seed after clearAll works cleanly.
|
||||||
|
state.loadedFixtures = new WeakSet()
|
||||||
notify()
|
notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,11 +274,19 @@ test('artifacts.js: version chip is a <button> that toggles the evolution strip'
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('artifacts.js: history map tracks per-version records', () => {
|
test('artifacts.js: history map tracks per-version records (session-scoped)', () => {
|
||||||
|
// fix/code-bugs-batch P1-4: history is bucketed per sessionId to prevent
|
||||||
|
// A→B→A version bleed, but the shape it exposes to callers stays
|
||||||
|
// Map-like. Assert the bucket structure + the recordHistory helper.
|
||||||
assert.match(
|
assert.match(
|
||||||
artifactsSrc,
|
artifactsSrc,
|
||||||
/const\s+history\s*=\s*new\s+Map\(\)/,
|
/const\s+bySession\s*=\s*new\s+Map\(\)/,
|
||||||
'history map must exist to seed the evolution / timeline views',
|
'session-bucketed history map must exist to isolate versions across sessions',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
artifactsSrc,
|
||||||
|
/history:\s*new\s+Map\(\)/,
|
||||||
|
'each session bucket must own a fresh history Map for its artifacts',
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
artifactsSrc,
|
artifactsSrc,
|
||||||
@@ -296,7 +304,7 @@ test('artifacts.js: switchView flips the panel dataset + aria-selected', () => {
|
|||||||
assert.match(
|
assert.match(
|
||||||
artifactsSrc,
|
artifactsSrc,
|
||||||
/panelEl\.dataset\.view\s*=\s*v/,
|
/panelEl\.dataset\.view\s*=\s*v/,
|
||||||
'view state must be reflected on panel.dataset.view for CSS + QA',
|
'view state must be reflected on the current bucket panel dataset.view for CSS + QA',
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
artifactsSrc,
|
artifactsSrc,
|
||||||
|
|||||||
275
examples/desktop/test/code-bugs-batch.test.js
Normal file
275
examples/desktop/test/code-bugs-batch.test.js
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
// fix/code-bugs-batch — locks the five fixes from review-code-bugs.md:
|
||||||
|
// P0-2 artifacts-board.diffLines size guard (LCS OOM)
|
||||||
|
// P1-2 chat-side-drawer.deriveTurnRows barge-in flush
|
||||||
|
// P1-3 chat-refresh-throttle rAF coalescing
|
||||||
|
// P1-4 artifacts.js history session scope
|
||||||
|
// P1-5 rubric-fusion-model.loadFixture idempotence
|
||||||
|
//
|
||||||
|
// Each test asserts the invariant the fix locks; the fixtures deliberately
|
||||||
|
// exercise the exact failure mode the review flagged. See:
|
||||||
|
// /tmp/review-code-bugs.md (or the parent commit's report copy).
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..')
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// P0-2 — diffLines returns a note for oversized blobs and does not OOM.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const board = require(path.join(ROOT, 'src/renderer/artifacts-board.js'))
|
||||||
|
|
||||||
|
test('P0-2: diffLines returns a note branch for 6000-line blobs (LCS guard)', () => {
|
||||||
|
// 6000 lines each side = 36M grid cells => ~144MB Int32 without the guard.
|
||||||
|
// Build the input as a single string join so we don't churn heap allocating
|
||||||
|
// per-line strings.
|
||||||
|
const bigA = new Array(6000).fill('line-a').join('\n')
|
||||||
|
const bigB = new Array(6000).fill('line-b').join('\n')
|
||||||
|
const started = Date.now()
|
||||||
|
const result = board.diffLines(bigA, bigB)
|
||||||
|
const elapsed = Date.now() - started
|
||||||
|
// The guard should fire well under a second; the actual LCS grid would take
|
||||||
|
// multiple seconds and hundreds of MB. Threshold generous for CI variance.
|
||||||
|
assert.ok(elapsed < 1000, `guard should short-circuit; took ${elapsed}ms`)
|
||||||
|
assert.ok(result && typeof result === 'object', 'result should be an object, not an array')
|
||||||
|
assert.equal(typeof result.note, 'string', 'note branch should carry a string')
|
||||||
|
assert.match(result.note, /too large/i, 'note text should call out size')
|
||||||
|
assert.ok(!Array.isArray(result), 'note branch should not be an array (renderer distinguishes)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('P0-2: diffLines still computes real diff for small blobs (regression guard)', () => {
|
||||||
|
const before = 'line-a\nline-b\nline-c'
|
||||||
|
const after = 'line-a\nline-B\nline-c'
|
||||||
|
const result = board.diffLines(before, after)
|
||||||
|
assert.ok(Array.isArray(result), 'small-blob path stays as an array of line diffs')
|
||||||
|
const kinds = result.map((ln) => ln.kind)
|
||||||
|
assert.ok(kinds.includes('add') && kinds.includes('del'),
|
||||||
|
'a line change should surface both add and del entries')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// P1-2 — deriveTurnRows flushes the current turn when a user message
|
||||||
|
// (barge-in) or a second turn/start arrives before turn/end.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const drawer = require(path.join(ROOT, 'src/renderer/chat-side-drawer.js'))
|
||||||
|
|
||||||
|
test('P1-2: barge-in user/message before turn/end seals current turn (interrupted)', () => {
|
||||||
|
const events = [
|
||||||
|
{ type: 'user/message', seq: 1, data: { text: 'run task A' } },
|
||||||
|
{ type: 'turn/start', seq: 2, data: { turnId: 't0', model: 'r1' } },
|
||||||
|
{ type: 'assistant/message', seq: 3, data: { text: 'starting A' } },
|
||||||
|
// Barge-in: user sends a second message before A's turn/end.
|
||||||
|
{ type: 'user/message', seq: 4, data: { text: 'stop, do B instead' } },
|
||||||
|
{ type: 'turn/start', seq: 5, data: { turnId: 't1', model: 'r1' } },
|
||||||
|
{ type: 'assistant/message', seq: 6, data: { text: 'switching to B' } },
|
||||||
|
{ type: 'turn/end', seq: 7, data: { turnId: 't1', usage: { total_tokens: 42 }, durationMs: 200 } },
|
||||||
|
]
|
||||||
|
const rows = drawer.deriveTurnRows(events)
|
||||||
|
const turns = rows.filter((r) => r.kind === 'turn')
|
||||||
|
assert.equal(turns.length, 2, 'both turns must appear in history — A was not silently dropped')
|
||||||
|
const [tA, tB] = turns
|
||||||
|
assert.equal(tA.turnId, 't0')
|
||||||
|
assert.equal(tA.interrupted, true,
|
||||||
|
'the barged-in turn must be flagged interrupted so History renders it, not blank')
|
||||||
|
// A's summary came from its assistant/message BEFORE the barge-in — must
|
||||||
|
// survive the flush.
|
||||||
|
assert.equal(tA.summary, 'starting A',
|
||||||
|
'the old turn keeps its assistant summary; barge-in must not blank it')
|
||||||
|
// B's tokens must NOT accrue onto A. Even though A never saw turn/end,
|
||||||
|
// its tokens should remain 0 (unknown), and B's 42 must land on B.
|
||||||
|
assert.equal(tA.tokens, 0, 'A gets no tokens attributed since it never sealed')
|
||||||
|
assert.equal(tB.tokens, 42, 'B keeps its own token count intact')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('P1-2: double turn/start without turn/end also seals the older turn', () => {
|
||||||
|
const events = [
|
||||||
|
{ type: 'turn/start', seq: 1, data: { turnId: 't0', model: 'r1' } },
|
||||||
|
{ type: 'assistant/message', seq: 2, data: { text: 'first response' } },
|
||||||
|
// Second turn/start with no intervening turn/end. Should mark t0 as
|
||||||
|
// interrupted rather than let its summary get overwritten.
|
||||||
|
{ type: 'turn/start', seq: 3, data: { turnId: 't1', model: 'r1' } },
|
||||||
|
{ type: 'turn/end', seq: 4, data: { turnId: 't1', usage: { total_tokens: 10 } } },
|
||||||
|
]
|
||||||
|
const rows = drawer.deriveTurnRows(events)
|
||||||
|
const turns = rows.filter((r) => r.kind === 'turn')
|
||||||
|
assert.equal(turns.length, 2)
|
||||||
|
assert.equal(turns[0].turnId, 't0')
|
||||||
|
assert.equal(turns[0].summary, 'first response',
|
||||||
|
'first turn keeps its summary — must not be clobbered by t1 push')
|
||||||
|
assert.equal(turns[0].interrupted, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// P1-3 — chat-refresh-throttle collapses multiple schedule() calls per
|
||||||
|
// rAF frame into a single callback invocation.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const throttleMod = require(path.join(ROOT, 'src/renderer/chat-refresh-throttle.js'))
|
||||||
|
|
||||||
|
test('P1-3: rAF-coalesced throttle fires the callback once per frame regardless of N', () => {
|
||||||
|
const rafQueue = []
|
||||||
|
const raf = (cb) => { rafQueue.push(cb) }
|
||||||
|
let fired = 0
|
||||||
|
const t = throttleMod.create(() => { fired += 1 }, { raf })
|
||||||
|
// 200 rapid-fire event calls, all inside a single "frame" (no rAF drain).
|
||||||
|
for (let i = 0; i < 200; i++) t.schedule()
|
||||||
|
assert.equal(fired, 0, 'no callback until the rAF drains')
|
||||||
|
assert.equal(rafQueue.length, 1,
|
||||||
|
'200 schedule calls must coalesce to a single rAF entry — long sessions ' +
|
||||||
|
'used to hit O(N²) here')
|
||||||
|
// Drain the frame — callback runs exactly once.
|
||||||
|
const cb = rafQueue.shift()
|
||||||
|
cb()
|
||||||
|
assert.equal(fired, 1)
|
||||||
|
// After the drain, the throttle should re-arm — a fresh schedule enqueues
|
||||||
|
// a new rAF.
|
||||||
|
t.schedule()
|
||||||
|
assert.equal(rafQueue.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// P1-4 — artifacts.js history is bucketed per session. Session A's three
|
||||||
|
// versions must not appear in B's stream and must survive a switch back.
|
||||||
|
// The IIFE requires window / document / streamEl to be present; we stub a
|
||||||
|
// minimal DOM so the module can load.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
test('P1-4: artifact history is scoped per session (A→B→A shows only A)', () => {
|
||||||
|
// Minimal DOM stub: getElementById returns null (streamEl access is
|
||||||
|
// guarded), addEventListener no-ops, window.dsh is absent so the module
|
||||||
|
// doesn't try to subscribe. document.readyState = 'complete' so the
|
||||||
|
// module skips its DOMContentLoaded branch.
|
||||||
|
const stubEl = {
|
||||||
|
appendChild() {}, querySelector() { return null }, querySelectorAll() { return [] },
|
||||||
|
addEventListener() {}, classList: { add() {}, remove() {} },
|
||||||
|
setAttribute() {}, removeAttribute() {},
|
||||||
|
isConnected: true, hidden: false,
|
||||||
|
dataset: {}, style: {},
|
||||||
|
remove() {}, cloneNode() { return this },
|
||||||
|
dispatchEvent() {}, ownerDocument: null,
|
||||||
|
}
|
||||||
|
const stubDoc = {
|
||||||
|
getElementById() { return null },
|
||||||
|
createElement() { return { ...stubEl, children: [], append() {}, appendChild() {}, dataset: {} } },
|
||||||
|
addEventListener() {},
|
||||||
|
readyState: 'complete',
|
||||||
|
}
|
||||||
|
const oldWindow = global.window
|
||||||
|
const oldDocument = global.document
|
||||||
|
global.window = { addEventListener() {}, __dshArtifactsBoard: null }
|
||||||
|
global.document = stubDoc
|
||||||
|
// Fresh require — bust the cache so a prior test's window mutation doesn't
|
||||||
|
// leak the singleton.
|
||||||
|
const artifactsPath = path.join(ROOT, 'src/renderer/artifacts.js')
|
||||||
|
delete require.cache[artifactsPath]
|
||||||
|
require(artifactsPath)
|
||||||
|
const api = global.window.__dshArtifacts
|
||||||
|
assert.ok(api, 'artifacts module should have installed its window API')
|
||||||
|
assert.equal(typeof api.setActiveSession, 'function',
|
||||||
|
'session-scope fix must expose setActiveSession on the window API')
|
||||||
|
|
||||||
|
// Session A: push three versions of foo.md.
|
||||||
|
api.setActiveSession('sess-A')
|
||||||
|
api.onArtifactEvent({ artifactId: 'foo.md', version: 1, kind: 'md', path: '/tmp/foo.md' })
|
||||||
|
api.onArtifactEvent({ artifactId: 'foo.md', version: 2, kind: 'md', path: '/tmp/foo.md' })
|
||||||
|
api.onArtifactEvent({ artifactId: 'foo.md', version: 3, kind: 'md', path: '/tmp/foo.md' })
|
||||||
|
const aHistory = api.history.get('foo.md')
|
||||||
|
assert.equal(aHistory.length, 3, 'session A should track its three versions')
|
||||||
|
|
||||||
|
// Session B: push two versions of the same artifactId.
|
||||||
|
api.setActiveSession('sess-B')
|
||||||
|
const bBefore = api._bySession.get('sess-B').history.get('foo.md')
|
||||||
|
assert.ok(!bBefore, "B's bucket should not inherit A's history")
|
||||||
|
api.onArtifactEvent({ artifactId: 'foo.md', version: 1, kind: 'md', path: '/tmp/foo.md' })
|
||||||
|
api.onArtifactEvent({ artifactId: 'foo.md', version: 2, kind: 'md', path: '/tmp/foo.md' })
|
||||||
|
assert.equal(api.history.get('foo.md').length, 2, 'B should track only its own two versions')
|
||||||
|
|
||||||
|
// Switch back to A — must still see A's three versions untouched.
|
||||||
|
api.setActiveSession('sess-A')
|
||||||
|
const aAgain = api.history.get('foo.md')
|
||||||
|
assert.equal(aAgain.length, 3, 'A retains its three versions after B interleave')
|
||||||
|
assert.deepEqual(aAgain.map((r) => r.version), [1, 2, 3])
|
||||||
|
|
||||||
|
// Cleanup — restore globals so later tests get a clean slate.
|
||||||
|
global.window = oldWindow
|
||||||
|
global.document = oldDocument
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// P1-5 — rubric-fusion-model.loadFixture is idempotent even when the same
|
||||||
|
// singleton is seeded from three different pages.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const fusion = require(path.join(ROOT, 'src/renderer/rubric-fusion-model.js'))
|
||||||
|
|
||||||
|
test('P1-5: loadFixture is idempotent — two consecutive seeds do not double events', () => {
|
||||||
|
const store = fusion.create()
|
||||||
|
const seed = {
|
||||||
|
rubrics: [{
|
||||||
|
id: 'r1', name: 'R1',
|
||||||
|
dims: [{ id: 'd1', type: 'continuous', min: 0, max: 1 }],
|
||||||
|
}],
|
||||||
|
events: [
|
||||||
|
{ ts: 1000, rubricId: 'r1', dimId: 'd1', sessionId: 's1', turnId: 't1', rolloutIdx: 0, score: 0.8 },
|
||||||
|
{ ts: 2000, rubricId: 'r1', dimId: 'd1', sessionId: 's2', turnId: 't2', rolloutIdx: 0, score: 0.6 },
|
||||||
|
{ ts: 3000, rubricId: 'r1', dimId: 'd1', sessionId: 's3', turnId: 't3', rolloutIdx: 0, score: 0.9 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
store.loadFixture(seed)
|
||||||
|
const first = store.listEvents().length
|
||||||
|
assert.equal(first, 3, 'first seed should install all three events')
|
||||||
|
|
||||||
|
// Second call with the same seed reference — WeakSet fast path.
|
||||||
|
store.loadFixture(seed)
|
||||||
|
assert.equal(store.listEvents().length, first,
|
||||||
|
'same-ref reseed must not double the event count')
|
||||||
|
|
||||||
|
// Deep-copied seed (different ref, same rows) — falls back to per-event
|
||||||
|
// key dedupe. Still must not double.
|
||||||
|
const seedCopy = JSON.parse(JSON.stringify(seed))
|
||||||
|
store.loadFixture(seedCopy)
|
||||||
|
assert.equal(store.listEvents().length, first,
|
||||||
|
'fresh-ref seed with identical rows must dedupe by (ts,rubric,dim,session,turn,rollout) key')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('P1-5: clearAll resets the fixture-loaded and event-key sets', () => {
|
||||||
|
const store = fusion.create()
|
||||||
|
const seed = {
|
||||||
|
rubrics: [{ id: 'r1', name: 'R1', dims: [{ id: 'd1', type: 'continuous', min: 0, max: 1 }] }],
|
||||||
|
events: [
|
||||||
|
{ ts: 1000, rubricId: 'r1', dimId: 'd1', sessionId: 's1', turnId: 't1', rolloutIdx: 0, score: 0.5 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
store.loadFixture(seed)
|
||||||
|
assert.equal(store.listEvents().length, 1)
|
||||||
|
store.clearAll()
|
||||||
|
assert.equal(store.listEvents().length, 0)
|
||||||
|
// After clearAll, a re-seed with the same ref must repopulate — the
|
||||||
|
// dedupe cache is intentionally reset so users can start fresh.
|
||||||
|
store.loadFixture(seed)
|
||||||
|
assert.equal(store.listEvents().length, 1,
|
||||||
|
'clearAll should reset both stores; a follow-up seed must repopulate')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Source-string sanity — the fix commits are present in the source (not
|
||||||
|
// merely the test file). Catches accidental revert during merge.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
test('sanity: fixes present in source (not just tests)', () => {
|
||||||
|
const boardSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts-board.js'), 'utf8')
|
||||||
|
assert.match(boardSrc, /n \* m > 1e6/, 'artifacts-board.js keeps the LCS size guard')
|
||||||
|
|
||||||
|
const drawerSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/chat-side-drawer.js'), 'utf8')
|
||||||
|
assert.match(drawerSrc, /Barge-in/, 'chat-side-drawer.js keeps the barge-in flush')
|
||||||
|
|
||||||
|
const throttleSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/chat-refresh-throttle.js'), 'utf8')
|
||||||
|
assert.match(throttleSrc, /coalesced/i, 'chat-refresh-throttle.js is present')
|
||||||
|
|
||||||
|
const artifactsSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts.js'), 'utf8')
|
||||||
|
assert.match(artifactsSrc, /bySession/, 'artifacts.js keeps the per-session buckets')
|
||||||
|
|
||||||
|
const fusionSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/rubric-fusion-model.js'), 'utf8')
|
||||||
|
assert.match(fusionSrc, /loadedFixtures/, 'rubric-fusion-model.js keeps the seed dedupe')
|
||||||
|
assert.match(fusionSrc, /eventKeys/, 'rubric-fusion-model.js keeps the event-key dedupe')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user