fix(desktop): session Graph node order — sort by seq + repair turn/user reversal, node label previews
Also picks up two files the previous sync batch missed (test/artifact-compact-row.test.js, docs/qa-artifact-compact/).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
BIN
examples/desktop/docs/qa-artifact-compact/after-expanded.png
Normal file
BIN
examples/desktop/docs/qa-artifact-compact/after-expanded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
examples/desktop/docs/qa-artifact-compact/after.png
Normal file
BIN
examples/desktop/docs/qa-artifact-compact/after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
examples/desktop/docs/qa-artifact-compact/before.png
Normal file
BIN
examples/desktop/docs/qa-artifact-compact/before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
105
examples/desktop/docs/qa-artifact-compact/fixture-before.html
Normal file
105
examples/desktop/docs/qa-artifact-compact/fixture-before.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- "Before" reproduction for docs/qa-artifact-compact/ — mirrors the
|
||||
pre-2026-07-18 hero-card artifact shape. Inline copy of the old
|
||||
builder + the pre-fix CSS block so the before/after diff is
|
||||
screenshot-comparable without git bisect. Not shipped. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>artifact-compact fixture (BEFORE)</title>
|
||||
<link rel="stylesheet" href="../../src/renderer/style.css">
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||
.app { display: flex; height: 100vh; }
|
||||
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.header {
|
||||
padding: 10px 16px; border-bottom: 1px solid var(--border);
|
||||
font-size: 13px; color: var(--muted);
|
||||
}
|
||||
#stream { flex: 1; }
|
||||
.artifact-live-dot { animation: none !important; }
|
||||
/* --- BEFORE styling (pre-fix): wide flex-row with hero button.
|
||||
This is a verbatim replay of the pre-fix style.css block that the
|
||||
compact-row change replaced. Kept out of the shipped stylesheet
|
||||
— this override only applies inside this fixture. */
|
||||
.artifact-card {
|
||||
align-self: flex-start; max-width: 780px; width: 100%;
|
||||
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
transition: border-color 0.4s, background 0.4s;
|
||||
}
|
||||
.artifact-icon { font-size: 22px; line-height: 1; flex: 0 0 auto; }
|
||||
.artifact-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.artifact-name {
|
||||
font-family: var(--mono); font-size: 12.5px; color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.artifact-meta {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
}
|
||||
.artifact-kind {
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 1px 6px; border-radius: 3px;
|
||||
background: var(--bg-elev-2); color: var(--muted);
|
||||
}
|
||||
.artifact-version { color: var(--accent); font-family: var(--mono); }
|
||||
.artifact-live-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: var(--ok);
|
||||
}
|
||||
.artifact-open {
|
||||
flex: 0 0 auto; font-size: 12px; padding: 6px 12px;
|
||||
background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="main">
|
||||
<div class="header">BEFORE: 8 markdown artifacts (hero-card shape)</div>
|
||||
<div id="stream" class="stream"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const entries = [
|
||||
{ artifactId: 'session.md', kind: 'md', version: 3, path: '/w/.artifacts/session.md' },
|
||||
{ artifactId: 'tools.md', kind: 'md', version: 1, path: '/w/.artifacts/tools.md' },
|
||||
{ artifactId: 'AGENTS.md', kind: 'md', version: 2, path: '/w/.artifacts/AGENTS.md' },
|
||||
{ artifactId: 'plan.md', kind: 'md', version: 5, path: '/w/.artifacts/plan.md' },
|
||||
{ artifactId: 'trace.md', kind: 'md', version: 1, path: '/w/.artifacts/trace.md' },
|
||||
{ artifactId: 'growth.md', kind: 'md', version: 7, path: '/w/.artifacts/growth.md' },
|
||||
{ artifactId: 'recap.md', kind: 'md', version: 2, path: '/w/.artifacts/recap.md' },
|
||||
{ artifactId: 'notes.md', kind: 'md', version: 4, path: '/w/.artifacts/notes.md' },
|
||||
]
|
||||
// Pre-fix DOM builder (replayed verbatim).
|
||||
const stream = document.getElementById('stream')
|
||||
for (const entry of entries) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'artifact-card'
|
||||
el.innerHTML = ''
|
||||
const icon = document.createElement('span')
|
||||
icon.className = 'artifact-icon'
|
||||
icon.textContent = '📄'
|
||||
const body = document.createElement('div')
|
||||
body.className = 'artifact-body'
|
||||
const name = document.createElement('div')
|
||||
name.className = 'artifact-name'
|
||||
name.textContent = entry.artifactId
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'artifact-meta'
|
||||
const kind = document.createElement('span'); kind.className = 'artifact-kind'; kind.textContent = entry.kind
|
||||
const ver = document.createElement('span'); ver.className = 'artifact-version'; ver.textContent = `v${entry.version}`
|
||||
const dot = document.createElement('span'); dot.className = 'artifact-live-dot'
|
||||
meta.append(kind, ver, dot)
|
||||
body.append(name, meta)
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'artifact-open primary'
|
||||
btn.textContent = 'Open in browser'
|
||||
el.append(icon, body, btn)
|
||||
stream.appendChild(el)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Same 8-artifact fixture, but the third row is opened so the L1
|
||||
body (path + ghost "Open in browser" button) is visible for the
|
||||
inline-expand selfie. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>artifact-compact fixture (expanded)</title>
|
||||
<link rel="stylesheet" href="../../src/renderer/style.css">
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||
.app { display: flex; height: 100vh; }
|
||||
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.header { padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
||||
#stream { flex: 1; }
|
||||
.artifact-live-dot { animation: none !important; }
|
||||
.artifact-card.artifact-flash { border-color: var(--border) !important; background: var(--bg-elev) !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="main">
|
||||
<div class="header">Fixture: L1 body expanded on row 3</div>
|
||||
<div id="stream" class="stream"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.dsh = { onArtifact: (cb) => { window.__cb = cb }, openArtifact: async () => ({ ok: true }), mockArtifact: async () => {} }
|
||||
</script>
|
||||
<script src="../../src/renderer/artifacts.js"></script>
|
||||
<script>
|
||||
const entries = [
|
||||
{ artifactId: 'session.md', kind: 'md', version: 3, path: '/w/.artifacts/session.md' },
|
||||
{ artifactId: 'tools.md', kind: 'md', version: 1, path: '/w/.artifacts/tools.md' },
|
||||
{ artifactId: 'AGENTS.md', kind: 'md', version: 2, path: '/w/harness/dsh-demo-worktrees/lane-artifact-compact/.artifacts/AGENTS.md' },
|
||||
{ artifactId: 'plan.md', kind: 'md', version: 5, path: '/w/.artifacts/plan.md' },
|
||||
{ artifactId: 'trace.md', kind: 'md', version: 1, path: '/w/.artifacts/trace.md' },
|
||||
{ artifactId: 'growth.md', kind: 'md', version: 7, path: '/w/.artifacts/growth.md' },
|
||||
{ artifactId: 'recap.md', kind: 'md', version: 2, path: '/w/.artifacts/recap.md' },
|
||||
{ artifactId: 'notes.md', kind: 'md', version: 4, path: '/w/.artifacts/notes.md' },
|
||||
]
|
||||
setTimeout(() => {
|
||||
for (const e of entries) window.__cb(e)
|
||||
// Open the third artifact so the L1 body is captured.
|
||||
const c = document.querySelectorAll('.artifact-card')[2]
|
||||
if (c) c.open = true
|
||||
}, 20)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
63
examples/desktop/docs/qa-artifact-compact/fixture.html
Normal file
63
examples/desktop/docs/qa-artifact-compact/fixture.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Reproducible fixture for the artifact-card compact-row change.
|
||||
Mocks 8 md artifact events so the auto-group + L0 row can be shot in
|
||||
one screenshot without booting the whole Electron shell. Used only
|
||||
for docs/qa-artifact-compact/ selfies; not shipped. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>artifact-compact fixture</title>
|
||||
<link rel="stylesheet" href="../../src/renderer/style.css">
|
||||
<style>
|
||||
/* Bare-minimum host chrome: give the .stream a viewport-height body
|
||||
so scroll behaviour matches the real shell. */
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
|
||||
.app { display: flex; height: 100vh; }
|
||||
.main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.header {
|
||||
padding: 10px 16px; border-bottom: 1px solid var(--border);
|
||||
font-size: 13px; color: var(--muted);
|
||||
}
|
||||
#stream { flex: 1; }
|
||||
/* Silence the pulse + the arrival flash for a stable selfie so the
|
||||
real resting-state layout (auto-group fusion, muted colors) shows. */
|
||||
.artifact-live-dot { animation: none !important; }
|
||||
.artifact-card.artifact-flash { border-color: var(--border) !important; background: var(--bg-elev) !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="main">
|
||||
<div class="header">Fixture: 8 markdown artifacts arriving in a row</div>
|
||||
<div id="stream" class="stream"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fake bridge — artifacts.js only touches window.dsh.onArtifact +
|
||||
window.dsh.openArtifact + window.dsh.mockArtifact. -->
|
||||
<script>
|
||||
window.dsh = {
|
||||
onArtifact: (cb) => { window.__cb = cb },
|
||||
openArtifact: async () => ({ ok: true }),
|
||||
mockArtifact: async () => {},
|
||||
}
|
||||
</script>
|
||||
<script src="../../src/renderer/artifacts.js"></script>
|
||||
<script>
|
||||
// Fire 8 md artifact events, then a divergent HTML one for variety.
|
||||
const entries = [
|
||||
{ artifactId: 'session.md', kind: 'md', version: 3, path: '/w/.artifacts/session.md' },
|
||||
{ artifactId: 'tools.md', kind: 'md', version: 1, path: '/w/.artifacts/tools.md' },
|
||||
{ artifactId: 'AGENTS.md', kind: 'md', version: 2, path: '/w/.artifacts/AGENTS.md' },
|
||||
{ artifactId: 'plan.md', kind: 'md', version: 5, path: '/w/.artifacts/plan.md' },
|
||||
{ artifactId: 'trace.md', kind: 'md', version: 1, path: '/w/.artifacts/trace.md' },
|
||||
{ artifactId: 'growth.md', kind: 'md', version: 7, path: '/w/.artifacts/growth.md' },
|
||||
{ artifactId: 'recap.md', kind: 'md', version: 2, path: '/w/.artifacts/recap.md' },
|
||||
{ artifactId: 'notes.md', kind: 'md', version: 4, path: '/w/.artifacts/notes.md' },
|
||||
]
|
||||
setTimeout(() => {
|
||||
for (const e of entries) window.__cb(e)
|
||||
}, 20)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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…"
|
||||
|
||||
@@ -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. `<title>` inside an SVG `<g>`
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
208
examples/desktop/test/artifact-compact-row.test.js
Normal file
208
examples/desktop/test/artifact-compact-row.test.js
Normal file
@@ -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:
|
||||
//
|
||||
// * `<details class="artifact-card">` with a native `<summary>`
|
||||
// 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 <details> 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 <details> so the row expands inline'
|
||||
)
|
||||
})
|
||||
|
||||
test('artifacts.js: L0 row uses <summary class="artifact-row">', () => {
|
||||
assert.match(
|
||||
artifactsSrc,
|
||||
/createElement\(['"]summary['"]\)[\s\S]{0,120}summary\.className\s*=\s*['"]artifact-row['"]/,
|
||||
'compact row must be a <summary> 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')
|
||||
})
|
||||
@@ -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 <title> 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 <title> tooltip child on the user node')
|
||||
assert.equal(titleEl.textContent, 'y'.repeat(60))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
// `- <name>` 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)}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user