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:
ZiyaZhang
2026-07-19 01:22:28 -07:00
parent c8f5ca3d5b
commit fc558c2db8
18 changed files with 2705 additions and 4 deletions

View 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')
})

View 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)
})

View 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)
})

View 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)
})

View File

@@ -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() {

View 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)
})