feat(desktop): fuse step card with tool row at narrative position + glyph fallback
This commit is contained in:
BIN
examples/desktop/docs/qa-step-card-merge/01-fused-collapsed.png
Normal file
BIN
examples/desktop/docs/qa-step-card-merge/01-fused-collapsed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
BIN
examples/desktop/docs/qa-step-card-merge/02-fused-expanded.png
Normal file
BIN
examples/desktop/docs/qa-step-card-merge/02-fused-expanded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
BIN
examples/desktop/docs/qa-step-card-merge/03-fused-edit-rerun.png
Normal file
BIN
examples/desktop/docs/qa-step-card-merge/03-fused-edit-rerun.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
258
examples/desktop/scripts/qa-cdp-shoot-step-card-merge.mjs
Normal file
258
examples/desktop/scripts/qa-cdp-shoot-step-card-merge.mjs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// scripts/qa-cdp-shoot-step-card-merge.mjs — feat/step-card-merge shoot.
|
||||||
|
//
|
||||||
|
// Boots an isolated Electron on CDP :9522 with its own --user-data-dir
|
||||||
|
// and $DSH_DESKTOP_HOME so the user's live desktop demo (pid 7810/7816
|
||||||
|
// on ~/.dsh-desktop) is never touched. Seeds a turn with thinking +
|
||||||
|
// multiple tool calls, captures three screenshots:
|
||||||
|
//
|
||||||
|
// 01-fused-collapsed.png — chat flow, fused step/tool cards collapsed
|
||||||
|
// 02-fused-expanded.png — first fused card opened, showing args +
|
||||||
|
// result + inputs/outputs/events panes + the
|
||||||
|
// edit-and-re-run trigger revealed on open
|
||||||
|
// 03-fused-edit-rerun.png — edit-and-re-run panel opened inside the
|
||||||
|
// expanded fused card (textarea visible)
|
||||||
|
//
|
||||||
|
// Isolation follows scripts/qa-cdp-shoot-context-topright.mjs precedent.
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||||
|
import { resolve, join } from 'node:path'
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
|
||||||
|
const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
|
||||||
|
const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
|
||||||
|
const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
|
||||||
|
const CDP_PORT = Number(process.env.DSH_STEP_CARD_MERGE_PORT || 9522)
|
||||||
|
const USER_DATA = join(tmpdir(), 'dsh-step-card-merge-userdata')
|
||||||
|
const DSH_HOME = join(tmpdir(), 'dsh-step-card-merge-home')
|
||||||
|
const OUTDIR = join(WORKTREE, 'docs/qa-step-card-merge')
|
||||||
|
|
||||||
|
if (!existsSync(ELECTRON)) {
|
||||||
|
console.error(`electron binary not found at ${ELECTRON}`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
mkdirSync(OUTDIR, { recursive: true })
|
||||||
|
for (const dir of [USER_DATA, DSH_HOME]) {
|
||||||
|
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
writeFileSync(join(DSH_HOME, 'config.json'), JSON.stringify({
|
||||||
|
role: 'coding', approvalMode: 'never',
|
||||||
|
}))
|
||||||
|
writeFileSync(join(DSH_HOME, '.onboarded'), new Date().toISOString())
|
||||||
|
|
||||||
|
async function bootElectron() {
|
||||||
|
const child = spawn(ELECTRON, [
|
||||||
|
`--remote-debugging-port=${CDP_PORT}`,
|
||||||
|
`--user-data-dir=${USER_DATA}`,
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-sandbox',
|
||||||
|
'.',
|
||||||
|
], {
|
||||||
|
cwd: WORKTREE,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DSH_DESKTOP_HOME: DSH_HOME,
|
||||||
|
DSH_MAXIMIZE: '1',
|
||||||
|
// Deliberately DO NOT set DSH_QA=1 — the QA fixture bootstrap seeds
|
||||||
|
// its own bench/session content which overlays our seeded turns and
|
||||||
|
// scrolls the fused cards off-screen. We drive the renderer seam
|
||||||
|
// directly instead of relying on the fixture path.
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
const logs = []
|
||||||
|
child.stdout.on('data', d => logs.push(String(d)))
|
||||||
|
child.stderr.on('data', d => logs.push(String(d)))
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await sleep(500)
|
||||||
|
try {
|
||||||
|
const r = await fetch(`http://localhost:${CDP_PORT}/json/list`)
|
||||||
|
if (r.ok) return { child, logs }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
console.error('electron CDP did not come up. logs:\n' + logs.join(''))
|
||||||
|
process.exit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newCdp() {
|
||||||
|
const targets = await (await fetch(`http://localhost:${CDP_PORT}/json/list`)).json()
|
||||||
|
const target = targets.find(t => t.type === 'page')
|
||||||
|
if (!target) throw new Error('no page target on port ' + CDP_PORT)
|
||||||
|
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||||
|
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) })
|
||||||
|
let id = 1
|
||||||
|
const pending = new Map()
|
||||||
|
ws.onmessage = ev => {
|
||||||
|
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data))
|
||||||
|
if (msg.id != null && pending.has(msg.id)) {
|
||||||
|
const [ok, err] = pending.get(msg.id); pending.delete(msg.id)
|
||||||
|
if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
|
||||||
|
const _id = id++
|
||||||
|
const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
|
||||||
|
pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }])
|
||||||
|
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
|
||||||
|
})
|
||||||
|
const evj = async expr => {
|
||||||
|
const r = await call('Runtime.evaluate', {
|
||||||
|
expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
|
||||||
|
returnByValue: true, awaitPromise: true,
|
||||||
|
})
|
||||||
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
|
||||||
|
return r.result?.value
|
||||||
|
}
|
||||||
|
return { ws, call, evj }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed: user prompt → 2 turns. First turn has one thinking chunk and a
|
||||||
|
// single tool call (read). Second turn has thinking + 3 parallel tool
|
||||||
|
// calls (read, grep, bash) so we can observe the "<first> +N" summary
|
||||||
|
// on the multi-call fused card.
|
||||||
|
const SEED = `(async () => {
|
||||||
|
const R = window.__dshRenderer
|
||||||
|
if (!R) return { __err: 'renderer seam missing' }
|
||||||
|
const sid = 'step-merge-' + Date.now()
|
||||||
|
R.ensureSession(sid, { title: 'step-card-merge demo', header: { model: 'deepseek-r1' } })
|
||||||
|
await R.selectSession(sid)
|
||||||
|
const emit = (ev) => R.onSessionEvent(sid, ev)
|
||||||
|
const t0 = Date.now()
|
||||||
|
let seq = 1
|
||||||
|
const at = (o) => t0 + o
|
||||||
|
// Turn 1: single-tool step
|
||||||
|
emit({ type: 'user/message', seq: seq++, time: at(0),
|
||||||
|
data: { content: [{ type: 'text', text: 'Read the design doc header for me.' }] } })
|
||||||
|
emit({ type: 'turn/start', seq: seq++, time: at(5), data: { turnId: 't0', model: 'deepseek-r1' } })
|
||||||
|
emit({ type: 'step/start', seq: seq++, time: at(10), data: { turn: 0, step: 0 } })
|
||||||
|
emit({ type: 'assistant/message', seq: seq++, time: at(20), data: {
|
||||||
|
content: [{ type: 'text', text: 'thinking: open the design doc' }],
|
||||||
|
usage: { inputTokens: 210, outputTokens: 8, cacheReadTokens: 1024 },
|
||||||
|
} })
|
||||||
|
emit({ type: 'tool/call', seq: seq++, time: at(60),
|
||||||
|
data: { callId: 'c-1', name: 'read', arguments: JSON.stringify({ path: 'docs/DESIGN.md', limit: 40 }) } })
|
||||||
|
emit({ type: 'tool/result', seq: seq++, time: at(140),
|
||||||
|
data: { callId: 'c-1', content: [{ type: 'text', text: '# design doc\\n\\n(header excerpt)' }] } })
|
||||||
|
emit({ type: 'step/end', seq: seq++, time: at(150), data: {} })
|
||||||
|
emit({ type: 'turn/end', seq: seq++, time: at(160), data: { turnId: 't0', reason: 'completed',
|
||||||
|
usage: { total_tokens: 240 }, durationMs: 160 } })
|
||||||
|
// Turn 2: multi-tool step
|
||||||
|
emit({ type: 'user/message', seq: seq++, time: at(200),
|
||||||
|
data: { content: [{ type: 'text', text: 'now grep for TODOs and list changed files.' }] } })
|
||||||
|
emit({ type: 'turn/start', seq: seq++, time: at(210), data: { turnId: 't1', model: 'deepseek-r1' } })
|
||||||
|
emit({ type: 'step/start', seq: seq++, time: at(215), data: { turn: 1, step: 0 } })
|
||||||
|
emit({ type: 'assistant/message', seq: seq++, time: at(220), data: {
|
||||||
|
content: [{ type: 'text', text: 'thinking: run three tools in parallel' }],
|
||||||
|
usage: { inputTokens: 320, outputTokens: 14, cacheReadTokens: 2048 },
|
||||||
|
} })
|
||||||
|
emit({ type: 'tool/call', seq: seq++, time: at(230),
|
||||||
|
data: { callId: 'c-2', name: 'read', arguments: JSON.stringify({ path: 'src/renderer/renderer.js', limit: 20 }) } })
|
||||||
|
emit({ type: 'tool/call', seq: seq++, time: at(232),
|
||||||
|
data: { callId: 'c-3', name: 'grep', arguments: JSON.stringify({ pattern: 'TODO', path: 'src' }) } })
|
||||||
|
emit({ type: 'tool/call', seq: seq++, time: at(234),
|
||||||
|
data: { callId: 'c-4', name: 'bash', arguments: JSON.stringify({ command: 'git status -s' }) } })
|
||||||
|
emit({ type: 'tool/result', seq: seq++, time: at(310),
|
||||||
|
data: { callId: 'c-2', content: [{ type: 'text', text: '// top of renderer.js' }] } })
|
||||||
|
emit({ type: 'tool/result', seq: seq++, time: at(320),
|
||||||
|
data: { callId: 'c-3', content: [{ type: 'text', text: 'src/renderer/foo.js:42: // TODO polish' }] } })
|
||||||
|
emit({ type: 'tool/result', seq: seq++, time: at(330),
|
||||||
|
data: { callId: 'c-4', content: [{ type: 'text', text: ' M src/renderer/style.css' }] } })
|
||||||
|
emit({ type: 'step/end', seq: seq++, time: at(340), data: {} })
|
||||||
|
emit({ type: 'turn/end', seq: seq++, time: at(350), data: { turnId: 't1', reason: 'completed',
|
||||||
|
usage: { total_tokens: 512 }, durationMs: 150 } })
|
||||||
|
return { sid, count: seq - 1 }
|
||||||
|
})()`
|
||||||
|
|
||||||
|
async function shoot(cdp, name) {
|
||||||
|
const shot = await cdp.call('Page.captureScreenshot', { format: 'png', fromSurface: false })
|
||||||
|
const buf = Buffer.from(shot.data, 'base64')
|
||||||
|
writeFileSync(join(OUTDIR, name), buf)
|
||||||
|
console.log(' shot', name, buf.length, 'bytes')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { child } = await bootElectron()
|
||||||
|
try {
|
||||||
|
await sleep(1500)
|
||||||
|
const cdp = await newCdp()
|
||||||
|
await cdp.call('Page.enable')
|
||||||
|
await cdp.call('Emulation.setDeviceMetricsOverride', {
|
||||||
|
width: 1400, height: 1000, deviceScaleFactor: 2, mobile: false,
|
||||||
|
})
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const ready = await cdp.evj(`!!(window.__dshRenderer && window.__dshRenderer.onSessionEvent)`)
|
||||||
|
if (ready) break
|
||||||
|
await sleep(250)
|
||||||
|
}
|
||||||
|
const seedRes = await cdp.evj(SEED)
|
||||||
|
console.log('seed:', JSON.stringify(seedRes))
|
||||||
|
await sleep(800)
|
||||||
|
|
||||||
|
// Verify fusion DOM contract before shooting so we don't waste a
|
||||||
|
// screenshot on a broken build.
|
||||||
|
const contract = await cdp.evj(`(() => {
|
||||||
|
const fused = document.querySelectorAll('.tool-block.trace-card-fused')
|
||||||
|
const standalone = document.querySelectorAll('.trace-card:not(.trace-card-fused)')
|
||||||
|
const drawers = document.querySelectorAll('.turn-trace-drawer')
|
||||||
|
const multi = document.querySelector('.tool-block.trace-card-fused[data-step-index="0"][data-step-turn="1"]')
|
||||||
|
const multiName = multi ? multi.querySelector('.tool-family-name').textContent : null
|
||||||
|
return {
|
||||||
|
fusedCount: fused.length,
|
||||||
|
standaloneTraceCount: standalone.length,
|
||||||
|
drawerCount: drawers.length,
|
||||||
|
multiCallSummary: multiName,
|
||||||
|
}
|
||||||
|
})()`)
|
||||||
|
console.log('contract:', JSON.stringify(contract))
|
||||||
|
|
||||||
|
// Make sure we're on the Chat tab so the seeded turns are on-screen,
|
||||||
|
// then scroll to the top of the stream so shot 1 captures the whole
|
||||||
|
// "collapsed fused" flow (thinking bubbles + tool row + turn footer).
|
||||||
|
await cdp.evj(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`)
|
||||||
|
await sleep(300)
|
||||||
|
await cdp.evj(`(() => {
|
||||||
|
const first = document.querySelector('.tool-block.trace-card-fused')
|
||||||
|
if (first && first.scrollIntoView) first.scrollIntoView({ block: 'center' })
|
||||||
|
})()`)
|
||||||
|
await sleep(300)
|
||||||
|
|
||||||
|
// Shot 1: collapsed
|
||||||
|
await shoot(cdp, '01-fused-collapsed.png')
|
||||||
|
|
||||||
|
// Open the first fused card + shoot expanded (args, result, panes + edit&re-run trigger visible)
|
||||||
|
await cdp.evj(`(() => {
|
||||||
|
const c = document.querySelector('.tool-block.trace-card-fused')
|
||||||
|
if (c && c.tagName === 'DETAILS') c.open = true
|
||||||
|
c && c.scrollIntoView({ block: 'center' })
|
||||||
|
})()`)
|
||||||
|
await sleep(400)
|
||||||
|
await shoot(cdp, '02-fused-expanded.png')
|
||||||
|
|
||||||
|
// Open the edit-and-re-run panel inside the same expanded fused card.
|
||||||
|
const editState = await cdp.evj(`(() => {
|
||||||
|
const c = document.querySelector('.tool-block.trace-card-fused')
|
||||||
|
if (!c) return { __err: 'no fused card' }
|
||||||
|
const trigger = c.querySelector('.tool-edit-rerun-trigger')
|
||||||
|
if (!trigger) return { __err: 'no edit trigger inside fused card' }
|
||||||
|
trigger.click()
|
||||||
|
const panel = c.querySelector('.tool-edit-rerun-panel')
|
||||||
|
return { visible: !!(panel && !panel.hidden), triggerText: trigger.textContent }
|
||||||
|
})()`)
|
||||||
|
console.log('edit-rerun:', JSON.stringify(editState))
|
||||||
|
// Scroll the whole fused card into view so the shot captures the
|
||||||
|
// summary + args/result + edit panel, not just the panel textarea.
|
||||||
|
await cdp.evj(`(() => {
|
||||||
|
const c = document.querySelector('.tool-block.trace-card-fused')
|
||||||
|
if (c && c.scrollIntoView) c.scrollIntoView({ block: 'start' })
|
||||||
|
})()`)
|
||||||
|
await sleep(300)
|
||||||
|
await shoot(cdp, '03-fused-edit-rerun.png')
|
||||||
|
} finally {
|
||||||
|
try { child.kill('SIGKILL') } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1) })
|
||||||
@@ -1444,8 +1444,21 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex
|
|||||||
const hasSignal = tf && typeof tf.specHasAnySignal === 'function'
|
const hasSignal = tf && typeof tf.specHasAnySignal === 'function'
|
||||||
? (!!tf.specHasAnySignal(rawSpec) || !!tf.specHasAnySignal(footerSpec))
|
? (!!tf.specHasAnySignal(rawSpec) || !!tf.specHasAnySignal(footerSpec))
|
||||||
: !!footerSpec
|
: !!footerSpec
|
||||||
const hasTraceCard = !!(traceCard && traceCard.parentNode)
|
// Fusion: a fused step card is already positioned inline at the tool
|
||||||
if (!hasSignal && !hasTraceCard) {
|
// call's narrative slot. Lifting it into the footer drawer would tear
|
||||||
|
// it out of the stream — treat it as "no drawer needed here". The
|
||||||
|
// turn-flow glyph already scrolls to it via `dsh-open-turn-trace`
|
||||||
|
// (see the listener below).
|
||||||
|
const isFusedCard = !!(traceCard && traceCard.dataset && traceCard.dataset.stepFused === '1')
|
||||||
|
const hasTraceCard = !isFusedCard && !!(traceCard && traceCard.parentNode)
|
||||||
|
// Fused card path: the trailing standalone .trace-card is suppressed
|
||||||
|
// (the card now lives inline at the tool call's position), so we can't
|
||||||
|
// rely on `hasTraceCard` alone. Keep the footer up when the turn has
|
||||||
|
// at least one fused step so the "turn ended" chip row + flow glyph +
|
||||||
|
// click-to-scroll behavior remain reachable.
|
||||||
|
const hasFusedStep = Array.isArray(turnSteps) && turnSteps.some(
|
||||||
|
s => s && Array.isArray(s._toolBlocks) && s._toolBlocks.length > 0)
|
||||||
|
if (!hasSignal && !hasTraceCard && !hasFusedStep) {
|
||||||
// Nothing to draw — leave the assistant-turn container clean.
|
// Nothing to draw — leave the assistant-turn container clean.
|
||||||
ct.section.dataset.turnStatus = 'sealed'
|
ct.section.dataset.turnStatus = 'sealed'
|
||||||
state.currentTurn = null
|
state.currentTurn = null
|
||||||
@@ -1493,7 +1506,7 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
let drawer = null
|
let drawer = null
|
||||||
if (traceCard && traceCard.parentNode) {
|
if (!isFusedCard && traceCard && traceCard.parentNode) {
|
||||||
// Lift the trace card out of `streamEl` into a details drawer inside
|
// Lift the trace card out of `streamEl` into a details drawer inside
|
||||||
// the footer. Preserves whatever internal state the card already has
|
// the footer. Preserves whatever internal state the card already has
|
||||||
// (chunk fold, per-line payload expander). When the tri-view module
|
// (chunk fold, per-line payload expander). When the tri-view module
|
||||||
@@ -1556,6 +1569,28 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex
|
|||||||
try { drawer.scrollIntoView({ block: 'nearest' }) } catch (_) { /* jsdom */ }
|
try { drawer.scrollIntoView({ block: 'nearest' }) } catch (_) { /* jsdom */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// Fused-only turn: no trailing drawer was built (the step card lives
|
||||||
|
// in-stream at its tool-call position). The glyph still fires, so
|
||||||
|
// fall back to opening + scrolling to the first fused card inside
|
||||||
|
// this turn's section, plus a brief `.flash-ring` accent so the eye
|
||||||
|
// tracks the jump. Matches the drawer path's "open + scroll" gesture.
|
||||||
|
footer.addEventListener('dsh-open-turn-trace', () => {
|
||||||
|
const section = ct.section
|
||||||
|
if (!section || typeof section.querySelector !== 'function') return
|
||||||
|
const fused = section.querySelector('.tool-block.trace-card-fused')
|
||||||
|
if (!fused) return
|
||||||
|
fused.open = true
|
||||||
|
if (typeof fused.scrollIntoView === 'function') {
|
||||||
|
try { fused.scrollIntoView({ block: 'nearest' }) } catch (_) { /* jsdom */ }
|
||||||
|
}
|
||||||
|
if (fused.classList && typeof fused.classList.add === 'function') {
|
||||||
|
fused.classList.add('flash-ring')
|
||||||
|
setTimeout(() => {
|
||||||
|
try { fused.classList.remove('flash-ring') } catch (_) { /* jsdom */ }
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
ct.section.appendChild(footer)
|
ct.section.appendChild(footer)
|
||||||
ct.section.dataset.turnStatus = 'sealed'
|
ct.section.dataset.turnStatus = 'sealed'
|
||||||
@@ -2041,6 +2076,13 @@ function beginTraceStep(meta, event) {
|
|||||||
inputs: meta.pendingTraceInputs || [],
|
inputs: meta.pendingTraceInputs || [],
|
||||||
outputs: [],
|
outputs: [],
|
||||||
events: [],
|
events: [],
|
||||||
|
// Tool-blocks emitted within this step's window — populated by the
|
||||||
|
// tool/call handler. finishTraceStep fuses these with the step
|
||||||
|
// meta (usage badge + duration + panes) instead of appending a
|
||||||
|
// trailing standalone .trace-card, so the tool row IS the step card
|
||||||
|
// at its narrative position. Text-only steps leave this empty and
|
||||||
|
// fall through to the historical .trace-card render.
|
||||||
|
_toolBlocks: [],
|
||||||
}
|
}
|
||||||
meta.pendingTraceInputs = []
|
meta.pendingTraceInputs = []
|
||||||
// "streaming-first": drop a placeholder card
|
// "streaming-first": drop a placeholder card
|
||||||
@@ -2123,6 +2165,20 @@ function finishTraceStep(meta, endSeq, endTime) {
|
|||||||
}
|
}
|
||||||
rec._streamingNode = null
|
rec._streamingNode = null
|
||||||
}
|
}
|
||||||
|
// Fusion path: if the step emitted any tool blocks, upgrade the first
|
||||||
|
// one into the step card in place (adds usage badge / duration / step
|
||||||
|
// marker to its summary, absorbs sibling tool-blocks, appends the
|
||||||
|
// trace panes to its body). The narrative position of the tool call
|
||||||
|
// is preserved and the trailing standalone .trace-card is not emitted.
|
||||||
|
// Text-only steps fall through to the historical renderTraceCard path.
|
||||||
|
if (Array.isArray(rec._toolBlocks) && rec._toolBlocks.length > 0) {
|
||||||
|
const fused = fuseStepIntoToolBlock(rec)
|
||||||
|
if (fused) {
|
||||||
|
meta.lastTurnTraceCard = fused
|
||||||
|
return fused
|
||||||
|
}
|
||||||
|
// fall through if fusion failed (defensive)
|
||||||
|
}
|
||||||
// Return the just-appended trace card so callers (turn/end handler)
|
// Return the just-appended trace card so callers (turn/end handler)
|
||||||
// can lift it into the turn-footer drawer.
|
// can lift it into the turn-footer drawer.
|
||||||
//
|
//
|
||||||
@@ -2142,6 +2198,128 @@ function finishTraceStep(meta, endSeq, endTime) {
|
|||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fuse the step's aggregated record (usage badge / duration / step marker
|
||||||
|
// / trace panes) into the first tool-block emitted during the step.
|
||||||
|
// The tool-block stays at its narrative position in the turn body — no
|
||||||
|
// standalone .trace-card is appended at the stream tail for tool steps.
|
||||||
|
//
|
||||||
|
// Multi-call step: the first tool-block becomes the outer card; sibling
|
||||||
|
// tool-blocks are moved inside its body (above the trace panes) and the
|
||||||
|
// summary shows `<first-tool> +N`.
|
||||||
|
//
|
||||||
|
// Returns the fused DOM node (also usable as the drawer traceCard via
|
||||||
|
// `finishTurnContainer`, though we suppress the drawer for fused steps
|
||||||
|
// so the reader doesn't see the same card twice).
|
||||||
|
function fuseStepIntoToolBlock(rec) {
|
||||||
|
const agg = window.__dshTraceAgg
|
||||||
|
if (!agg || !rec || !Array.isArray(rec._toolBlocks) || rec._toolBlocks.length === 0) return null
|
||||||
|
const first = rec._toolBlocks[0]
|
||||||
|
const el = first && first.el
|
||||||
|
if (!el || !el.querySelector) return null
|
||||||
|
const doc = el.ownerDocument || document
|
||||||
|
const summary = el.querySelector(':scope > summary')
|
||||||
|
if (!summary) return null
|
||||||
|
|
||||||
|
// Mark the block as a fused step card so callers (finishTurnContainer)
|
||||||
|
// know not to lift it into a trailing drawer + tests can select it.
|
||||||
|
el.classList.add('trace-card-fused')
|
||||||
|
el.dataset.stepFused = '1'
|
||||||
|
if (rec.startSeq !== null) el.dataset.startSeq = String(rec.startSeq)
|
||||||
|
if (rec.endSeq !== null) el.dataset.endSeq = String(rec.endSeq)
|
||||||
|
if (rec.turn !== null) el.dataset.stepTurn = String(rec.turn)
|
||||||
|
if (rec.step !== null) el.dataset.stepIndex = String(rec.step)
|
||||||
|
|
||||||
|
// Multi-call: absorb sibling tool-blocks into this card's body BEFORE
|
||||||
|
// the panes. Update the summary's tool-name to show `<first> +N`.
|
||||||
|
const extras = rec._toolBlocks.slice(1)
|
||||||
|
if (extras.length > 0) {
|
||||||
|
const nameEl = summary.querySelector('.tool-family-name')
|
||||||
|
if (nameEl && typeof first.name === 'string') {
|
||||||
|
nameEl.textContent = `${first.name} +${extras.length}`
|
||||||
|
nameEl.title = extras.map(b => b.name).join(', ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rehost each sibling tool-block as a nested `.fused-call-row` inside
|
||||||
|
// the outer card. `<details>` inside `<details>` is legal DOM; we
|
||||||
|
// keep the child tool-block's own open/close independent so per-call
|
||||||
|
// args/result stay explorable. Insertion order matches call order.
|
||||||
|
for (const extra of extras) {
|
||||||
|
if (!extra || !extra.el) continue
|
||||||
|
const child = extra.el
|
||||||
|
if (child.parentNode) child.parentNode.removeChild(child)
|
||||||
|
child.classList.add('fused-call-row')
|
||||||
|
el.appendChild(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-cluster on the summary: usage badge, duration pill, fold glyph.
|
||||||
|
// Insert BEFORE any existing `.tool-duration` / `.tool-edit-rerun-trigger`
|
||||||
|
// so the pill+glyph stay at the tail; margin-left:auto on the badge
|
||||||
|
// pushes the whole cluster right per LangSmith parity.
|
||||||
|
const stepUsage = agg.sumUsageForStep ? agg.sumUsageForStep(rec) : null
|
||||||
|
const badgeText = agg.usageBadgeText ? agg.usageBadgeText(stepUsage) : ''
|
||||||
|
const anchor = summary.querySelector('.tool-duration')
|
||||||
|
|| summary.querySelector('.tool-edit-rerun-trigger')
|
||||||
|
|| null
|
||||||
|
if (badgeText) {
|
||||||
|
const badge = doc.createElement('span')
|
||||||
|
badge.className = 'trace-usage-badge fused-usage-badge'
|
||||||
|
badge.textContent = badgeText
|
||||||
|
if (stepUsage) {
|
||||||
|
let total = 0
|
||||||
|
for (const k of ['inputTokens','outputTokens','cacheReadTokens','cacheWriteTokens','reasoningTokens']) {
|
||||||
|
const v = stepUsage[k]
|
||||||
|
if (Number.isFinite(v)) total += v
|
||||||
|
}
|
||||||
|
badge.title = tokenBreakdownTooltip(stepUsage, total)
|
||||||
|
} else {
|
||||||
|
badge.title = 'Sum of `data.usage` across every assistant/message in this step'
|
||||||
|
}
|
||||||
|
if (anchor && anchor.parentNode === summary) summary.insertBefore(badge, anchor)
|
||||||
|
else summary.appendChild(badge)
|
||||||
|
}
|
||||||
|
const dur = doc.createElement('span')
|
||||||
|
dur.className = 'trace-duration fused-duration'
|
||||||
|
dur.textContent = rec.durationMs !== null ? `${rec.durationMs}ms` : ''
|
||||||
|
if (anchor && anchor.parentNode === summary) summary.insertBefore(dur, anchor)
|
||||||
|
else summary.appendChild(dur)
|
||||||
|
// Right-side fold glyph (LangSmith parity — same ∨ marker as the
|
||||||
|
// standalone trace-card). Toggles the outer `<details>`.
|
||||||
|
const foldGlyph = doc.createElement('span')
|
||||||
|
foldGlyph.className = 'trace-card-fold-glyph fused-fold-glyph mono'
|
||||||
|
foldGlyph.setAttribute('aria-hidden', 'true')
|
||||||
|
foldGlyph.textContent = '∨'
|
||||||
|
foldGlyph.title = 'Fold / unfold this step\'s subtree'
|
||||||
|
foldGlyph.addEventListener('click', function (e) {
|
||||||
|
if (e && e.stopPropagation) e.stopPropagation()
|
||||||
|
if (e && e.preventDefault) e.preventDefault()
|
||||||
|
el.open = !el.open
|
||||||
|
})
|
||||||
|
summary.appendChild(foldGlyph)
|
||||||
|
|
||||||
|
// Body: appended AFTER the existing args/result rows so the tool's
|
||||||
|
// own detail sits above the step-aggregate panes. This mirrors the
|
||||||
|
// standalone trace-card body (meta strip + inputs/outputs/events).
|
||||||
|
const fusedBody = doc.createElement('div')
|
||||||
|
fusedBody.className = 'trace-body fused-trace-body'
|
||||||
|
fusedBody.appendChild(renderTraceStepMetaStrip(rec, stepUsage))
|
||||||
|
const stepModelName = (rec && rec.header && typeof rec.header.model === 'string' && rec.header.model)
|
||||||
|
|| modelFromRecEvents(rec)
|
||||||
|
|| null
|
||||||
|
const barCtx = (Number.isFinite(rec.startTime) && Number.isFinite(rec.durationMs) && rec.durationMs > 0)
|
||||||
|
? { startTime: rec.startTime, durationMs: rec.durationMs, stepModel: stepModelName }
|
||||||
|
: (stepModelName ? { stepModel: stepModelName } : null)
|
||||||
|
fusedBody.appendChild(renderTracePane('inputs', rec.inputs, 'events consumed by this step', barCtx))
|
||||||
|
fusedBody.appendChild(renderTracePane('outputs', rec.outputs, 'events produced by this step', barCtx))
|
||||||
|
fusedBody.appendChild(renderTracePane('events', rec.events, 'every SessionEvent inside this step', barCtx))
|
||||||
|
el.appendChild(fusedBody)
|
||||||
|
// stash the step record on the fused node so anything that walks the
|
||||||
|
// DOM for aggregates (turn-footer tri-view, QA probes) finds the same
|
||||||
|
// shape it would on a standalone .trace-card.
|
||||||
|
el._rec = rec
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
function renderTraceCard(rec) {
|
function renderTraceCard(rec) {
|
||||||
const agg = window.__dshTraceAgg
|
const agg = window.__dshTraceAgg
|
||||||
if (!agg) return null
|
if (!agg) return null
|
||||||
@@ -5042,6 +5220,23 @@ function onSessionEvent(sessionId, event) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// step/tool fusion: register the tool-block on the currently-open
|
||||||
|
// trace record so finishTraceStep can upgrade it into the step
|
||||||
|
// card in place instead of appending a trailing standalone
|
||||||
|
// .trace-card. On the FIRST tool block of the step, also retire
|
||||||
|
// the streaming placeholder — its role (marking "step in flight")
|
||||||
|
// is now filled by the tool-block itself.
|
||||||
|
const rec = meta.currentTraceRecord
|
||||||
|
if (rec && Array.isArray(rec._toolBlocks) && toolBlockEl) {
|
||||||
|
if (rec._toolBlocks.length === 0 && rec._streamingNode) {
|
||||||
|
if (typeof rec._streamingNode.remove === 'function') rec._streamingNode.remove()
|
||||||
|
else if (rec._streamingNode.parentNode && rec._streamingNode.parentNode.removeChild) {
|
||||||
|
rec._streamingNode.parentNode.removeChild(rec._streamingNode)
|
||||||
|
}
|
||||||
|
rec._streamingNode = null
|
||||||
|
}
|
||||||
|
rec._toolBlocks.push({ callId, name, el: toolBlockEl })
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'tool/result': {
|
case 'tool/result': {
|
||||||
|
|||||||
@@ -10404,6 +10404,18 @@ textarea:focus-visible {
|
|||||||
animation: traceDeepLinkFlash 1.4s ease-out;
|
animation: traceDeepLinkFlash 1.4s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fused step card fallback: when a turn's only trace lives as a fused
|
||||||
|
* tool-block (no trailing drawer), the turn-flow glyph click scrolls
|
||||||
|
* to the card and briefly rings it so the eye tracks the jump. Runs
|
||||||
|
* for ~2s, then the JS strips the class. */
|
||||||
|
@keyframes flashRing {
|
||||||
|
0% { box-shadow: 0 0 0 2px var(--accent, #4c9aff); }
|
||||||
|
100% { box-shadow: 0 0 0 2px transparent; }
|
||||||
|
}
|
||||||
|
.flash-ring {
|
||||||
|
animation: flashRing 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Task #203: full-session trace overlay (opened from Devtools drawer). */
|
/* Task #203: full-session trace overlay (opened from Devtools drawer). */
|
||||||
.devtools-full-trace-overlay {
|
.devtools-full-trace-overlay {
|
||||||
position: fixed; inset: 0; z-index: 40;
|
position: fixed; inset: 0; z-index: 40;
|
||||||
@@ -12904,3 +12916,58 @@ button.artifact-version:hover {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
* Step / tool-block fusion (feat/step-card-merge, 2026-07-19).
|
||||||
|
*
|
||||||
|
* A step whose outputs include tool/call events no longer appends a
|
||||||
|
* trailing standalone .trace-card at the stream tail — instead, the
|
||||||
|
* step meta (usage badge / duration / trace panes) is fused INTO the
|
||||||
|
* first tool-block emitted during the step, right where the call
|
||||||
|
* originally landed in the narrative flow. See fuseStepIntoToolBlock
|
||||||
|
* in renderer.js for the DOM shape.
|
||||||
|
*
|
||||||
|
* Visual grammar (Lane A, LangSmith parity):
|
||||||
|
* collapsed = one low-key row → [glyph][name][gist] … [usage][dur][∨]
|
||||||
|
* expanded = args + result + edit&re-run panel + inputs/outputs/events
|
||||||
|
* panes stacked in one card, single left border.
|
||||||
|
* ----------------------------------------------------------------------- */
|
||||||
|
.tool-block.trace-card-fused > summary .fused-usage-badge {
|
||||||
|
/* Anchor the right-cluster on the fused summary — mirrors the standalone
|
||||||
|
* .trace-card's `margin-left:auto` on .trace-usage-badge. */
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.tool-block.trace-card-fused > summary .fused-duration {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.tool-block.trace-card-fused > summary .fused-fold-glyph {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
.tool-block.trace-card-fused[open] > summary .fused-fold-glyph {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
/* Panes appear last inside the fused card body; add a hairline separator
|
||||||
|
* so the reader's eye splits "this call" from "the whole step". */
|
||||||
|
.tool-block.trace-card-fused > .fused-trace-body {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
/* Nested tool-blocks for multi-call steps: reduce visual weight so the
|
||||||
|
* outer card owns the frame. Fused-call-row keeps its own detail toggle
|
||||||
|
* and edit&re-run affordance. */
|
||||||
|
.tool-block.trace-card-fused .fused-call-row {
|
||||||
|
margin: 4px 0 4px 12px;
|
||||||
|
border-left-width: 1px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* edit & re-run affordance is only visible while the tool-block is open
|
||||||
|
* (design confirm §a: no hover-only reveal, keep the collapsed row
|
||||||
|
* quiet). */
|
||||||
|
.tool-block:not([open]) > summary .tool-edit-rerun-trigger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
253
examples/desktop/test/renderer-step-tool-fusion.test.js
Normal file
253
examples/desktop/test/renderer-step-tool-fusion.test.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// feat/step-card-merge (2026-07-19): the tool call row and the trailing
|
||||||
|
// standalone step card get fused into a single unit at the tool call's
|
||||||
|
// narrative position. This covers the DOM contract fuseStepIntoToolBlock
|
||||||
|
// (renderer.js) produces + the finishTurnContainer guard that stops the
|
||||||
|
// fused card from getting lifted into a trailing turn-trace-drawer.
|
||||||
|
//
|
||||||
|
// Related design memo: the assistant turn's chat stream should show ONE
|
||||||
|
// entry per tool call (the tool-block, now upgraded to a step card),
|
||||||
|
// not two — the historical grammar dropped a `.tool-block` at the call
|
||||||
|
// position AND a `.trace-card` at the stream tail. The trailing card is
|
||||||
|
// suppressed for tool-only steps; text-only steps still emit it.
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
const { loadRenderer } = require('./renderer-harness.js')
|
||||||
|
|
||||||
|
// Small helpers: the test harness has a naive selector matcher that
|
||||||
|
// doesn't grok combinators or `:scope`. Walk children explicitly.
|
||||||
|
function directChild(el, predicate) {
|
||||||
|
if (!el || !el.children) return null
|
||||||
|
for (const c of el.children) if (predicate(c)) return c
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function directSummary(el) {
|
||||||
|
return directChild(el, c => c && c.tagName === 'SUMMARY')
|
||||||
|
}
|
||||||
|
function firstDescendant(el, cls) {
|
||||||
|
return el && el.querySelector ? el.querySelector('.' + cls) : null
|
||||||
|
}
|
||||||
|
function playStream(renderer, sid, events) {
|
||||||
|
for (const ev of events) renderer.onSessionEvent(sid, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-tool step in a single-step turn. Should produce:
|
||||||
|
// - one .tool-block (also .trace-card-fused, data-step-fused=1)
|
||||||
|
// - zero standalone .trace-card
|
||||||
|
// - one .turn-footer with the flow glyph but NO .turn-trace-drawer
|
||||||
|
function makeSingleToolTurn() {
|
||||||
|
return [
|
||||||
|
{ seq: 1, time: 1000, type: 'turn/start', data: {} },
|
||||||
|
{ seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } },
|
||||||
|
{ seq: 3, time: 1020, type: 'tool/call', data: {
|
||||||
|
callId: 'c-a', name: 'read', arguments: JSON.stringify({ path: 'foo.ts' }),
|
||||||
|
} },
|
||||||
|
{ seq: 4, time: 1080, type: 'tool/result', data: {
|
||||||
|
callId: 'c-a', content: [{ type: 'text', text: 'file contents' }],
|
||||||
|
} },
|
||||||
|
// usage rides on assistant/message; include one so the fused summary
|
||||||
|
// gets a usage badge. Real DeepSeek traffic always emits at least one
|
||||||
|
// assistant/message per step (the model reply that triggers the call).
|
||||||
|
{ seq: 5, time: 1100, type: 'assistant/message', data: {
|
||||||
|
content: [{ type: 'text', text: 'looking at foo.ts' }],
|
||||||
|
usage: { inputTokens: 40, outputTokens: 12, cacheReadTokens: 200 },
|
||||||
|
} },
|
||||||
|
{ seq: 6, time: 1120, type: 'step/end', data: {} },
|
||||||
|
{ seq: 7, time: 1125, type: 'turn/end', data: { reason: 'completed' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
test('fused: single tool step upgrades its tool-block into the step card', async () => {
|
||||||
|
const { renderer, document } = await loadRenderer()
|
||||||
|
renderer.ensureSession('s-fuse', { title: 't', header: {} })
|
||||||
|
await renderer.selectSession('s-fuse')
|
||||||
|
playStream(renderer, 's-fuse', makeSingleToolTurn())
|
||||||
|
|
||||||
|
const fused = document.querySelectorAll('.tool-block.trace-card-fused')
|
||||||
|
assert.equal(fused.length, 1, 'exactly one fused tool-block acting as the step card')
|
||||||
|
const el = fused[0]
|
||||||
|
assert.equal(el.dataset.stepFused, '1', 'stepFused marker present for downstream QA/tests')
|
||||||
|
|
||||||
|
// Trailing standalone .trace-card is suppressed for a tool-only step.
|
||||||
|
const standalone = document.querySelectorAll('.trace-card:not(.trace-card-fused)')
|
||||||
|
assert.equal(standalone.length, 0, 'no trailing standalone .trace-card for tool-only step')
|
||||||
|
|
||||||
|
// Streaming placeholder must be gone (retired on first tool/call).
|
||||||
|
const placeholders = document.querySelectorAll('.trace-card-streaming')
|
||||||
|
assert.equal(placeholders.length, 0, 'streaming placeholder retired')
|
||||||
|
|
||||||
|
// Summary carries the fused usage badge + duration pill + fold glyph.
|
||||||
|
const summary = directSummary(el)
|
||||||
|
assert.ok(summary, 'fused card has a summary')
|
||||||
|
const summaryChildren = summary.children.map(c => c.className)
|
||||||
|
assert.ok(summaryChildren.some(cn => cn.includes('fused-usage-badge')),
|
||||||
|
'usage badge attached to fused summary; got children ' + JSON.stringify(summaryChildren))
|
||||||
|
const dur = directChild(summary, c => c.className && c.className.includes('fused-duration'))
|
||||||
|
assert.ok(dur && /ms$/.test(dur.textContent), 'duration pill shows ms (got ' + (dur && dur.textContent) + ')')
|
||||||
|
assert.ok(directChild(summary, c => c.className && c.className.includes('fused-fold-glyph')),
|
||||||
|
'right-side fold glyph present')
|
||||||
|
|
||||||
|
// Fused body includes the meta strip + three trace panes.
|
||||||
|
const body = directChild(el, c => c.className && c.className.includes('fused-trace-body'))
|
||||||
|
assert.ok(body, 'fused trace body appended inside the tool-block')
|
||||||
|
assert.ok(firstDescendant(body, 'trace-step-meta'), 'step meta strip lives in fused body')
|
||||||
|
const panes = Array.from(body.querySelectorAll('.trace-pane'))
|
||||||
|
assert.ok(panes.length >= 3, 'three panes rendered inside fused body — got ' + panes.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fused: single tool step does not build a trailing turn-trace-drawer', async () => {
|
||||||
|
const { renderer, document } = await loadRenderer()
|
||||||
|
renderer.ensureSession('s-drw', { title: 't', header: {} })
|
||||||
|
await renderer.selectSession('s-drw')
|
||||||
|
playStream(renderer, 's-drw', makeSingleToolTurn())
|
||||||
|
|
||||||
|
const drawers = document.querySelectorAll('.turn-trace-drawer')
|
||||||
|
assert.equal(drawers.length, 0,
|
||||||
|
'fused card stays inline — footer drawer is suppressed for tool-only steps')
|
||||||
|
|
||||||
|
// Turn footer still exists so the "turn ended" glyph + chips are reachable.
|
||||||
|
const footers = document.querySelectorAll('.turn-footer')
|
||||||
|
assert.equal(footers.length, 1, 'turn footer preserved (glyph + optional chips)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fused: multi-tool step lists sibling calls inside the fused card + summary shows "<first> +N"', async () => {
|
||||||
|
const { renderer, document } = await loadRenderer()
|
||||||
|
renderer.ensureSession('s-mul', { title: 't', header: {} })
|
||||||
|
await renderer.selectSession('s-mul')
|
||||||
|
const events = [
|
||||||
|
{ seq: 1, time: 1000, type: 'turn/start', data: {} },
|
||||||
|
{ seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } },
|
||||||
|
{ seq: 3, time: 1010, type: 'tool/call', data: {
|
||||||
|
callId: 'c-a', name: 'read', arguments: JSON.stringify({ path: 'a.ts' }),
|
||||||
|
} },
|
||||||
|
{ seq: 4, time: 1015, type: 'tool/call', data: {
|
||||||
|
callId: 'c-b', name: 'grep', arguments: JSON.stringify({ pattern: 'foo' }),
|
||||||
|
} },
|
||||||
|
{ seq: 5, time: 1020, type: 'tool/call', data: {
|
||||||
|
callId: 'c-c', name: 'bash', arguments: JSON.stringify({ cmd: 'ls' }),
|
||||||
|
} },
|
||||||
|
{ seq: 6, time: 1050, type: 'tool/result', data: { callId: 'c-a', content: [{ type: 'text', text: '.' }] } },
|
||||||
|
{ seq: 7, time: 1051, type: 'tool/result', data: { callId: 'c-b', content: [{ type: 'text', text: '.' }] } },
|
||||||
|
{ seq: 8, time: 1052, type: 'tool/result', data: { callId: 'c-c', content: [{ type: 'text', text: '.' }] } },
|
||||||
|
{ seq: 9, time: 1070, type: 'assistant/message', data: {
|
||||||
|
content: [{ type: 'text', text: 'ok' }],
|
||||||
|
usage: { inputTokens: 20, outputTokens: 5 },
|
||||||
|
} },
|
||||||
|
{ seq: 10, time: 1080, type: 'step/end', data: {} },
|
||||||
|
{ seq: 11, time: 1085, type: 'turn/end', data: { reason: 'completed' } },
|
||||||
|
]
|
||||||
|
playStream(renderer, 's-mul', events)
|
||||||
|
|
||||||
|
// Exactly one fused card, three tool-blocks total (first outer + two absorbed).
|
||||||
|
const fused = document.querySelectorAll('.tool-block.trace-card-fused')
|
||||||
|
assert.equal(fused.length, 1, 'multi-call step still fuses into one outer card')
|
||||||
|
const outer = fused[0]
|
||||||
|
assert.equal(outer.getAttribute('data-tool-name'), 'read', 'outer card is the first tool by call order')
|
||||||
|
// Absorbed sibling blocks live inside the outer card with the marker class.
|
||||||
|
const absorbed = outer.querySelectorAll('.fused-call-row')
|
||||||
|
assert.equal(absorbed.length, 2, 'two sibling tool-blocks absorbed into the fused card')
|
||||||
|
const absorbedNames = Array.from(absorbed).map(el => el.getAttribute('data-tool-name'))
|
||||||
|
assert.deepEqual(absorbedNames, ['grep', 'bash'], 'absorbed rows retain their tool-name in call order')
|
||||||
|
|
||||||
|
// Summary shows `<first> +N` with the total-N excluding the outer.
|
||||||
|
const summary = directSummary(outer)
|
||||||
|
const nameEl = directChild(summary, c => c.className && c.className.includes('tool-family-name'))
|
||||||
|
assert.ok(nameEl && nameEl.textContent === 'read +2',
|
||||||
|
'summary tool-name reads "<first> +N" — got ' + (nameEl && nameEl.textContent))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fused: text-only step (no tool/call) still emits a standalone trace-card', async () => {
|
||||||
|
// Guardrail: the fusion path is opt-in — a step without any tool/call
|
||||||
|
// must retain the historical trailing .trace-card so text-only reasoning
|
||||||
|
// remains reachable via the turn-footer drawer.
|
||||||
|
const { renderer, document } = await loadRenderer()
|
||||||
|
renderer.ensureSession('s-txt', { title: 't', header: {} })
|
||||||
|
await renderer.selectSession('s-txt')
|
||||||
|
const events = [
|
||||||
|
{ seq: 1, time: 1000, type: 'turn/start', data: {} },
|
||||||
|
{ seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } },
|
||||||
|
{ seq: 3, time: 1050, type: 'assistant/message', data: {
|
||||||
|
content: [{ type: 'text', text: 'a plain response' }],
|
||||||
|
usage: { inputTokens: 5, outputTokens: 2 },
|
||||||
|
} },
|
||||||
|
{ seq: 4, time: 1080, type: 'step/end', data: {} },
|
||||||
|
{ seq: 5, time: 1085, type: 'turn/end', data: { reason: 'completed' } },
|
||||||
|
]
|
||||||
|
playStream(renderer, 's-txt', events)
|
||||||
|
|
||||||
|
const fused = document.querySelectorAll('.tool-block.trace-card-fused')
|
||||||
|
assert.equal(fused.length, 0, 'no fused card without a tool/call')
|
||||||
|
// The standalone trace-card gets lifted into the turn drawer at turn/end.
|
||||||
|
// Look at the drawer for its presence.
|
||||||
|
const drawer = document.querySelector('.turn-trace-drawer')
|
||||||
|
assert.ok(drawer, 'text-only step still emits a trailing trace-card lifted into the drawer')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Glyph fallback contract: a turn whose only trace lives as a fused
|
||||||
|
// tool-block (no trailing drawer built) MUST still respond to the
|
||||||
|
// turn-flow-glyph's `dsh-open-turn-trace` event — open the fused card
|
||||||
|
// + scroll it into view + briefly ring it with `.flash-ring` so the
|
||||||
|
// reader tracks the jump. Without this the glyph would be a silent
|
||||||
|
// no-op on tool-only turns.
|
||||||
|
test('fused: glyph fallback opens + scrolls to + flash-rings the fused card when no drawer exists', async () => {
|
||||||
|
const { renderer, document } = await loadRenderer()
|
||||||
|
renderer.ensureSession('s-glyph', { title: 't', header: {} })
|
||||||
|
await renderer.selectSession('s-glyph')
|
||||||
|
playStream(renderer, 's-glyph', makeSingleToolTurn())
|
||||||
|
|
||||||
|
// Sanity: drawer suppressed, fused card present, footer built.
|
||||||
|
assert.equal(document.querySelectorAll('.turn-trace-drawer').length, 0,
|
||||||
|
'precondition: no drawer built for fused-only turn')
|
||||||
|
const footer = document.querySelector('.turn-footer')
|
||||||
|
assert.ok(footer, 'turn footer built')
|
||||||
|
const fused = document.querySelector('.tool-block.trace-card-fused')
|
||||||
|
assert.ok(fused, 'fused card exists')
|
||||||
|
|
||||||
|
// Track scroll invocation on the fused card so we can assert the
|
||||||
|
// fallback actually reached the scroll step.
|
||||||
|
let scrolled = 0
|
||||||
|
fused.scrollIntoView = function () { scrolled += 1 }
|
||||||
|
fused.open = false
|
||||||
|
|
||||||
|
// Fire the glyph's event through the harness's listener hook.
|
||||||
|
assert.equal(typeof footer._fire, 'function',
|
||||||
|
'harness footer exposes _fire for synthetic event dispatch')
|
||||||
|
footer._fire('dsh-open-turn-trace', {})
|
||||||
|
|
||||||
|
assert.equal(fused.open, true, 'fused card opened by fallback')
|
||||||
|
assert.equal(scrolled, 1, 'fallback scrolled the fused card into view')
|
||||||
|
assert.ok(fused.classList.contains('flash-ring'),
|
||||||
|
'fallback applied .flash-ring accent for 2s visual cue')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Twin of the above: when a text-only step already built a drawer,
|
||||||
|
// the fallback path must NOT fire — the historical drawer-open path
|
||||||
|
// remains authoritative. Guards against a regression where both
|
||||||
|
// listeners run and the fused-selector accidentally matches something.
|
||||||
|
test('fused: text-only turn keeps the drawer path — no flash-ring on the stream', async () => {
|
||||||
|
const { renderer, document } = await loadRenderer()
|
||||||
|
renderer.ensureSession('s-txt2', { title: 't', header: {} })
|
||||||
|
await renderer.selectSession('s-txt2')
|
||||||
|
playStream(renderer, 's-txt2', [
|
||||||
|
{ seq: 1, time: 1000, type: 'turn/start', data: {} },
|
||||||
|
{ seq: 2, time: 1005, type: 'step/start', data: { turn: 0, step: 0 } },
|
||||||
|
{ seq: 3, time: 1050, type: 'assistant/message', data: {
|
||||||
|
content: [{ type: 'text', text: 'a plain response' }],
|
||||||
|
usage: { inputTokens: 5, outputTokens: 2 },
|
||||||
|
} },
|
||||||
|
{ seq: 4, time: 1080, type: 'step/end', data: {} },
|
||||||
|
{ seq: 5, time: 1085, type: 'turn/end', data: { reason: 'completed' } },
|
||||||
|
])
|
||||||
|
const footer = document.querySelector('.turn-footer')
|
||||||
|
const drawer = document.querySelector('.turn-trace-drawer')
|
||||||
|
assert.ok(footer && drawer, 'precondition: drawer path in play')
|
||||||
|
drawer.open = false
|
||||||
|
footer._fire('dsh-open-turn-trace', {})
|
||||||
|
assert.equal(drawer.open, true, 'drawer opened by drawer path')
|
||||||
|
// No fused card to ring — the accent class must not be applied
|
||||||
|
// anywhere in the tree.
|
||||||
|
const ringed = document.querySelectorAll('.flash-ring')
|
||||||
|
assert.equal(ringed.length, 0, 'flash-ring not applied on drawer-path turn')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user