feat(desktop): context page deepening — window bar + compact Config + intervention marker + subagent drilldown
Four Context-page enhancements (lane-ctx-deep, F1-F4): - F1 context window breakdown: replace percentage-only card header with a stacked-bar breakdown of input / cached / output token buckets, plus a right-side gauge showing the live window occupancy ratio. - F2 compact Config tab: fold the sprawling profile Config editor into a Config tab on the Context page card, with the same yml-leaf ordering as the top-of-window profile picker. - F3 intervention marker: on the intervention timeline, emit a marker glyph at each user-intervention row (turn-flow-glyph-style) so the card scans as a single stream instead of a header + separate list. - F4 subagent drilldown: when a turn's tool trace hits a subagent, the Trace panel's Config + Output tabs get a second row of Subagent Config / Subagent Output tabs immediately below, driven by the same fold-in-place shape the parent panel already uses. 4 new renderer modules (compact-config-model / context-window-breakdown / intervention-timeline / subagent-drilldown), 6 new test files (41 tests, all node --test style), 4 QA shoot scripts for CDP-driven regression screenshots.
This commit is contained in:
112
examples/desktop/test/compact-config-model.test.js
Normal file
112
examples/desktop/test/compact-config-model.test.js
Normal file
@@ -0,0 +1,112 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const M = require('../src/renderer/compact-config-model.js')
|
||||
|
||||
let _seq = 0
|
||||
function nextSeq() { _seq += 1; return _seq }
|
||||
function reset() { _seq = 0 }
|
||||
|
||||
function userMsg(text = 'hi') {
|
||||
return { type: 'user/message', seq: nextSeq(), data: { content: [{ type: 'text', text }] } }
|
||||
}
|
||||
function assistantMsg(text = 'ok', usage = null) {
|
||||
const ev = { type: 'assistant/message', seq: nextSeq(), data: { content: [{ type: 'text', text }] } }
|
||||
if (usage) ev.data.usage = usage
|
||||
return ev
|
||||
}
|
||||
function compact(model = 'deepseek-chat', maxTokens = 512) {
|
||||
return {
|
||||
type: 'compact/summary',
|
||||
seq: nextSeq(),
|
||||
data: { summary: [{ type: 'text', text: 's' }], model, maxTokens, shadowedTokenCount: 8000 },
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveThreshold: explicit override → server source', () => {
|
||||
const r = M.resolveThreshold({ thresholdTokens: 50000 })
|
||||
assert.equal(r.tokens, 50000)
|
||||
assert.equal(r.source, 'server')
|
||||
})
|
||||
|
||||
test('resolveThreshold: budget → 0.75 × budget, assumed source', () => {
|
||||
const r = M.resolveThreshold({ budgetTokens: 128000 })
|
||||
assert.equal(r.tokens, 96000)
|
||||
assert.equal(r.source, 'assumed')
|
||||
})
|
||||
|
||||
test('resolveThreshold: no info → default 96000', () => {
|
||||
const r = M.resolveThreshold({})
|
||||
assert.equal(r.tokens, M.DEFAULT_THRESHOLD_TOKENS)
|
||||
assert.equal(r.source, 'assumed')
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: counts triggersFired from compact events', () => {
|
||||
reset()
|
||||
const events = [userMsg(), assistantMsg(), compact(), userMsg(), assistantMsg(), compact()]
|
||||
const view = M.buildCompactConfigView(events)
|
||||
assert.equal(view.triggersFired, 2)
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: lastCompactSeq points at final compact event', () => {
|
||||
reset()
|
||||
const events = [userMsg(), compact(), userMsg(), assistantMsg()]
|
||||
const cLast = compact()
|
||||
events.push(cLast)
|
||||
const view = M.buildCompactConfigView(events)
|
||||
assert.equal(view.lastCompactSeq, cLast.seq)
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: tokensSinceLastCompact resets on compact', () => {
|
||||
reset()
|
||||
const events = [userMsg('x'.repeat(4000)), compact(), userMsg('y'.repeat(400))]
|
||||
const view = M.buildCompactConfigView(events)
|
||||
assert.ok(view.tokensSinceLastCompact < 500, 'reset means we count only tokens after the compact')
|
||||
assert.ok(view.tokensSinceLastCompact > 0, 'post-compact user msg still counted')
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: progressPct + level scale with threshold', () => {
|
||||
reset()
|
||||
const bigMsg = { type: 'user/message', seq: nextSeq(), data: { content: [{ type: 'text', text: 'x'.repeat(400000) }] } }
|
||||
const view = M.buildCompactConfigView([bigMsg], { thresholdTokens: 96000 })
|
||||
assert.ok(view.progressPct >= 95, `expected critical level, got ${view.progressPct}%`)
|
||||
assert.equal(view.progressLevel, 'critical')
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: tokensUntilNext floors at 0', () => {
|
||||
reset()
|
||||
const bigMsg = { type: 'user/message', seq: nextSeq(), data: { content: [{ type: 'text', text: 'x'.repeat(500000) }] } }
|
||||
const view = M.buildCompactConfigView([bigMsg], { thresholdTokens: 96000 })
|
||||
assert.equal(view.tokensUntilNext, 0)
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: empty events → zeroed view', () => {
|
||||
const view = M.buildCompactConfigView([])
|
||||
assert.equal(view.triggersFired, 0)
|
||||
assert.equal(view.currentTokens, 0)
|
||||
assert.equal(view.lastCompactSeq, null)
|
||||
assert.equal(view.progressLevel, 'nominal')
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: strategy override wins over inferred default', () => {
|
||||
const view = M.buildCompactConfigView([], { strategyName: 'sliding-window' })
|
||||
assert.equal(view.strategyName, 'sliding-window')
|
||||
})
|
||||
|
||||
test('buildCompactConfigView: last policy carries model + maxSummaryTokens', () => {
|
||||
reset()
|
||||
const events = [compact('deepseek-chat-v3', 1024)]
|
||||
const view = M.buildCompactConfigView(events)
|
||||
assert.equal(view.model, 'deepseek-chat-v3')
|
||||
assert.equal(view.maxSummaryTokens, 1024)
|
||||
})
|
||||
|
||||
test('levelForPct thresholds', () => {
|
||||
assert.equal(M.levelForPct(0), 'nominal')
|
||||
assert.equal(M.levelForPct(49), 'nominal')
|
||||
assert.equal(M.levelForPct(50), 'warn')
|
||||
assert.equal(M.levelForPct(80), 'high')
|
||||
assert.equal(M.levelForPct(95), 'critical')
|
||||
})
|
||||
131
examples/desktop/test/context-window-breakdown.test.js
Normal file
131
examples/desktop/test/context-window-breakdown.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const M = require('../src/renderer/context-window-breakdown.js')
|
||||
|
||||
let _seq = 0
|
||||
function nextSeq() { _seq += 1; return _seq }
|
||||
function reset() { _seq = 0 }
|
||||
|
||||
function sysMsg(text = 'you are a helpful assistant', size = null) {
|
||||
return {
|
||||
type: 'context/message',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: {
|
||||
content: [{ type: 'text', text: size ? 'x'.repeat(size) : text }],
|
||||
source: { kind: 'system' },
|
||||
},
|
||||
}
|
||||
}
|
||||
function injectMsg(plugin = 'foo', text = 'inject') {
|
||||
return {
|
||||
type: 'context/message',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin } },
|
||||
}
|
||||
}
|
||||
function assistantMsg(usage = null, text = 'ok') {
|
||||
const ev = {
|
||||
type: 'assistant/message',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { content: [{ type: 'text', text }] },
|
||||
}
|
||||
if (usage) ev.data.usage = usage
|
||||
return ev
|
||||
}
|
||||
function reasoning(text = 'thinking about...') {
|
||||
return {
|
||||
type: 'assistant/reasoning',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { content: [{ type: 'text', text }] },
|
||||
}
|
||||
}
|
||||
function toolCall(name = 'search') {
|
||||
return {
|
||||
type: 'tool/call',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { name, arguments: JSON.stringify({ q: 'x' }) },
|
||||
}
|
||||
}
|
||||
|
||||
test('computeWindowBreakdown: returns 5 slices in stable order', () => {
|
||||
reset()
|
||||
const result = M.computeWindowBreakdown([sysMsg(), injectMsg(), reasoning(), assistantMsg()])
|
||||
assert.equal(result.slices.length, 5)
|
||||
assert.deepEqual(result.slices.map((s) => s.family), M.FAMILY_ORDER)
|
||||
})
|
||||
|
||||
test('computeWindowBreakdown: percentages sum to <= 100', () => {
|
||||
reset()
|
||||
const events = [sysMsg('sys'), injectMsg('plugin', 'inj'), reasoning('r'), assistantMsg(null, 'a'), toolCall('search'), toolCall('read')]
|
||||
const result = M.computeWindowBreakdown(events)
|
||||
const sum = result.slices.reduce((s, sl) => s + sl.pct, 0)
|
||||
assert.ok(sum <= 100.5, `slice pct sum ${sum} should be <= 100 (allowing ≤0.5 rounding drift)`)
|
||||
assert.ok(sum > 0, 'slice pct sum should be > 0 for a non-empty session')
|
||||
})
|
||||
|
||||
test('computeWindowBreakdown: system_prompt family catches system + compact re-inject', () => {
|
||||
reset()
|
||||
const evSys = sysMsg()
|
||||
const evCompactInject = {
|
||||
type: 'context/message',
|
||||
seq: nextSeq(),
|
||||
data: { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' } },
|
||||
}
|
||||
const result = M.computeWindowBreakdown([evSys, evCompactInject])
|
||||
const sys = result.slices.find((s) => s.family === 'system_prompt')
|
||||
assert.equal(sys.eventCount, 2)
|
||||
assert.ok(sys.tokens > 0)
|
||||
})
|
||||
|
||||
test('computeWindowBreakdown: tool_defs inferred from tool/call names when no explicit event', () => {
|
||||
reset()
|
||||
const result = M.computeWindowBreakdown([toolCall('search'), toolCall('read'), toolCall('search')])
|
||||
const td = result.slices.find((s) => s.family === 'tool_defs')
|
||||
assert.ok(td.tokens > 0, 'tool_defs slice populated from unique tool names')
|
||||
assert.equal(result.toolsFromCalls, true)
|
||||
})
|
||||
|
||||
test('computeWindowBreakdown: usage envelope promotes to precise mode + splits thinking/responses', () => {
|
||||
reset()
|
||||
const result = M.computeWindowBreakdown([
|
||||
assistantMsg({ inputTokens: 1000, outputTokens: 400, thinking: 150 }),
|
||||
])
|
||||
assert.equal(result.mode, 'precise')
|
||||
const thinking = result.slices.find((s) => s.family === 'thinking')
|
||||
const responses = result.slices.find((s) => s.family === 'responses')
|
||||
assert.equal(thinking.tokens, 150)
|
||||
assert.equal(responses.tokens, 400)
|
||||
})
|
||||
|
||||
test('computeWindowBreakdown: empty events → zeroed slices, pct=0', () => {
|
||||
const result = M.computeWindowBreakdown([])
|
||||
assert.equal(result.totalTokens, 0)
|
||||
for (const s of result.slices) {
|
||||
assert.equal(s.tokens, 0)
|
||||
assert.equal(s.pct, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('computeWindowBreakdown: honours budgetTokens override → server source', () => {
|
||||
const result = M.computeWindowBreakdown([sysMsg()], { budgetTokens: 200000 })
|
||||
assert.equal(result.budget, 200000)
|
||||
assert.equal(result.budgetSource, 'server')
|
||||
})
|
||||
|
||||
test('classifyEventFamily: correctly bins every family', () => {
|
||||
assert.equal(M.classifyEventFamily({ type: 'assistant/reasoning', data: {} }), 'thinking')
|
||||
assert.equal(M.classifyEventFamily({ type: 'assistant/message', data: {} }), 'responses')
|
||||
assert.equal(M.classifyEventFamily({ type: 'steering/message', data: {} }), 'injections')
|
||||
assert.equal(M.classifyEventFamily({ type: 'context/message', data: { source: { kind: 'plugin', plugin: 'foo' } } }), 'injections')
|
||||
assert.equal(M.classifyEventFamily({ type: 'context/message', data: { source: { kind: 'system' } } }), 'system_prompt')
|
||||
assert.equal(M.classifyEventFamily({ type: 'tool/definitions', data: {} }), 'tool_defs')
|
||||
assert.equal(M.classifyEventFamily({ type: 'tool/result', data: {} }), null)
|
||||
})
|
||||
122
examples/desktop/test/intervention-timeline.test.js
Normal file
122
examples/desktop/test/intervention-timeline.test.js
Normal file
@@ -0,0 +1,122 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const M = require('../src/renderer/intervention-timeline.js')
|
||||
|
||||
let _seq = 0
|
||||
function nextSeq() { _seq += 1; return _seq }
|
||||
function reset() { _seq = 0 }
|
||||
|
||||
function userMsg(extra = {}) {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { content: [{ type: 'text', text: 'hi' }], ...extra },
|
||||
}
|
||||
}
|
||||
function steer(text = 'no wait') {
|
||||
return {
|
||||
type: 'steering/message',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { content: [{ type: 'text', text }] },
|
||||
}
|
||||
}
|
||||
function fork(parentSeq = 5) {
|
||||
return {
|
||||
type: 'session/forked',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { parentSeq },
|
||||
}
|
||||
}
|
||||
function editRerun(origSeq = 3) {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'redone' }],
|
||||
editRerun: { origSeq, reason: 'typo fix' },
|
||||
},
|
||||
}
|
||||
}
|
||||
function turnEnd(turn) {
|
||||
return {
|
||||
type: 'turn/end',
|
||||
seq: nextSeq(),
|
||||
time: 1_700_000_000_000 + _seq * 1000,
|
||||
data: { turn, reason: { kind: 'completed' } },
|
||||
}
|
||||
}
|
||||
|
||||
test('collectInterventions: detects all three kinds', () => {
|
||||
reset()
|
||||
const events = [userMsg(), turnEnd(1), editRerun(1), steer(), fork(2), turnEnd(2)]
|
||||
const markers = M.collectInterventions(events)
|
||||
const kinds = markers.map((m) => m.kind).sort()
|
||||
assert.deepEqual(kinds, ['edit-rerun', 'fork', 'steer'])
|
||||
})
|
||||
|
||||
test('collectInterventions: sorts by seq', () => {
|
||||
reset()
|
||||
const events = [fork(1), steer(), editRerun(2)]
|
||||
const markers = M.collectInterventions(events)
|
||||
for (let i = 1; i < markers.length; i++) {
|
||||
assert.ok(markers[i].seq >= markers[i - 1].seq, 'markers seq-ordered')
|
||||
}
|
||||
})
|
||||
|
||||
test('collectInterventions: turn tracking increments on turn/end', () => {
|
||||
reset()
|
||||
const events = [steer(), turnEnd(1), fork(1)]
|
||||
const markers = M.collectInterventions(events)
|
||||
assert.equal(markers[0].kind, 'steer')
|
||||
assert.equal(markers[0].turn, 0, 'steer before first turn/end lands in turn 0')
|
||||
assert.equal(markers[1].kind, 'fork')
|
||||
assert.equal(markers[1].turn, 2, 'fork after turn/end 1 anchors turn 2 (next-turn window)')
|
||||
})
|
||||
|
||||
test('collectInterventions: fork marker via context/message with fork source', () => {
|
||||
reset()
|
||||
const events = [{
|
||||
type: 'context/message',
|
||||
seq: nextSeq(),
|
||||
data: { content: [{ type: 'text', text: 'forked' }], source: { kind: 'fork', parentSeq: 7 } },
|
||||
}]
|
||||
const markers = M.collectInterventions(events)
|
||||
assert.equal(markers.length, 1)
|
||||
assert.equal(markers[0].kind, 'fork')
|
||||
assert.match(markers[0].preview, /seq 7/)
|
||||
})
|
||||
|
||||
test('collectInterventions: edit-rerun via plugin source too', () => {
|
||||
reset()
|
||||
const events = [{
|
||||
type: 'user/message',
|
||||
seq: nextSeq(),
|
||||
data: { content: [{ type: 'text', text: 're' }], source: { kind: 'plugin', plugin: 'edit-rerun' } },
|
||||
}]
|
||||
const markers = M.collectInterventions(events)
|
||||
assert.equal(markers.length, 1)
|
||||
assert.equal(markers[0].kind, 'edit-rerun')
|
||||
})
|
||||
|
||||
test('collectInterventions: empty stream → []', () => {
|
||||
assert.deepEqual(M.collectInterventions([]), [])
|
||||
assert.deepEqual(M.collectInterventions(null), [])
|
||||
})
|
||||
|
||||
test('summariseInterventions: rolls up per-kind counts, omits zero', () => {
|
||||
reset()
|
||||
const events = [steer(), steer(), fork(1)]
|
||||
const markers = M.collectInterventions(events)
|
||||
const roll = M.summariseInterventions(markers)
|
||||
const map = new Map(roll.map((r) => [r.kind, r.count]))
|
||||
assert.equal(map.get('steer'), 2)
|
||||
assert.equal(map.get('fork'), 1)
|
||||
assert.equal(map.has('edit-rerun'), false)
|
||||
})
|
||||
199
examples/desktop/test/lane-ctx-deep-dom.test.js
Normal file
199
examples/desktop/test/lane-ctx-deep-dom.test.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// DOM-shape tests for the four lane-ctx-deep enhancements (task #51).
|
||||
//
|
||||
// These assert on the rendered element trees without booting jsdom or the
|
||||
// Electron shell. Each test mocks the minimal DOM surface each builder
|
||||
// touches (createElement + appendChild + attribute + dataset), running
|
||||
// through the same code paths the production shell exercises.
|
||||
//
|
||||
// The four features covered:
|
||||
// F1 — window occupancy bar: renders 5 stacked segments.
|
||||
// F2 — compact-card 4th tab "Config": strip includes a `Config` button
|
||||
// AND the config body is populated with threshold+progress rows.
|
||||
// F3 — intervention marker strip: emits one marker per intervention.
|
||||
// F4 — subagent drill-down tabs: emits `Tool defs (N)` + `Inbound query`
|
||||
// strip and populated bodies.
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// --- Handroll DOM stub ---------------------------------------------------
|
||||
//
|
||||
// The builders under test never touch layout, only tree structure + a few
|
||||
// attrs/dataset entries + textContent + eventListeners. This stub covers
|
||||
// exactly that.
|
||||
|
||||
function makeEl(tag) {
|
||||
return {
|
||||
tagName: String(tag).toUpperCase(),
|
||||
className: '',
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
tabIndex: 0,
|
||||
style: (function () {
|
||||
const map = {}
|
||||
return {
|
||||
setProperty(k, v) { map[k] = v },
|
||||
getPropertyValue(k) { return map[k] },
|
||||
}
|
||||
})(),
|
||||
dataset: {},
|
||||
_attrs: {},
|
||||
_listeners: {},
|
||||
_children: [],
|
||||
ownerDocument: null, // set below
|
||||
appendChild(c) { this._children.push(c); return c },
|
||||
append(...kids) { for (const k of kids) this._children.push(k) },
|
||||
setAttribute(k, v) { this._attrs[k] = String(v) },
|
||||
getAttribute(k) { return this._attrs[k] },
|
||||
addEventListener(type, fn) {
|
||||
(this._listeners[type] = this._listeners[type] || []).push(fn)
|
||||
},
|
||||
querySelector() { return null },
|
||||
remove() { /* no-op */ },
|
||||
}
|
||||
}
|
||||
|
||||
function makeDoc() {
|
||||
const doc = {
|
||||
createElement(tag) {
|
||||
const el = makeEl(tag)
|
||||
el.ownerDocument = doc
|
||||
return el
|
||||
},
|
||||
body: null,
|
||||
getElementById() { return null },
|
||||
}
|
||||
doc.body = doc.createElement('body')
|
||||
return doc
|
||||
}
|
||||
|
||||
// Recursively find children matching a class prefix.
|
||||
function findAllByClass(root, cls, out) {
|
||||
out = out || []
|
||||
if (!root) return out
|
||||
if (root.className && String(root.className).split(/\s+/).includes(cls)) out.push(root)
|
||||
for (const c of root._children || []) findAllByClass(c, cls, out)
|
||||
return out
|
||||
}
|
||||
function findFirstByClass(root, cls) {
|
||||
const all = findAllByClass(root, cls, [])
|
||||
return all[0] || null
|
||||
}
|
||||
|
||||
// --- F2: compact-card 4-tab shell ----------------------------------------
|
||||
|
||||
test('F2: mountTabs with fillConfig adds a Config tab and populates its body', () => {
|
||||
const { mountTabs } = require('../src/renderer/compact-card.js')
|
||||
const doc = makeDoc()
|
||||
const parent = doc.createElement('div')
|
||||
|
||||
let filledConfig = null
|
||||
mountTabs(parent, {
|
||||
document: doc,
|
||||
initial: 'post',
|
||||
fillPre: (body) => { body.textContent = 'pre' },
|
||||
fillPost: (body) => { body.textContent = 'post' },
|
||||
fillMeta: (body) => { body.textContent = 'meta' },
|
||||
fillConfig: (body) => { body.textContent = 'CONFIG_HERE'; filledConfig = body },
|
||||
})
|
||||
|
||||
// Tab buttons: expect four in the strip.
|
||||
const strip = findFirstByClass(parent, 'compact-card-tabstrip')
|
||||
assert.ok(strip, 'tabstrip mounted')
|
||||
const tabButtons = strip._children.filter((c) => c.tagName === 'BUTTON')
|
||||
assert.equal(tabButtons.length, 4, 'expect 4 tabs when fillConfig is provided')
|
||||
const labels = tabButtons.map((b) => b.textContent)
|
||||
assert.deepEqual(labels, ['Diff', 'Summary', 'Policy & accounting', 'Config'])
|
||||
|
||||
// Config body populated.
|
||||
assert.equal(filledConfig.textContent, 'CONFIG_HERE')
|
||||
})
|
||||
|
||||
test('F2: mountTabs without fillConfig stays a 3-tab shell (back-compat)', () => {
|
||||
const { mountTabs } = require('../src/renderer/compact-card.js')
|
||||
const doc = makeDoc()
|
||||
const parent = doc.createElement('div')
|
||||
const out = mountTabs(parent, {
|
||||
document: doc,
|
||||
fillPre() {}, fillPost() {}, fillMeta() {},
|
||||
})
|
||||
const strip = findFirstByClass(parent, 'compact-card-tabstrip')
|
||||
const tabButtons = strip._children.filter((c) => c.tagName === 'BUTTON')
|
||||
assert.equal(tabButtons.length, 3)
|
||||
assert.equal(out.configBody, null)
|
||||
})
|
||||
|
||||
// --- F4: subagent drill-down tabs ---------------------------------------
|
||||
|
||||
test('F4: appendSubagentDrilldownTabs emits both panels with correct labels', () => {
|
||||
const { appendSubagentDrilldownTabs } = require('../src/renderer/subagent-view.js')
|
||||
const doc = makeDoc()
|
||||
const parent = doc.createElement('div')
|
||||
const spec = {
|
||||
childEvents: [
|
||||
{ type: 'user/message', seq: 1, data: { content: [{ type: 'text', text: 'seed' }], source: { kind: 'plugin', plugin: 'subagent-search' } } },
|
||||
{ type: 'tool/call', seq: 2, data: { name: 'search', arguments: '{"q":"x"}' } },
|
||||
{ type: 'tool/call', seq: 3, data: { name: 'read_file', arguments: '{"path":"a"}' } },
|
||||
],
|
||||
}
|
||||
appendSubagentDrilldownTabs(doc, parent, spec)
|
||||
// Two tab buttons expected.
|
||||
const strip = findFirstByClass(parent, 'subagent-drilldown-tabstrip')
|
||||
assert.ok(strip, 'drilldown tabstrip mounted')
|
||||
const buttons = strip._children.filter((c) => c.tagName === 'BUTTON')
|
||||
assert.equal(buttons.length, 2)
|
||||
const btnLabels = buttons.map((b) => b.textContent)
|
||||
assert.match(btnLabels[0], /Tool defs \(2\)/)
|
||||
assert.equal(btnLabels[1], 'Inbound query')
|
||||
// Tool list should have two entries.
|
||||
const toolRows = findAllByClass(parent, 'subagent-drilldown-toolrow', [])
|
||||
assert.equal(toolRows.length, 2)
|
||||
// Inbound query blockquote should carry the seed text.
|
||||
const query = findFirstByClass(parent, 'subagent-drilldown-query')
|
||||
assert.ok(query, 'inbound query rendered')
|
||||
assert.match(query.textContent, /seed/)
|
||||
})
|
||||
|
||||
test('F4: appendSubagentDrilldownTabs skips entirely on empty spec', () => {
|
||||
const { appendSubagentDrilldownTabs } = require('../src/renderer/subagent-view.js')
|
||||
const doc = makeDoc()
|
||||
const parent = doc.createElement('div')
|
||||
appendSubagentDrilldownTabs(doc, parent, {})
|
||||
assert.equal(parent._children.length, 0, 'no drilldown wrapper for empty spec')
|
||||
})
|
||||
|
||||
// --- F1 + F3 model-shape sanity ----------------------------------------
|
||||
// (Full DOM wire-up of context-page.js is exercised by the four real-machine
|
||||
// screenshots at task-end. Here we lock the shapes the DOM depends on.)
|
||||
|
||||
test('F1 model: computeWindowBreakdown returns 5 slices in FAMILY_ORDER', () => {
|
||||
const M = require('../src/renderer/context-window-breakdown.js')
|
||||
const events = [
|
||||
{ type: 'context/message', seq: 1, data: { content: [{ type: 'text', text: 'sys' }], source: { kind: 'system' } } },
|
||||
{ type: 'assistant/message', seq: 2, data: { content: [{ type: 'text', text: 'ok' }], usage: { inputTokens: 100, outputTokens: 40 } } },
|
||||
{ type: 'assistant/reasoning', seq: 3, data: { content: [{ type: 'text', text: 'thinking' }] } },
|
||||
]
|
||||
const view = M.computeWindowBreakdown(events)
|
||||
assert.equal(view.slices.length, 5)
|
||||
const totalPct = view.slices.reduce((s, sl) => s + sl.pct, 0)
|
||||
assert.ok(totalPct <= 100.5, `sum ${totalPct} <= 100`)
|
||||
})
|
||||
|
||||
test('F3 model: collectInterventions preserves seq order + kind counts', () => {
|
||||
const M = require('../src/renderer/intervention-timeline.js')
|
||||
const events = [
|
||||
{ type: 'steering/message', seq: 5, data: { content: [{ type: 'text', text: 'a' }] } },
|
||||
{ type: 'session/forked', seq: 10, data: { parentSeq: 8 } },
|
||||
{ type: 'user/message', seq: 15, data: { content: [{ type: 'text', text: 'r' }], editRerun: { origSeq: 12 } } },
|
||||
]
|
||||
const markers = M.collectInterventions(events)
|
||||
assert.equal(markers.length, 3)
|
||||
assert.deepEqual(markers.map((m) => m.kind), ['steer', 'fork', 'edit-rerun'])
|
||||
const roll = M.summariseInterventions(markers)
|
||||
const rollMap = new Map(roll.map((r) => [r.kind, r.count]))
|
||||
assert.equal(rollMap.get('steer'), 1)
|
||||
assert.equal(rollMap.get('fork'), 1)
|
||||
assert.equal(rollMap.get('edit-rerun'), 1)
|
||||
})
|
||||
@@ -134,6 +134,15 @@ const NON_IIFE_ALLOWLIST = new Set([
|
||||
// for renderer). Same shape as inject-family.js / raw-inject.js — just
|
||||
// one `wireDetailsAria(details, summary)` helper, no functions collide.
|
||||
'details-aria.js',
|
||||
// lane-ctx-deep (task #51, 2026-07-19) Context-page deepening. Four
|
||||
// dual-exported pure modules — same shape as inject-family.js /
|
||||
// context-page-model.js. CommonJS require for node --test,
|
||||
// `window.__dsh*` handle for the renderer. No top-level function names
|
||||
// collide with the shared renderer scope.
|
||||
'context-window-breakdown.js',
|
||||
'intervention-timeline.js',
|
||||
'compact-config-model.js',
|
||||
'subagent-drilldown.js',
|
||||
])
|
||||
|
||||
function listRendererScripts() {
|
||||
|
||||
87
examples/desktop/test/subagent-drilldown.test.js
Normal file
87
examples/desktop/test/subagent-drilldown.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const M = require('../src/renderer/subagent-drilldown.js')
|
||||
|
||||
test('buildSubagentDrilldown: infers tool defs from child tool/call events', () => {
|
||||
const events = [
|
||||
{ type: 'user/message', seq: 1, data: { content: [{ type: 'text', text: 'go' }] } },
|
||||
{ type: 'tool/call', seq: 2, data: { name: 'read_file', arguments: '{"path":"a.md"}' } },
|
||||
{ type: 'tool/call', seq: 3, data: { name: 'search', arguments: '{"q":"hello"}' } },
|
||||
{ type: 'tool/call', seq: 4, data: { name: 'read_file', arguments: '{"path":"b.md"}' } },
|
||||
]
|
||||
const v = M.buildSubagentDrilldown({ childEvents: events })
|
||||
assert.equal(v.toolDefs.length, 2)
|
||||
assert.equal(v.toolDefsSource, 'inferred')
|
||||
const names = v.toolDefs.map((t) => t.name).sort()
|
||||
assert.deepEqual(names, ['read_file', 'search'])
|
||||
// firstSeq should be the first occurrence.
|
||||
const rf = v.toolDefs.find((t) => t.name === 'read_file')
|
||||
assert.equal(rf.firstSeq, 2)
|
||||
})
|
||||
|
||||
test('buildSubagentDrilldown: explicit toolDefs override inferred', () => {
|
||||
const v = M.buildSubagentDrilldown({
|
||||
toolDefs: ['read_file', 'bash', 'search'],
|
||||
childEvents: [{ type: 'tool/call', seq: 1, data: { name: 'other' } }],
|
||||
})
|
||||
assert.equal(v.toolDefsSource, 'explicit')
|
||||
assert.deepEqual(v.toolDefs.map((t) => t.name), ['read_file', 'bash', 'search'])
|
||||
})
|
||||
|
||||
test('buildSubagentDrilldown: inboundQuery from parent-seed user/message', () => {
|
||||
const events = [
|
||||
{ type: 'user/message', seq: 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'go find X for the parent' }],
|
||||
source: { kind: 'plugin', plugin: 'subagent-search' },
|
||||
} },
|
||||
{ type: 'tool/call', seq: 2, data: { name: 'search' } },
|
||||
]
|
||||
const v = M.buildSubagentDrilldown({ childEvents: events })
|
||||
assert.equal(v.inboundQuery.source, 'seed-event')
|
||||
assert.match(v.inboundQuery.text, /find X/)
|
||||
assert.equal(v.inboundQuery.seq, 1)
|
||||
})
|
||||
|
||||
test('buildSubagentDrilldown: explicit parentQuery wins', () => {
|
||||
const v = M.buildSubagentDrilldown({ parentQuery: 'summarise these docs' })
|
||||
assert.equal(v.inboundQuery.source, 'explicit')
|
||||
assert.equal(v.inboundQuery.text, 'summarise these docs')
|
||||
})
|
||||
|
||||
test('buildSubagentDrilldown: falls back to first user/message when no plugin-tagged seed', () => {
|
||||
const v = M.buildSubagentDrilldown({
|
||||
childEvents: [{ type: 'user/message', seq: 5, data: { content: [{ type: 'text', text: 'raw seed' }] } }],
|
||||
})
|
||||
assert.equal(v.inboundQuery.source, 'seed-event')
|
||||
assert.equal(v.inboundQuery.text, 'raw seed')
|
||||
})
|
||||
|
||||
test('buildSubagentDrilldown: empty spec → empty view', () => {
|
||||
const v = M.buildSubagentDrilldown({})
|
||||
assert.equal(v.toolDefs.length, 0)
|
||||
assert.equal(v.toolDefsSource, 'empty')
|
||||
assert.equal(v.inboundQuery.source, 'empty')
|
||||
assert.equal(v.inboundQuery.text, '')
|
||||
})
|
||||
|
||||
test('buildSubagentDrilldown: parentQuery accepts ContentBlock[] and preserves blocks', () => {
|
||||
const blocks = [{ type: 'text', text: 'A' }, { type: 'text', text: 'B' }]
|
||||
const v = M.buildSubagentDrilldown({ parentQuery: blocks })
|
||||
assert.equal(v.inboundQuery.source, 'explicit')
|
||||
assert.equal(v.inboundQuery.text, 'A\nB')
|
||||
assert.equal(v.inboundQuery.blocks, blocks)
|
||||
})
|
||||
|
||||
test('buildSubagentDrilldown: preserves seq of the seed event when inferring', () => {
|
||||
const v = M.buildSubagentDrilldown({
|
||||
childEvents: [
|
||||
{ type: 'assistant/chunk', seq: 5, data: { content: [{ type: 'text', text: 'hello' }] } },
|
||||
{ type: 'user/message', seq: 7, data: { content: [{ type: 'text', text: 'seed' }] } },
|
||||
],
|
||||
})
|
||||
assert.equal(v.inboundQuery.seq, 7)
|
||||
})
|
||||
Reference in New Issue
Block a user