mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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)
|
||||
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')
|
||||
diffEl.className = 'artifact-evolution-diff-body'
|
||||
const lines = diffLines(prev.blob, next.blob)
|
||||
const lines = Array.isArray(result) ? result : []
|
||||
for (const ln of lines) {
|
||||
const row = document.createElement('div')
|
||||
row.className = `artifact-evolution-diff-line kind-${ln.kind}`
|
||||
@@ -373,6 +381,16 @@
|
||||
const bLines = b.split('\n')
|
||||
const n = aLines.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))
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
|
||||
@@ -34,18 +34,47 @@
|
||||
;(function () {
|
||||
const streamEl = () => document.getElementById('stream')
|
||||
|
||||
// artifactId -> DOM element, so a same-path re-declare updates in place.
|
||||
const cards = new Map()
|
||||
// artifactId -> [{ version, seenAt, kind, path, blob? }, …] — populated
|
||||
// as `artifact:event` fires. Fed to artifacts-board.js for Board/
|
||||
// Timeline/Evolution renderers.
|
||||
const history = new Map()
|
||||
// The tab-bar container ("Artifact panel") wraps every artifact-group
|
||||
// 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
|
||||
// live inside it under the List view.
|
||||
let panelEl = null
|
||||
let currentView = 'list'
|
||||
// Session-scoped state. Both `cards` (artifactId -> DOM element) and
|
||||
// `history` (artifactId -> [{ version, seenAt, kind, path, blob? }, …])
|
||||
// used to be module-level singletons, which meant switching from
|
||||
// session A (three versions of foo.md) to session B (a fresh foo.md)
|
||||
// and back showed the B versions inside A's Board/Timeline. Bucketing
|
||||
// by sessionId isolates each session's stream while keeping the
|
||||
// per-session state addressable when the user tabs back to A.
|
||||
//
|
||||
// Sentinel `__default__` receives artifact events that arrive before
|
||||
// any session id is known (typical for early boot / debug fixture
|
||||
// buttons). Once a real sessionId flows in, that bucket is orphaned;
|
||||
// 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)
|
||||
// so artifact rows match the minimalist icon language rather than
|
||||
@@ -116,14 +145,14 @@
|
||||
// View-level projections re-render on demand — the Board / Timeline
|
||||
// views read live state on switch, so a dropped-in event during
|
||||
// those views repaints the panel body.
|
||||
if (currentView !== 'list') refreshView()
|
||||
if (bucket().currentView !== 'list') refreshView()
|
||||
return existing
|
||||
}
|
||||
const el = renderCard(entry)
|
||||
cards.set(entry.artifactId, el)
|
||||
const s = streamEl()
|
||||
if (s) appendGrouped(s, el)
|
||||
if (currentView !== 'list') refreshView()
|
||||
if (bucket().currentView !== 'list') refreshView()
|
||||
scrollToBottom()
|
||||
return el
|
||||
}
|
||||
@@ -142,11 +171,12 @@
|
||||
// First artifact of the session: build the panel + tab bar and
|
||||
// append it to the stream. The panel owns a `.artifact-group` in
|
||||
// its body which the List view uses as-is.
|
||||
if (!panelEl || !panelEl.isConnected) {
|
||||
panelEl = buildPanel()
|
||||
stream.appendChild(panelEl)
|
||||
const b = bucket()
|
||||
if (!b.panelEl || !b.panelEl.isConnected) {
|
||||
b.panelEl = buildPanel()
|
||||
stream.appendChild(b.panelEl)
|
||||
}
|
||||
const groupHost = panelEl.querySelector('.artifact-group')
|
||||
const groupHost = b.panelEl.querySelector('.artifact-group')
|
||||
groupHost.appendChild(el)
|
||||
}
|
||||
|
||||
@@ -184,19 +214,21 @@
|
||||
}
|
||||
|
||||
function switchView(v) {
|
||||
if (v === currentView) return
|
||||
currentView = v
|
||||
if (!panelEl) return
|
||||
panelEl.dataset.view = v
|
||||
for (const tab of panelEl.querySelectorAll('.artifact-panel-tab')) {
|
||||
const b = bucket()
|
||||
if (v === b.currentView) return
|
||||
b.currentView = v
|
||||
if (!b.panelEl) return
|
||||
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')
|
||||
}
|
||||
refreshView()
|
||||
}
|
||||
|
||||
function refreshView() {
|
||||
if (!panelEl) return
|
||||
const body = panelEl.querySelector('.artifact-panel-body')
|
||||
const b = bucket()
|
||||
if (!b.panelEl) return
|
||||
const body = b.panelEl.querySelector('.artifact-panel-body')
|
||||
if (!body) return
|
||||
// The List view is stable DOM (the auto-grouped card rows). Board
|
||||
// and Timeline are re-rendered from state on every switch — cheap,
|
||||
@@ -205,7 +237,7 @@
|
||||
const listGroup = body.querySelector('.artifact-group')
|
||||
const stale = body.querySelectorAll('.artifact-board, .artifact-timeline')
|
||||
for (const s of stale) s.remove()
|
||||
if (currentView === 'list') {
|
||||
if (b.currentView === 'list') {
|
||||
if (listGroup) listGroup.hidden = false
|
||||
return
|
||||
}
|
||||
@@ -214,11 +246,16 @@
|
||||
const entries = collectLatestEntries()
|
||||
const board = window.__dshArtifactsBoard
|
||||
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.renderTimeline(entries, {
|
||||
openArtifact: (id) => window.dsh && window.dsh.openArtifact(id),
|
||||
history,
|
||||
history: historyMap,
|
||||
})
|
||||
body.appendChild(view)
|
||||
}
|
||||
@@ -227,7 +264,7 @@
|
||||
// Board tiles show. Timeline reads full history separately.
|
||||
function collectLatestEntries() {
|
||||
const out = []
|
||||
for (const [id, arr] of history) {
|
||||
for (const [id, arr] of history.entries()) {
|
||||
if (!arr || arr.length === 0) continue
|
||||
const latest = arr.reduce((a, b) => (a.version >= b.version ? a : b))
|
||||
out.push({
|
||||
@@ -421,11 +458,6 @@
|
||||
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.
|
||||
function bindMockButton() {
|
||||
const btn = document.getElementById('mock-artifact')
|
||||
@@ -454,14 +486,35 @@
|
||||
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
|
||||
// reuse. `history` and `switchView` join the surface so fixture
|
||||
// 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 = {
|
||||
onArtifactEvent,
|
||||
cards,
|
||||
history,
|
||||
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 data = evt.data || {}
|
||||
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)
|
||||
rows.push({
|
||||
kind: 'user',
|
||||
@@ -40,6 +49,12 @@ function deriveTurnRows(events) {
|
||||
turnId: null,
|
||||
})
|
||||
} 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 = {
|
||||
kind: 'turn',
|
||||
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="./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-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="./capabilities.js"></script>
|
||||
|
||||
@@ -533,6 +533,12 @@ function updateEmptyStateVisibility() {
|
||||
|
||||
async function selectSession(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)
|
||||
// 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
|
||||
@@ -4296,6 +4302,21 @@ function 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) {
|
||||
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
|
||||
// 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 /
|
||||
// switched to Graph yet.
|
||||
refreshChatSideDrawerIfOpen()
|
||||
refreshSessionGraphIfActive()
|
||||
// switched to Graph yet. Coalesced via rAF so long sessions don't take
|
||||
// an O(N²) hit from the O(N) derives.
|
||||
refreshChatSurfacesCoalesced()
|
||||
|
||||
// §2.3 (batch 6) template triggers: pure module decides whether the event
|
||||
// qualifies for a template card (T2 error recovery / T4 artifact preview /
|
||||
|
||||
@@ -60,6 +60,12 @@ function createStore() {
|
||||
events: [], // scoreEvent[]
|
||||
similarClasses: [], // similarSessionsClass[]
|
||||
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() {
|
||||
@@ -134,6 +140,17 @@ function createStore() {
|
||||
const spec = rubric.dims.find(d => d.id === raw.dimId)
|
||||
if (!spec) return null
|
||||
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 = {
|
||||
ts,
|
||||
rubricId: rubric.id,
|
||||
@@ -154,11 +171,26 @@ function createStore() {
|
||||
|
||||
// Bulk load from a fixture JSON blob. Shape:
|
||||
// { 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) {
|
||||
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
|
||||
for (const r of json.rubrics || []) { if (registerRubric(r)) rn++ }
|
||||
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() : []
|
||||
notify()
|
||||
return { rubrics: rn, events: en }
|
||||
@@ -168,6 +200,11 @@ function createStore() {
|
||||
state.rubrics.clear()
|
||||
state.events.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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
artifactsSrc,
|
||||
/const\s+history\s*=\s*new\s+Map\(\)/,
|
||||
'history map must exist to seed the evolution / timeline views',
|
||||
/const\s+bySession\s*=\s*new\s+Map\(\)/,
|
||||
'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(
|
||||
artifactsSrc,
|
||||
@@ -296,7 +304,7 @@ test('artifacts.js: switchView flips the panel dataset + aria-selected', () => {
|
||||
assert.match(
|
||||
artifactsSrc,
|
||||
/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(
|
||||
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