// subagent-view.test.js — task #138 batch 3 §1.4. // Verifies: // 1. parseSubagentReturn extracts JSON from a ```json ... ``` fence // 2. parseSubagentReturn returns ok:false with raw payload on parse failure // 3. parseSubagentReturn returns null for empty/missing content blocks // 4. summariseSubagentSteps counts step/start/step/end and captures duration // 5. buildSubagentCard renders lineage + status pill + steps + return sections // 6. buildSubagentCard uses raw section when return is not JSON // 7. buildSubagentRail renders one pill per agent with click handler // 8. shortId shortens long ids in the head 'use strict' const test = require('node:test') const assert = require('node:assert') const path = require('node:path') const fs = require('node:fs') const view = require('../src/renderer/subagent-view.js') function makeDoc() { return { createElement(tag) { const el = { tagName: tag.toUpperCase(), className: '', textContent: '', _children: [], _listeners: {}, dataset: {}, appendChild(c) { this._children.push(c); return c }, append(...cs) { for (const c of cs) this._children.push(c) }, addEventListener(name, fn) { (this._listeners[name] = this._listeners[name] || []).push(fn) }, setAttribute() {}, } Object.defineProperty(el, 'open', { get() { return this._open === true }, set(v) { this._open = v }, }) return el }, } } function collectByClass(root, className, out = []) { if (!root) return out if (root.className && root.className.split(' ').includes(className)) out.push(root) for (const c of (root._children || [])) collectByClass(c, className, out) return out } test('parseSubagentReturn extracts JSON from a ```json fence', () => { const blocks = [{ type: 'text', text: '```json\n{"a":1,"b":[2,3]}\n```' }] const r = view.parseSubagentReturn(blocks) assert.ok(r) assert.strictEqual(r.ok, true) assert.deepStrictEqual(r.json, { a: 1, b: [2, 3] }) }) test('parseSubagentReturn returns ok:false with raw payload on parse failure', () => { const blocks = [{ type: 'text', text: '```json\nnot valid json {\n```' }] const r = view.parseSubagentReturn(blocks) assert.ok(r) assert.strictEqual(r.ok, false) assert.match(r.raw, /not valid/) }) test('parseSubagentReturn returns null for empty/missing content', () => { assert.strictEqual(view.parseSubagentReturn(null), null) assert.strictEqual(view.parseSubagentReturn([]), null) assert.strictEqual(view.parseSubagentReturn([{ type: 'image' }]), null) }) test('summariseSubagentSteps counts step/start and step/end and captures duration', () => { const evs = [ { type: 'turn/start', time: 100, data: { turn: 0 } }, { type: 'step/start', time: 110, data: { step: 0 } }, { type: 'step/end', time: 200, data: { step: 0 } }, { type: 'step/start', time: 210, data: { step: 1 } }, { type: 'step/end', time: 300, data: { step: 1 } }, { type: 'turn/end', time: 310, data: { reason: { kind: 'completed' } } }, ] const s = view.summariseSubagentSteps(evs) assert.strictEqual(s.total, 2) assert.strictEqual(s.done, 2) assert.strictEqual(s.running, 0) assert.strictEqual(s.failed, 0) assert.strictEqual(s.durationMs, 210) }) test('buildSubagentCard shows lineage + status + steps + return section', () => { const doc = makeDoc() const fixturePath = path.join(__dirname, '..', 'fixtures', 'trace-samples', '1.4-subagent-structured-return.json') const seq = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')) const started = seq.find(e => e && e.method === 'subagent.started') const finished = seq.find(e => e && e.method === 'subagent.finished') const childEvents = seq.filter(e => e && !e.method) const spec = { parentSessionId: started.params.parentSessionId, childSessionId: started.params.childSessionId, provider: finished.params.provider, status: finished.params.status, childEvents, lastAssistantMessage: finished.params.lastAssistantMessage, } const card = view.buildSubagentCard(doc, spec, {}) const lineage = collectByClass(card, 'subagent-card-lineage')[0] assert.match(lineage.textContent, /root/) assert.match(lineage.textContent, /sub-def/) const pill = collectByClass(card, 'subagent-card-status')[0] assert.strictEqual(pill.textContent, 'done') const returnJson = collectByClass(card, 'subagent-card-return-json')[0] assert.ok(returnJson, 'expected a JSON return section for the structured fixture') assert.match(returnJson.textContent, /references/) }) test('buildSubagentCard falls back to prose section when return is not JSON', () => { // viz-coverage-matrix §5 P0-5 fix: unstructured lastAssistantMessage now // renders as prose paragraph (.subagent-card-return-prose), not a
  // (.subagent-card-return-raw). The old class remains reserved for callers
  // that need explicit raw-text framing (none in-tree).
  const doc = makeDoc()
  const spec = {
    parentSessionId: 'p', childSessionId: 'c',
    status: 'ok',
    childEvents: [],
    lastAssistantMessage: [{ type: 'text', text: 'plain english, no fence' }],
  }
  const card = view.buildSubagentCard(doc, spec, {})
  assert.strictEqual(collectByClass(card, 'subagent-card-return-json').length, 0)
  const prose = collectByClass(card, 'subagent-card-return-prose')[0]
  assert.ok(prose, 'expected .subagent-card-return-prose element')
  assert.strictEqual(prose.textContent, 'plain english, no fence')
})

test('buildSubagentRail renders one pill per agent with click handler', () => {
  const doc = makeDoc()
  const clicks = []
  const rail = view.buildSubagentRail(doc, [
    { childSessionId: 'a1234567890', status: 'running' },
    { childSessionId: 'b0987654321', status: 'done' },
  ], { onPillClick: (id) => clicks.push(id) })
  const pills = collectByClass(rail, 'subagent-rail-pill')
  assert.strictEqual(pills.length, 2)
  const click = (pills[0]._listeners.click || [])[0]
  click()
  assert.deepStrictEqual(clicks, ['a1234567890'])
})

test('buildSubagentRail shows empty state when no agents', () => {
  const doc = makeDoc()
  const rail = view.buildSubagentRail(doc, [], {})
  const empty = collectByClass(rail, 'subagent-rail-empty')[0]
  assert.ok(empty)
})

// -- buildInlineSubagentTrace (#162 rec 31) --------------------------------
//
// Wraps buildSubagentCard in an inline shell so a subagent shows up as a
// peer step under the parent's spawn tool-row inside the assistant-turn
// body — not as a floating side card. Header row: glyph + label +
// lineage + meta (steps · duration · status).

test('buildInlineSubagentTrace: sealed subagent = check glyph + steps summary + full card body', () => {
  const doc = makeDoc()
  const spec = {
    parentSessionId: 'root-abc',
    childSessionId: 'sub-def-1234',
    parentCallId: 'call_spawn_9',
    status: 'ok',
    childEvents: [
      { type: 'step/start', time: 100, data: { step: 0 } },
      { type: 'step/end',   time: 250, data: { step: 0 } },
      { type: 'step/start', time: 260, data: { step: 1 } },
      { type: 'step/end',   time: 400, data: { step: 1 } },
    ],
    lastAssistantMessage: [{ type: 'text', text: '```json\n{"count":1}\n```' }],
  }
  const trace = view.buildInlineSubagentTrace(doc, spec, {})
  assert.strictEqual(trace.tagName, 'DETAILS')
  assert.ok(trace.className.includes('turn-child'))
  assert.ok(trace.className.includes('subagent-trace'))
  assert.strictEqual(trace.dataset.parentCallId, 'call_spawn_9')
  assert.strictEqual(trace.dataset.childSessionId, 'sub-def-1234')

  const glyph = collectByClass(trace, 'subagent-trace-glyph')[0]
  assert.ok(glyph)
  assert.strictEqual(glyph.textContent, '✓')  // ✓

  const meta = collectByClass(trace, 'subagent-trace-meta')[0]
  assert.ok(meta)
  // "2/2 steps · 300ms · done"
  assert.match(meta.textContent, /2\/2 steps/)
  assert.match(meta.textContent, /300ms/)
  assert.match(meta.textContent, /done/)

  // Full card sits inside body — verify at least one signature class from
  // buildSubagentCard is present. C16/C17 (drift cycle 13/14): the outer
  //  now owns lineage/status/steps/duration and the inner card
  // renders with omitHead:true, so we look for `.subagent-card` itself
  // and confirm no `.subagent-card-head` duplicate lives inside.
  const innerCard = collectByClass(trace, 'subagent-card')[0]
  assert.ok(innerCard)
  const heads = collectByClass(trace, 'subagent-card-head')
  assert.strictEqual(heads.length, 0,
    'buildInlineSubagentTrace must pass omitHead:true so the inner card does not re-render lineage/status/steps/duration')
})

test('buildInlineSubagentTrace: failed subagent = cross glyph and "failed" meta', () => {
  const doc = makeDoc()
  const spec = {
    childSessionId: 'sub-fail',
    parentCallId: 'call_x',
    status: 'failed',
    childEvents: [],
  }
  const trace = view.buildInlineSubagentTrace(doc, spec, {})
  const glyph = collectByClass(trace, 'subagent-trace-glyph')[0]
  assert.strictEqual(glyph.textContent, '✗')  // ✗
  const meta = collectByClass(trace, 'subagent-trace-meta')[0]
  assert.match(meta.textContent, /failed/)
})

test('buildInlineSubagentTrace: collapsed by default, opts.collapsed=false forces open', () => {
  const doc = makeDoc()
  const specA = { childSessionId: 's', parentCallId: 'c', status: 'ok', childEvents: [] }
  const collapsed = view.buildInlineSubagentTrace(doc, specA, {})
  assert.notStrictEqual(collapsed._open, true)
  const expanded = view.buildInlineSubagentTrace(doc, specA, { collapsed: false })
  assert.strictEqual(expanded._open, true)
})

// -- viz-coverage-matrix §5 P0-5/6: subagent.finished field backfill ---------

test('renderStatusToken: appends stopReason when present, falls back on empty', () => {
  assert.strictEqual(view.renderStatusToken('done', 'stop'), 'done · stop')
  assert.strictEqual(view.renderStatusToken('done', 'max-tokens'), 'done · max-tokens')
  assert.strictEqual(view.renderStatusToken('failed', 'error'), 'failed · error')
  assert.strictEqual(view.renderStatusToken('done', null), 'done')
  assert.strictEqual(view.renderStatusToken('done', ''), 'done')
  assert.strictEqual(view.renderStatusToken('running', undefined), 'running')
})

test('buildInlineSubagentTrace: stopReason surfaces in the summary meta segment', () => {
  const doc = makeDoc()
  const spec = {
    childSessionId: 'sub-1', parentCallId: 'call_1',
    status: 'ok', stopReason: 'max-tokens', childEvents: [],
  }
  const trace = view.buildInlineSubagentTrace(doc, spec, {})
  const meta = collectByClass(trace, 'subagent-trace-meta')[0]
  assert.match(meta.textContent, /done · max-tokens/)
})

test('buildSubagentCard: stopReason surfaces in the head status pill', () => {
  const doc = makeDoc()
  const spec = {
    parentSessionId: 'p', childSessionId: 'c',
    status: 'ok', stopReason: 'stop', childEvents: [],
  }
  const card = view.buildSubagentCard(doc, spec, {})
  const pill = collectByClass(card, 'subagent-card-status')[0]
  assert.ok(pill)
  assert.strictEqual(pill.textContent, 'done · stop')
})

test('subagentLastMessagePreview: flattens ContentBlock[] to one-liner ≤140 chars', () => {
  const short = view.subagentLastMessagePreview([
    { type: 'text', text: 'quick answer' },
  ])
  assert.strictEqual(short, 'quick answer')

  const long = view.subagentLastMessagePreview([
    { type: 'text', text: 'a'.repeat(200) },
  ])
  assert.ok(long.endsWith('…'))
  // Cap: 137 chars + one ellipsis character = 138. The 140 in the doc is
  // the length gate (>140 triggers truncation), not the final output.
  assert.strictEqual(long.length, 138)

  const multiline = view.subagentLastMessagePreview([
    { type: 'text', text: 'line one\n\nline two' },
  ])
  assert.strictEqual(multiline, 'line one line two')

  assert.strictEqual(view.subagentLastMessagePreview(null), '')
  assert.strictEqual(view.subagentLastMessagePreview([]), '')
})

test('buildSubagentRail: pill title carries status·stopReason and message preview', () => {
  const doc = makeDoc()
  const rail = view.buildSubagentRail(doc, [
    { childSessionId: 'sub-abc-xyz', status: 'ok', stopReason: 'stop',
      lastAssistantMessage: [{ type: 'text', text: 'summary of the run' }] },
    { childSessionId: 'sub-run-000', status: 'running' },
  ], {})
  // The mock doc only stores dataset + className + textContent; title lands
  // on el.title via a direct assignment which the mock stashes as own property.
  const pills = collectByClass(rail, 'subagent-rail-pill')
  assert.strictEqual(pills.length, 2)
  assert.match(pills[0].title, /sub-abc-xyz/)
  assert.match(pills[0].title, /done · stop/)
  assert.match(pills[0].title, /summary of the run/)
  assert.match(pills[1].title, /running/)
})

test('buildSubagentCard: empty lastAssistantMessage skips return section entirely', () => {
  const doc = makeDoc()
  const spec = {
    parentSessionId: 'p', childSessionId: 'c', status: 'ok',
    childEvents: [], lastAssistantMessage: [],
  }
  const card = view.buildSubagentCard(doc, spec, {})
  assert.strictEqual(collectByClass(card, 'subagent-card-return').length, 0)
})