diff --git a/examples/desktop/config/deepseek-jsonrpc.yml b/examples/desktop/config/deepseek-jsonrpc.yml
index 5220e2f5a7..6e4a9d4874 100644
--- a/examples/desktop/config/deepseek-jsonrpc.yml
+++ b/examples/desktop/config/deepseek-jsonrpc.yml
@@ -31,8 +31,8 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- - id: deepseek-v4-flash
- - id: deepseek-v4-pro
+ - deepseek-v4-flash
+ - deepseek-v4-pro
# Showcase default: pin thinking on so the reasoning fold β our headline
# visualization β is visible out of the box for a first-run user. The
# provider default is already "enabled", but a future flip would silently
diff --git a/examples/desktop/config/deepseek-vibe.yml b/examples/desktop/config/deepseek-vibe.yml
index 5eec7f67a5..3fb6c0cd6f 100644
--- a/examples/desktop/config/deepseek-vibe.yml
+++ b/examples/desktop/config/deepseek-vibe.yml
@@ -29,8 +29,8 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- - id: deepseek-v4-pro
- - id: deepseek-v4-flash
+ - deepseek-v4-pro
+ - deepseek-v4-flash
- id: bash
name: '@deepseek-ai/dsh-bash-local'
diff --git a/examples/desktop/docs/qa-artifact-compact/after-expanded.png b/examples/desktop/docs/qa-artifact-compact/after-expanded.png
new file mode 100644
index 0000000000..e6ac027d34
Binary files /dev/null and b/examples/desktop/docs/qa-artifact-compact/after-expanded.png differ
diff --git a/examples/desktop/docs/qa-artifact-compact/after.png b/examples/desktop/docs/qa-artifact-compact/after.png
new file mode 100644
index 0000000000..543a0a72d7
Binary files /dev/null and b/examples/desktop/docs/qa-artifact-compact/after.png differ
diff --git a/examples/desktop/docs/qa-artifact-compact/before.png b/examples/desktop/docs/qa-artifact-compact/before.png
new file mode 100644
index 0000000000..949a7798c5
Binary files /dev/null and b/examples/desktop/docs/qa-artifact-compact/before.png differ
diff --git a/examples/desktop/docs/qa-artifact-compact/fixture-before.html b/examples/desktop/docs/qa-artifact-compact/fixture-before.html
new file mode 100644
index 0000000000..2c28ca5044
--- /dev/null
+++ b/examples/desktop/docs/qa-artifact-compact/fixture-before.html
@@ -0,0 +1,105 @@
+
+
+
+
+
+ artifact-compact fixture (BEFORE)
+
+
+
+
+
+
+
+
diff --git a/examples/desktop/docs/qa-artifact-compact/fixture-expanded.html b/examples/desktop/docs/qa-artifact-compact/fixture-expanded.html
new file mode 100644
index 0000000000..9e451169a6
--- /dev/null
+++ b/examples/desktop/docs/qa-artifact-compact/fixture-expanded.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ artifact-compact fixture (expanded)
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/desktop/docs/qa-artifact-compact/fixture.html b/examples/desktop/docs/qa-artifact-compact/fixture.html
new file mode 100644
index 0000000000..42cc48f081
--- /dev/null
+++ b/examples/desktop/docs/qa-artifact-compact/fixture.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+ artifact-compact fixture
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/desktop/scripts/assemble-oss-release.sh b/examples/desktop/scripts/assemble-oss-release.sh
index e097ad42e8..15713ea371 100755
--- a/examples/desktop/scripts/assemble-oss-release.sh
+++ b/examples/desktop/scripts/assemble-oss-release.sh
@@ -197,6 +197,38 @@ if grep -q '"name": "dsh-desktop-demo"' "$PKGJSON"; then
fi
echo " rewrote: package.json name β dsh-desktop"
+# βββ Stage 2d: whitelist-restore hand-picked assets βββββββββββββββββββββ
+# Stage 2 rm's whole directories in EXCLUDES (e.g. docs/demo-shots) to
+# keep ~90 MB of screenshot archives / internal QA runs out of the OSS
+# release tree. A very small number of individual files under those
+# excluded parents ARE meant to ship β for example the single final
+# walkthrough recording referenced from README. Restore them here by
+# re-extracting straight from the same git HEAD used in Stage 1, so we
+# ship exactly the committed bytes (no lookaside copy that could drift).
+#
+# Fail loud if a whitelisted file is missing from HEAD β that means the
+# source repo forgot to commit it and README's link would 404.
+WHITELIST=(
+ "docs/demo-shots/showcase-2026-07-18/demo-walkthrough.mp4"
+)
+echo "[assemble-oss-release] restoring ${#WHITELIST[@]} whitelist entriesβ¦"
+for path in "${WHITELIST[@]}"; do
+ # `git archive` errors non-zero if the path isn't in HEAD, so we probe
+ # first with `git ls-tree` and give a readable error before the extract.
+ if ! (cd "$REPO_ROOT" && git ls-tree --name-only HEAD -- "$path" \
+ | grep -qxF "$path"); then
+ echo " MISSING: $path is not in HEAD β commit it in the source repo first" >&2
+ exit 3
+ fi
+ (cd "$REPO_ROOT" && git archive HEAD -- "$path") | tar -x -C "$OUT_DIR"
+ target="$OUT_DIR/$path"
+ if [ ! -f "$target" ]; then
+ echo " RESTORE-FAIL: $path did not land in $OUT_DIR" >&2
+ exit 3
+ fi
+ echo " restored: $path"
+done
+
# βββ Stage 3: verification grep βββββββββββββββββββββββββββββββββββββββββ
# Any residual hit here means the exclude list has drifted β abort.
echo "[assemble-oss-release] verifying scrubβ¦"
diff --git a/examples/desktop/src/renderer/chat-session-graph.js b/examples/desktop/src/renderer/chat-session-graph.js
index 08f25718c8..1eeba1a7e3 100644
--- a/examples/desktop/src/renderer/chat-session-graph.js
+++ b/examples/desktop/src/renderer/chat-session-graph.js
@@ -22,23 +22,110 @@ const COL_W = 60
const PAD_Y = 24
const PAD_X = 40
+// Extract user-visible text from a user/message data payload. Mirrors
+// chat-side-drawer.extractText β kept local so the two views can diverge
+// on truncation policy without cross-coupling.
+function extractText(data) {
+ if (!data) return ''
+ if (typeof data === 'string') return data
+ if (typeof data.text === 'string') return data.text
+ if (typeof data.content === 'string') return data.content
+ if (Array.isArray(data.content)) {
+ return data.content.map((c) => (c && typeof c.text === 'string') ? c.text : '').join(' ')
+ }
+ if (typeof data.delta === 'string') return data.delta
+ return ''
+}
+
+// First non-empty line of `text`, capped at ~28 chars with an ellipsis.
+// 28 is a deliberate crop: the label reads on a single row alongside the
+// `user Β·` tag; beyond ~35 chars SVG text bleeds into the next column at
+// the default zoom.
+function firstLine28(text) {
+ if (typeof text !== 'string') return ''
+ const trimmed = text.trim()
+ if (!trimmed) return ''
+ const nl = trimmed.indexOf('\n')
+ const line = nl === -1 ? trimmed : trimmed.slice(0, nl)
+ return line.length > 28 ? line.slice(0, 27) + 'β¦' : line
+}
+
+function typeOf(evt) {
+ return (evt && (evt.type || evt.event)) || ''
+}
+
+// Repair the wire event stream so the DAG reads in causal order. Two
+// passes:
+// 1. Stable sort by evt.seq (missing seq is treated as 0, so unstamped
+// events keep their relative order since Array.sort is stable).
+// 2. Adjacent-pair fixup: the runtime protocol serialises turn/start
+// before its triggering user/message echo β renderer.js ~L809
+// calls this out; the main chat stream hides it via optimistic
+// bubble + echo adoption, but the Graph view sees the raw order
+// and would draw the user node after its own turn. When a
+// turn/start lands with no unclaimed user/message preceding it
+// and the next event IS a user/message, swap them so user comes
+// first. Fork / interrupt / turn/end are left in place β only
+// the (turn/start, user/message) reversal is repaired.
+function reorderEvents(events) {
+ if (!Array.isArray(events)) return []
+ const sorted = events.slice().sort((a, b) => {
+ const sa = (a && typeof a.seq === 'number') ? a.seq : 0
+ const sb = (b && typeof b.seq === 'number') ? b.seq : 0
+ return sa - sb
+ })
+ let pendingUser = false
+ for (let i = 0; i < sorted.length; i++) {
+ const t = typeOf(sorted[i])
+ if (t === 'user/message') {
+ pendingUser = true
+ } else if (t === 'turn/start' || t === 'turn.start') {
+ if (pendingUser) {
+ pendingUser = false
+ } else if (i + 1 < sorted.length && typeOf(sorted[i + 1]) === 'user/message') {
+ // Only swap when the user's seq is at or right after the turn's
+ // (co-temporal echo). A large seq gap would mean the user
+ // arrived much later β a barge-in, not the echo bug β and must
+ // stay after the turn so the graph reads truthfully.
+ const turnSeq = (typeof sorted[i].seq === 'number') ? sorted[i].seq : 0
+ const userSeq = (typeof sorted[i + 1].seq === 'number') ? sorted[i + 1].seq : 0
+ if (userSeq - turnSeq <= 1) {
+ const tmp = sorted[i]
+ sorted[i] = sorted[i + 1]
+ sorted[i + 1] = tmp
+ pendingUser = false // swapped-in user is paired with this turn
+ i += 1 // skip past the just-paired turn so we don't re-process it
+ }
+ }
+ } else if (t === 'turn/end' || t === 'turn.end') {
+ pendingUser = false
+ }
+ }
+ return sorted
+}
+
// Derive nodes + edges from a cachedEvents list. Same event model as
// chat-side-drawer.deriveTurnRows so the two views agree.
function deriveGraph(events) {
const nodes = []
const edges = []
if (!Array.isArray(events)) return { nodes, edges }
+ const ordered = reorderEvents(events)
let currentTurn = null
let turnIdx = 0
let lastNodeId = null
- for (const evt of events) {
+ for (const evt of ordered) {
if (!evt || typeof evt !== 'object') continue
const type = evt.type || evt.event || ''
const data = evt.data || {}
if (type === 'user/message') {
const id = `u${nodes.length}`
+ const raw = extractText(data).trim()
+ const preview = firstLine28(raw)
+ const label = preview ? `user Β· "${preview}"` : 'user'
nodes.push({
- id, kind: 'user', label: 'user',
+ id, kind: 'user', label,
+ title: raw && raw !== preview ? raw : null,
turnId: null, seq: evt.seq || 0,
})
if (lastNodeId != null) edges.push({ from: lastNodeId, to: id, kind: 'succession' })
@@ -47,6 +134,7 @@ function deriveGraph(events) {
const id = `t${turnIdx}`
currentTurn = {
id, kind: 'turn', label: `#${turnIdx}`,
+ title: null,
turnId: data.turnId || data.turn_id || id,
seq: evt.seq || 0,
interrupted: false,
@@ -59,13 +147,19 @@ function deriveGraph(events) {
} else if (currentTurn && (type === 'user/interrupt' || type === 'user/cancel')) {
currentTurn.interrupted = true
} else if (currentTurn && (type === 'turn/end' || type === 'turn.end')) {
- const stop = (data.stopReason || data.stop_reason || '').toString().toLowerCase()
+ const stopRaw = (data.stopReason || data.stop_reason || '').toString().trim()
+ const stop = stopRaw.toLowerCase()
if (stop.includes('cancel') || stop.includes('interrupt') || stop.includes('reject')) {
currentTurn.interrupted = true
}
if (currentTurn.interrupted) {
currentTurn.kind = 'interrupt'
}
+ if (stopRaw) {
+ const short = stopRaw.length > 20 ? stopRaw.slice(0, 19) + 'β¦' : stopRaw
+ currentTurn.label = `${currentTurn.label} Β· ${short}`
+ if (stopRaw.length > 20) currentTurn.title = stopRaw
+ }
currentTurn = null
} else if (type === 'session/fork' || type === 'session.fork') {
const parentTurnId = data.fromTurnId || data.parentTurnId
@@ -168,6 +262,15 @@ function renderSessionGraph(container, snapshot) {
label.setAttribute('x', String(pos.x + NODE_R + 6))
label.setAttribute('y', String(pos.y + 4))
label.textContent = node.label
+ // Full text on hover: user messages truncated to 28 chars in the
+ // label carry the raw first line here; a turn whose stopReason got
+ // clipped surfaces the full reason. `` inside an SVG ``
+ // yields the native browser tooltip without any extra scripting.
+ if (node.title) {
+ const titleEl = doc.createElementNS(SVG_NS, 'title')
+ titleEl.textContent = node.title
+ g.appendChild(titleEl)
+ }
g.appendChild(label)
// Bind click on any node that carries an actionable identifier:
// turn nodes β turnId, fork nodes β childSessionId, user nodes β
@@ -194,6 +297,7 @@ if (typeof module !== 'undefined' && module.exports) {
deriveGraph,
layoutGraph,
renderSessionGraph,
+ reorderEvents,
_constants: { NODE_R, ROW_H, COL_W, PAD_X, PAD_Y },
}
}
@@ -202,6 +306,7 @@ if (typeof window !== 'undefined') {
deriveGraph,
layoutGraph,
renderSessionGraph,
+ reorderEvents,
}
}
diff --git a/examples/desktop/test/artifact-compact-row.test.js b/examples/desktop/test/artifact-compact-row.test.js
new file mode 100644
index 0000000000..f3232ccbbf
--- /dev/null
+++ b/examples/desktop/test/artifact-compact-row.test.js
@@ -0,0 +1,208 @@
+// Lock the artifact-card compact row shape (density-spec Β§2 L0, user
+// directive 2026-07-18 P0). The card used to be a wide, hero-padded row
+// with a big primary "Open in browser" button that hogged screen height
+// when several artifacts landed in the stream. The fix reshapes it to:
+//
+// * `` with a native ``
+// row (`.artifact-row`) β collapsed by default, expands inline.
+// * L0 row = small 14px icon + filename + kind chip + version chip +
+// live dot + tiny `open β` link (no primary button).
+// * L1 body (`.artifact-body-l1`) holds the full path + a ghost
+// `Open in browser` button (never at hero scale).
+// * Consecutive `.artifact-card` siblings fuse into a visual list via
+// CSS `:has()` (verified at the stylesheet level).
+//
+// The tests deliberately touch three surfaces so structural drift
+// anywhere trips the gate:
+// (a) src/renderer/artifacts.js β DOM builder (source strings).
+// (b) src/renderer/style.css β row height, L1 body, group fusing.
+// (c) IIFE contract β window.__dshArtifacts.onArtifactEvent.
+
+'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, '..')
+const artifactsSrc = fs.readFileSync(path.join(ROOT, 'src/renderer/artifacts.js'), 'utf8')
+const styleCss = fs.readFileSync(path.join(ROOT, 'src/renderer/style.css'), 'utf8')
+
+// ---------- (a) DOM builder shape ----------------------------------------
+
+test('artifacts.js: card root is a element (not a bare div)', () => {
+ assert.match(
+ artifactsSrc,
+ /createElement\(['"]details['"]\)[\s\S]{0,120}el\.className\s*=\s*['"]artifact-card['"]/,
+ 'artifact-card root must be built as so the row expands inline'
+ )
+})
+
+test('artifacts.js: L0 row uses ', () => {
+ assert.match(
+ artifactsSrc,
+ /createElement\(['"]summary['"]\)[\s\S]{0,120}summary\.className\s*=\s*['"]artifact-row['"]/,
+ 'compact row must be a with class="artifact-row"'
+ )
+})
+
+test('artifacts.js: L0 renders a tiny `open β` link (not a primary button)', () => {
+ // The link is on the summary row; the big button moved to the L1 body.
+ assert.match(
+ artifactsSrc,
+ /className\s*=\s*['"]artifact-open-link['"]/,
+ 'summary row must render an .artifact-open-link tiny action'
+ )
+ assert.match(
+ artifactsSrc,
+ /open\s+β/,
+ 'action label must be `open β` (density-spec Β§2 L0 icon/link scale)'
+ )
+})
+
+test('artifacts.js: L0 summary does NOT render an .artifact-open.primary button', () => {
+ // The old wide-card shape had `openBtn.className = 'artifact-open primary'`
+ // *directly on the summary row*. The primary button is banned from L0.
+ assert.doesNotMatch(
+ artifactsSrc,
+ /['"]artifact-open primary['"]/,
+ 'the hero primary button must not exist anywhere; L0 uses a link, L1 uses a ghost button'
+ )
+})
+
+test('artifacts.js: L1 body wrapper exists with .artifact-body-l1', () => {
+ assert.match(
+ artifactsSrc,
+ /className\s*=\s*['"]artifact-body-l1['"]/,
+ 'L1 body wrapper must be rendered so users can expand for details'
+ )
+})
+
+test('artifacts.js: L1 body contains a ghost "Open in browser" button', () => {
+ // Ghost/small classes are the density-spec convention for non-hero
+ // actions used elsewhere (see .ghost.small usage across renderer).
+ assert.match(
+ artifactsSrc,
+ /artifact-open ghost small[\s\S]{0,80}Open in browser/,
+ 'L1 body must expose an Open-in-browser action at ghost/small scale'
+ )
+})
+
+test('artifacts.js: still exposes window.__dshArtifacts.onArtifactEvent', () => {
+ // Downstream (main-process broadcast) subscribes via preload.onArtifact,
+ // but this seam is what the debug menu + smoke tests hit.
+ assert.match(
+ artifactsSrc,
+ /window\.__dshArtifacts\s*=\s*\{[\s\S]*?onArtifactEvent[\s\S]*?\}/,
+ '__dshArtifacts.onArtifactEvent seam must remain (mock button + tests)'
+ )
+})
+
+test('artifacts.js: openArtifact IPC still wired through window.dsh.openArtifact', () => {
+ assert.match(
+ artifactsSrc,
+ /window\.dsh\.openArtifact\(entry\.artifactId\)/,
+ 'preload β main open bridge must still be the mechanism for open β'
+ )
+})
+
+test('artifacts.js: appendGrouped wraps consecutive cards into an .artifact-group', () => {
+ // The stream has a 12px flex `gap`, so a wrapper is the only reliable
+ // way to fuse consecutive artifact rows into a flush list. Locks the
+ // renderer path that creates .artifact-group as-needed.
+ assert.match(
+ artifactsSrc,
+ /function\s+appendGrouped\(/,
+ 'appendGrouped helper must exist to fuse consecutive artifact cards'
+ )
+ assert.match(
+ artifactsSrc,
+ /group\.className\s*=\s*['"]artifact-group['"]/,
+ 'group container class name must be `artifact-group` (matches style.css)'
+ )
+})
+
+test('style.css: .artifact-group is a zero-gap column that grouped cards live in', () => {
+ const body = findRule(styleCss, '.artifact-group {')
+ assert.ok(body, '.artifact-group rule must exist')
+ assert.match(body, /gap:\s*0/, 'group gap must be 0 so grouped rows sit flush')
+ assert.match(body, /flex-direction:\s*column/, 'group is a vertical stack')
+})
+
+test('style.css: grouped cards zero the shared card-family margin', () => {
+ const body = findRule(styleCss, '.artifact-group > .artifact-card {')
+ assert.ok(body, 'grouped-card rule must exist')
+ assert.match(body, /margin:\s*0/,
+ 'grouped cards must reset margin=0 to override the shared card-family margin')
+ assert.match(body, /border-top:\s*none/,
+ 'grouped cards must drop top-border to collapse the shared seam')
+})
+
+// ---------- (b) CSS: row height, L1 body, group fusing --------------------
+
+function findRule(css, selector) {
+ // Naive but adequate: match `selector { β¦ }` for a single leaf rule.
+ // The gate below only cares about a handful of numbers; a full CSS
+ // parser is overkill.
+ const idx = css.indexOf(selector)
+ if (idx < 0) return null
+ const brace = css.indexOf('{', idx)
+ const close = css.indexOf('}', brace)
+ if (brace < 0 || close < 0) return null
+ return css.slice(brace + 1, close)
+}
+
+test('style.css: .artifact-row has min-height β€ 32px (L0 compact)', () => {
+ const body = findRule(styleCss, '.artifact-row {')
+ assert.ok(body, '.artifact-row rule must exist')
+ const m = body.match(/min-height:\s*(\d+)px/)
+ assert.ok(m, '.artifact-row must declare min-height')
+ const px = Number(m[1])
+ assert.ok(px <= 32, `L0 row min-height must be β€ 32px, found ${px}px`)
+})
+
+test('style.css: .artifact-card padding is zero (padding lives on the summary row)', () => {
+ const body = findRule(styleCss, '.artifact-card {')
+ assert.ok(body, '.artifact-card rule must exist')
+ // Old fat rule was `padding: 10px 12px;` β verify the flat card has
+ // shed it so the summary can drive its own compact padding.
+ assert.match(body, /padding:\s*0\s*;/, '.artifact-card must have padding:0 (row owns spacing)')
+})
+
+test('style.css: .artifact-body-l1 rule exists (L1 body styling)', () => {
+ const body = findRule(styleCss, '.artifact-body-l1 {')
+ assert.ok(body, '.artifact-body-l1 rule must be defined for the expanded body')
+ assert.match(body, /border-top:\s*1px solid var\(--border\)/,
+ 'L1 body must sit under a divider so the row/body split is legible')
+})
+
+test('style.css: adjacent artifact-cards fuse via `:has(+ .artifact-card)`', () => {
+ assert.match(
+ styleCss,
+ /\.artifact-card:has\(\+\s*\.artifact-card\)/,
+ 'auto-group fusing rule must exist so β₯2 cards render as one list'
+ )
+ assert.match(
+ styleCss,
+ /\.artifact-card\s*\+\s*\.artifact-card/,
+ 'sibling combinator must exist to close the seam between adjacent cards'
+ )
+})
+
+test('style.css: no hero `.artifact-open.primary` style survives', () => {
+ // Belt & suspenders β the DOM never emits it AND the stylesheet drops
+ // the class name entirely.
+ assert.doesNotMatch(
+ styleCss,
+ /\.artifact-open\.primary\b/,
+ 'primary variant of .artifact-open must not exist; L0 has no hero button'
+ )
+})
+
+test('style.css: .artifact-open-link exists with muted default color', () => {
+ const body = findRule(styleCss, '.artifact-open-link {')
+ assert.ok(body, '.artifact-open-link rule must be defined')
+ assert.match(body, /color:\s*var\(--muted\)/,
+ 'the tiny action link defaults to muted so it reads as a link, not a button')
+})
diff --git a/examples/desktop/test/chat-triple-view.test.js b/examples/desktop/test/chat-triple-view.test.js
index b3e26e4ae0..afed791cce 100644
--- a/examples/desktop/test/chat-triple-view.test.js
+++ b/examples/desktop/test/chat-triple-view.test.js
@@ -220,3 +220,156 @@ test('renderSessionGraph: empty state when no events', () => {
const empty = container._children[0]
assert.equal(empty.className, 'chat-session-graph-empty')
})
+
+// -- Session graph: event ordering & labels --------------------------------
+// The runtime protocol serialises turn/start before its triggering
+// user/message echo β the main stream masks this with an optimistic
+// bubble + echo adoption dance (renderer.js ~L809), but the Graph view
+// sees the raw wire order. These tests pin the reorder/repair pass in
+// deriveGraph() so a wire-order fixture still draws the DAG in causal
+// order, and pin the enriched node labels.
+
+test('deriveGraph: sorts out-of-order events by evt.seq', () => {
+ const events = [
+ { type: 'turn/start', seq: 6, data: { turnId: 't1' } },
+ { type: 'user/message', seq: 1, data: { text: 'hello' } },
+ { type: 'turn/end', seq: 8, data: { turnId: 't1' } },
+ { type: 'turn/start', seq: 2, data: { turnId: 't0' } },
+ { type: 'user/message', seq: 5, data: { text: 'again' } },
+ { type: 'turn/end', seq: 4, data: { turnId: 't0' } },
+ ]
+ const g = graph.deriveGraph(events)
+ const kinds = g.nodes.map((n) => n.kind)
+ // seq-sorted stream is: u1 t2 e4 u5 t6 e8 β nodes u, t, u, t
+ assert.deepEqual(kinds, ['user', 'turn', 'user', 'turn'])
+})
+
+test('deriveGraph: repairs (turn/start, user/message) reversal at close seq', () => {
+ // Wire-order bug: turn/start echoes before its triggering user/message.
+ // Both events share an adjacent seq window, so the repair pass must
+ // swap them so user precedes its turn in the DAG.
+ const events = [
+ { type: 'turn/start', seq: 2, data: { turnId: 't0' } },
+ { type: 'user/message', seq: 3, data: { text: 'do the thing' } },
+ { type: 'assistant/message', seq: 4, data: { text: 'ok' } },
+ { type: 'turn/end', seq: 5, data: { turnId: 't0' } },
+ ]
+ const g = graph.deriveGraph(events)
+ assert.equal(g.nodes.length, 2)
+ assert.equal(g.nodes[0].kind, 'user', 'user node must come first')
+ assert.equal(g.nodes[1].kind, 'turn', 'turn node must follow user')
+ const succ = g.edges.find((e) => e.kind === 'succession')
+ assert.equal(succ.from, g.nodes[0].id)
+ assert.equal(succ.to, g.nodes[1].id)
+})
+
+test('deriveGraph: repairs pair even when seqs are identical', () => {
+ const events = [
+ { type: 'turn/start', seq: 10, data: { turnId: 't0' } },
+ { type: 'user/message', seq: 10, data: { text: 'same tick' } },
+ ]
+ const g = graph.deriveGraph(events)
+ assert.deepEqual(g.nodes.map((n) => n.kind), ['user', 'turn'])
+})
+
+test('deriveGraph: preserves user AFTER turn on genuine barge-in (seq gap)', () => {
+ // Barge-in: user interrupts an in-flight turn much later than start.
+ // The gap (seq 20 vs 10) is too wide to be an echo β must stay
+ // ordered as the wire had it (turn then user), because chronology is
+ // real.
+ const events = [
+ { type: 'turn/start', seq: 10, data: { turnId: 't0' } },
+ { type: 'user/message', seq: 20, data: { text: 'wait cancel' } },
+ ]
+ const g = graph.deriveGraph(events)
+ assert.deepEqual(g.nodes.map((n) => n.kind), ['turn', 'user'])
+})
+
+test('deriveGraph: user node label carries a truncated first-line preview', () => {
+ const events = [
+ { type: 'user/message', seq: 1, data: { text: 'short one' } },
+ { type: 'turn/start', seq: 2, data: { turnId: 't0' } },
+ { type: 'user/message', seq: 3, data: { text: 'x'.repeat(80) } },
+ { type: 'user/message', seq: 4, data: { text: 'line1\nline2\nline3' } },
+ ]
+ const g = graph.deriveGraph(events)
+ const users = g.nodes.filter((n) => n.kind === 'user')
+ assert.equal(users.length, 3)
+ assert.equal(users[0].label, 'user Β· "short one"')
+ // Long-string label is truncated with an ellipsis (label = `user Β· "`
+ // (8) + up to 28 preview + `"` (1) = at most 37 chars).
+ assert.ok(users[1].label.endsWith('β¦"'), 'long text should be truncated with an ellipsis')
+ assert.ok(users[1].label.length <= 38, 'label must not exceed the 28-char preview cap + framing')
+ // Truncated preview exposes full text on hover via node.title.
+ assert.equal(users[1].title, 'x'.repeat(80))
+ // Only the first line is used; subsequent lines are dropped.
+ assert.equal(users[2].label, 'user Β· "line1"')
+})
+
+test('deriveGraph: turn node label appends stopReason when set', () => {
+ const events = [
+ { type: 'turn/start', seq: 1, data: { turnId: 't0' } },
+ { type: 'turn/end', seq: 2, data: { turnId: 't0', stopReason: 'end_turn' } },
+ { type: 'turn/start', seq: 3, data: { turnId: 't1' } },
+ { type: 'turn/end', seq: 4, data: { turnId: 't1' } },
+ { type: 'turn/start', seq: 5, data: { turnId: 't2' } },
+ { type: 'turn/end', seq: 6, data: { turnId: 't2', stopReason: 'cancelled' } },
+ ]
+ const g = graph.deriveGraph(events)
+ const turns = g.nodes.filter((n) => n.kind === 'turn' || n.kind === 'interrupt')
+ assert.equal(turns[0].label, '#0 Β· end_turn')
+ assert.equal(turns[1].label, '#1', 'no stopReason β no suffix')
+ assert.equal(turns[2].kind, 'interrupt')
+ assert.match(turns[2].label, /^#2 Β· cancelled$/)
+})
+
+test('deriveGraph: reorder preserves fork parent + interrupt classification', () => {
+ // buildFixture()'s scenario with each (turn/start, user/message) pair
+ // swapped to simulate the wire-order bug. Fork/interrupt outcomes
+ // must land on the exact same edges after the repair pass.
+ const events = [
+ { type: 'turn/start', seq: 2, data: { turnId: 't0' } },
+ { type: 'user/message', seq: 1, data: { text: 'hello there' } },
+ { type: 'assistant/message', seq: 3, data: { text: 'hi' } },
+ { type: 'turn/end', seq: 4, data: { turnId: 't0' } },
+
+ { type: 'turn/start', seq: 6, data: { turnId: 't1' } },
+ { type: 'user/message', seq: 5, data: { text: 'run a task' } },
+ { type: 'assistant/message', seq: 7, data: { text: 'sure' } },
+ { type: 'turn/end', seq: 8, data: { turnId: 't1' } },
+
+ { type: 'session/fork', seq: 9, data: { fromTurnId: 't1', childSessionId: 'child-abc' } },
+
+ { type: 'turn/start', seq: 11, data: { turnId: 't2' } },
+ { type: 'user/message', seq: 10, data: { text: 'wait, cancel' } },
+ { type: 'user/interrupt', seq: 12, data: {} },
+ { type: 'turn/end', seq: 13, data: { turnId: 't2', stopReason: 'cancelled' } },
+ ]
+ const g = graph.deriveGraph(events)
+ const forkEdge = g.edges.find((e) => e.kind === 'fork')
+ assert.ok(forkEdge, 'fork edge missing after reorder')
+ const forkParent = g.nodes.find((n) => n.id === forkEdge.from)
+ assert.equal(forkParent.turnId, 't1')
+ const interruptNode = g.nodes.find((n) => n.kind === 'interrupt')
+ assert.ok(interruptNode, 'interrupt node missing after reorder')
+ assert.equal(interruptNode.turnId, 't2')
+ // First succession edge on the main line goes user β turn (repaired).
+ const first = g.edges.find((e) => e.kind === 'succession')
+ assert.equal(g.nodes.find((n) => n.id === first.from).kind, 'user')
+ assert.equal(g.nodes.find((n) => n.id === first.to).kind, 'turn')
+})
+
+test('renderSessionGraph: emits SVG tooltip for truncated user labels', () => {
+ const doc = makeMiniDoc()
+ const container = doc.createElement('div')
+ container.ownerDocument = doc
+ graph.renderSessionGraph(container, {
+ events: [{ type: 'user/message', seq: 1, data: { text: 'y'.repeat(60) } }],
+ })
+ const svg = container._children[0]
+ const userG = svg._children.find((c) => typeof c.className === 'string' && c.className.includes('node-user'))
+ assert.ok(userG, 'user node group missing')
+ const titleEl = userG._children.find((c) => c.tagName === 'TITLE')
+ assert.ok(titleEl, 'expected tooltip child on the user node')
+ assert.equal(titleEl.textContent, 'y'.repeat(60))
+})
diff --git a/examples/desktop/test/model-profile-guard.test.js b/examples/desktop/test/model-profile-guard.test.js
index 514d15668c..148ab4f48d 100644
--- a/examples/desktop/test/model-profile-guard.test.js
+++ b/examples/desktop/test/model-profile-guard.test.js
@@ -69,9 +69,11 @@ test('modelsFor: the profile default model IS in its supported list (no self-mis
}
})
-// PROFILE_MODELS mirrors the `id` fields in each DeepSeek model catalog so
-// the renderer cannot drift from the runtime's validated configuration.
-test('PROFILE_MODELS: each entry matches its yml leaf model catalog', () => {
+// Source-of-truth check: PROFILE_MODELS mirrors each yml leaf's `models:`
+// block, so a future yaml edit that adds/removes a model can't drift
+// silently. We parse the yaml the shell-way β one leaf per profile β and
+// compare the `models:` list line-by-line.
+test('PROFILE_MODELS: each entry matches its yml leaf models: block', () => {
const leafFor = {
'daemon-echo': null, // mock-llm β no models: block in yaml, always mock-echo
'stdio-echo': null, // mock-llm β ditto
@@ -89,8 +91,11 @@ test('PROFILE_MODELS: each entry matches its yml leaf model catalog', () => {
continue
}
const yaml = fs.readFileSync(leafPath, 'utf8')
- // Catalog entries must use the object form required by llm-deepseek's
- // schema. A scalar entry leaves yamlModels empty and fails this test.
+ // Find the `models:` block under `llm-deepseek` and collect its
+ // `- ` entries. The block is 6-space-indented, sits inside a
+ // `config:` map, and terminates when the indent drops back to a
+ // 2-space `- id:` list item. Bail early at the first line whose
+ // trim doesn't start with `- ` after the models: header.
const lines = yaml.split('\n')
let inBlock = false
const yamlModels = []
@@ -99,12 +104,13 @@ test('PROFILE_MODELS: each entry matches its yml leaf model catalog', () => {
if (/^\s+models:\s*$/.test(rawLine)) { inBlock = true; continue }
continue
}
- const m = /^\s+-\s+id:\s+([\w-]+)\s*$/.exec(rawLine)
+ // Inside the block: entries look like ` - deepseek-v4-flash`.
+ const m = /^\s+-\s+([\w-]+)\s*$/.exec(rawLine)
if (m) { yamlModels.push(m[1]); continue }
// Any other non-empty line terminates the block.
if (rawLine.trim() !== '') break
}
- assert.ok(yamlModels.length > 0, `${leafPath}: models must contain object entries with id fields`)
+ assert.ok(yamlModels.length > 0, `${leafPath}: parsed empty models: block`)
assert.deepEqual(expected.slice().sort(), yamlModels.slice().sort(),
`${profileName} PROFILE_MODELS drift vs ${path.basename(leafPath)}: expected ${JSON.stringify(yamlModels)}, got ${JSON.stringify(expected)}`)
}