feat(desktop): cordis dedicated card (mount/unmount/inspect) + nested code-dispatch tree

This commit is contained in:
ZiyaZhang
2026-07-20 02:57:55 -07:00
parent 067691a847
commit 64397f5b39
14 changed files with 1599 additions and 16 deletions

View File

@@ -327,6 +327,23 @@ becomes a visualiser of the runtime's actual state.
render a terminal card with a scrollback pane. Both fall back to the
generic collapsible-details view if the `meta` discriminant is
missing.
- **Cordis self-modification card.** The three self-referential tools
(`cordis_mount` / `cordis_unmount` / `cordis_inspect`) — the model
editing its own live runtime — get a purpose-built card instead of
raw text. Mount shows the `{id, name, state}` of the new entry plus a
`entry` delta (and the awaited services when a plugin is pending);
unmount shows the removed id with a `entry` delta; inspect renders
the returned runtime inventory (services / plugins / tools / dynamic /
api / events) through the shared collapsible Fields tree. All three
are parsed from the tool's own plain-text result (they carry a
`generic` render intent), so the card never fabricates a shape the
runtime didn't emit — an unrecognised text falls back to raw.
- **Nested code-dispatch tree.** When Code Mode (`run_code`) fans out to
sub-tool calls, each sub-call appears as an expandable row inside the
parent's result box: collapsed it keeps the one-line
`└─ ✓ name summary` shape; expanded it reveals that sub-call's
arguments and result, and carries its own `{ }` inspector badge
anchored to the sub-call event.
- **Fork & edit-rerun.** Every assistant bubble has a hover-revealed
**fork from here** button; use it to mint a child session seeded at
that exact seq, edit the user turn, and let a different reply

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -0,0 +1,305 @@
// QA verification script for lane-cordis-card. Boots an isolated Electron on
// its own CDP port, drives the cordis dedicated card + the upgraded
// code-dispatch nested tree entirely through the renderer's
// `window.__dshRenderer.onSessionEvent` seam (no live model / no key), and
// captures three screenshots into docs/qa-cordis-card/:
//
// 01-mount-card.png — cordis_mount card: op glyph + id + kv block
// (id/name/state) + dyn-1 add-delta + source fold.
// 02-inspect-fields.png — cordis_inspect card: the six sections rendered
// through the reused Fields-tree widget.
// 03-code-dispatch-open.png — a run_code block with three fan-out sub-call
// rows, the first row expanded to show its args +
// result blocks, and the { } inspector badge.
//
// Isolation follows the 2026-07-18 postmortem baked into the sibling shoots
// (scripts/qa-cdp-shoot-msg-queue.mjs / nav-optional):
// 1. --user-data-dir=<tmp> isolates Chromium userdata.
// 2. DSH_DESKTOP_HOME=<tmp> isolates the main-process config root.
// 3. own CDP port (≥9310) so a lingering Electron helper from another
// lane's shoot can't hijack our DevTools endpoint.
//
// Why the seam and not a real Vibe run: the cordis card + code-dispatch tree
// are pure renderer concerns (parse the tool's own text / fan-out rows). We
// inject the REAL wire shapes from test/fixtures/cordis-wire-shapes.json
// through onSessionEvent — the exact path the live wire uses — so the render
// is deterministic with no daemon round-trip and no model key.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, statSync } 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_CORDIS_PORT || 9310)
const OUTDIR = join(WORKTREE, 'docs/qa-cordis-card')
const FIX = JSON.parse(readFileSync(join(WORKTREE, 'test/fixtures/cordis-wire-shapes.json'), 'utf8'))
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
function seedHome(dshHome) {
const seedOverlay = [
'# QA cordis-card shoot seed overlay (tmp, per-run).',
'plugins:',
` - "@cordisjs/plugin-include":`,
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
'',
].join('\n')
writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay)
writeFileSync(join(dshHome, 'config.json'), JSON.stringify({ role: 'coding', approvalMode: 'never' }, null, 2))
writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString())
}
async function bootElectron(dshHome, userData, port) {
const child = spawn(ELECTRON, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userData}`,
'--disable-gpu',
'--no-sandbox',
'.',
], {
cwd: WORKTREE,
env: { ...process.env, DSH_DESKTOP_HOME: dshHome, DSH_MAXIMIZE: '1' },
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:${port}/json/list`)
if (r.ok) return { child, logs }
} catch {}
}
child.kill('SIGKILL')
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
process.exit(3)
}
async function newCdp(port) {
const targets = await (await fetch(`http://localhost:${port}/json/list`)).json()
const target = targets.find(t => t.type === 'page')
if (!target) throw new Error('no page target on port ' + 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 data = typeof ev.data === 'string' ? ev.data : String(ev.data)
let msg
try { msg = JSON.parse(data) } catch { return }
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 }
}
// Clip to the chat stream so the cards are unmistakably in frame.
async function shoot(call, evj, name) {
const clip = await evj(`
(() => {
const s = document.getElementById('stream')
if (!s) return null
const r = s.getBoundingClientRect()
return { x: r.x, y: Math.max(0, r.y), width: r.width, height: Math.min(r.height, 900), scale: 1 }
})()
`)
const shotArgs = { format: 'png', captureBeyondViewport: true }
if (clip) shotArgs.clip = clip
const shot = await call('Page.captureScreenshot', shotArgs, 30000)
if (!shot || !shot.data) throw new Error('captureScreenshot returned no data')
const outPath = join(OUTDIR, name)
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
const bytes = statSync(outPath).size
console.log(` wrote ${outPath} (${bytes} bytes)`)
if (bytes < 20000) throw new Error(`screenshot ${name} is only ${bytes} bytes (<20KB) — likely blank`)
return { path: outPath, bytes }
}
// Inject a call+result pair for a cordis tool through onSessionEvent.
function injectPairExpr(sid, pair) {
return `
(() => {
const R = window.__dshRenderer
R.onSessionEvent(${JSON.stringify(sid)}, ${JSON.stringify(pair.call)})
R.onSessionEvent(${JSON.stringify(sid)}, ${JSON.stringify(pair.result)})
return true
})()
`
}
// Open the tool-block that hosts a given cordis op + scroll it into view so
// the card body (not the collapsed summary) fills the shot. For inspect, also
// expand the first section in the reused Fields tree so the list reads richly.
async function openCordisBlock(evj, op) {
return evj(`
(() => {
const card = document.querySelector('.card-cordis[data-cordis-op="${op}"]')
if (!card) return { opened: false }
const block = card.closest ? card.closest('.tool-block') : null
if (block) block.open = true
// Expand the first Fields-tree section for inspect so it isn't all folded.
const firstBranch = card.querySelector('.card-cordis-tree .trace-detail-json-branch')
if (firstBranch) firstBranch.open = true
;(block || card).scrollIntoView({ block: 'center' })
return { opened: true }
})()
`)
}
async function main() {
const dshHome = join(tmpdir(), 'dsh-cordis-home')
const userData = join(tmpdir(), 'dsh-cordis-userdata')
for (const dir of [dshHome, userData]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
seedHome(dshHome)
console.log(`booting on CDP :${CDP_PORT}`)
const { child } = await bootElectron(dshHome, userData, CDP_PORT)
try {
await sleep(1500)
const { call, evj } = await newCdp(CDP_PORT)
await call('Page.enable')
// Fresh session for the cordis cards.
const setup = await evj(`
(async () => {
const R = window.__dshRenderer
if (!R) return { __err: 'no __dshRenderer seam' }
if (!window.__dshCordisCard) return { __err: 'no __dshCordisCard module' }
R.ensureSession('qa-cordis', { title: 'Cordis card demo', header: {}, hasUserMessage: true })
await R.selectSession('qa-cordis')
return { active: R.getActiveSessionId() }
})()
`)
console.log(' setup:', JSON.stringify(setup))
if (setup && setup.__err) throw new Error(setup.__err)
// --- Shot 1: mount card -------------------------------------------------
const mountOk = await evj(injectPairExpr('qa-cordis', FIX.mount_ok))
if (mountOk && mountOk.__err) throw new Error(mountOk.__err)
const mountCheck = await evj(`
(() => {
const c = document.querySelector('.card-cordis[data-cordis-op="cordis_mount"]')
if (!c) return { found: false }
return {
found: true,
id: (c.querySelector('.card-cordis-id') || {}).textContent || '',
kvKeys: Array.from(c.querySelectorAll('.card-cordis-kv-key')).map(n => n.textContent),
delta: !!c.querySelector('.card-cordis-delta.add'),
codeFold: !!c.querySelector('.card-cordis-code'),
}
})()
`)
console.log(' mount card:', JSON.stringify(mountCheck))
if (!mountCheck.found || mountCheck.id !== 'dyn-1' || !mountCheck.delta) {
throw new Error('mount card missing expected shape: ' + JSON.stringify(mountCheck))
}
// Open the enclosing tool-block + scroll it into view so the card body
// (not just the collapsed summary) is unmistakably in frame.
await openCordisBlock(evj, 'cordis_mount')
await sleep(250)
const shot1 = await shoot(call, evj, '01-mount-card.png')
// --- Shot 2: inspect card with fields tree ------------------------------
const inspectOk = await evj(injectPairExpr('qa-cordis', FIX.inspect_all))
if (inspectOk && inspectOk.__err) throw new Error(inspectOk.__err)
const inspectCheck = await evj(`
(() => {
const c = document.querySelector('.card-cordis[data-cordis-op="cordis_inspect"]')
if (!c) return { found: false }
const tree = c.querySelector('.card-cordis-tree .trace-detail-json-tree')
return {
found: true,
hasTree: !!tree,
sections: tree ? Array.from(tree.querySelectorAll('.trace-detail-json-key')).map(n => n.textContent) : [],
}
})()
`)
console.log(' inspect card:', JSON.stringify(inspectCheck))
if (!inspectCheck.found || !inspectCheck.hasTree) {
throw new Error('inspect card missing fields tree: ' + JSON.stringify(inspectCheck))
}
await openCordisBlock(evj, 'cordis_inspect')
await sleep(250)
const shot2 = await shoot(call, evj, '02-inspect-fields.png')
// --- Shot 3: code-dispatch nested tree, first row expanded --------------
const cd = FIX.code_dispatch_run
const dispatchInject = await evj(`
(() => {
const R = window.__dshRenderer
const sid = 'qa-cordis'
// Order matches mock-fixtures.mockCodeDispatch: the parent call+result
// land first (the result populates the .result box), THEN each
// tool/code-dispatch appends into it. Firing dispatches before the
// result would let the generic tool/result branch overwrite the box.
R.onSessionEvent(sid, ${JSON.stringify(cd.parent_call)})
R.onSessionEvent(sid, ${JSON.stringify(cd.parent_result)})
${cd.dispatches.map(d => `R.onSessionEvent(sid, ${JSON.stringify(d)})`).join('\n ')}
// Expand the first sub-call row so the args + result blocks show.
const parent = document.querySelector('.tool-block[data-call-id="run-code-1"]')
const firstRow = parent && parent.querySelector('.card-code-dispatch-row')
if (firstRow) firstRow.open = true
return {
rows: parent ? parent.querySelectorAll('.card-code-dispatch-row').length : 0,
firstOpen: firstRow ? !!firstRow.open : false,
badges: parent ? parent.querySelectorAll('.card-code-dispatch-summary-line .inspect-badge').length : 0,
detailBlocks: firstRow ? firstRow.querySelectorAll('.card-code-dispatch-detail-block').length : 0,
}
})()
`)
console.log(' code-dispatch:', JSON.stringify(dispatchInject))
if (!dispatchInject || dispatchInject.rows !== 3 || !dispatchInject.firstOpen || dispatchInject.badges !== 3) {
throw new Error('code-dispatch tree missing expected shape: ' + JSON.stringify(dispatchInject))
}
// Scroll the run_code block into view before shooting.
await evj(`
(() => {
const p = document.querySelector('.tool-block[data-call-id="run-code-1"]')
if (p) { p.open = true; p.scrollIntoView({ block: 'center' }) }
return true
})()
`)
await sleep(300)
const shot3 = await shoot(call, evj, '03-code-dispatch-open.png')
console.log('\n--- SUMMARY ---')
console.log(`mount card: ${shot1.path} (${shot1.bytes} bytes)`)
console.log(`inspect fields: ${shot2.path} (${shot2.bytes} bytes)`)
console.log(`code-dispatch open:${shot3.path} (${shot3.bytes} bytes)`)
} finally {
try { child.kill('SIGKILL') } catch {}
for (let i = 0; i < 20; i++) {
await sleep(500)
try { await fetch(`http://localhost:${CDP_PORT}/json/list`) } catch { break }
}
}
}
main().catch(err => { console.error(err); process.exit(1) }).then(() => process.exit(0))

View File

@@ -0,0 +1,321 @@
// Dedicated tool card for the self-referential cordis toolset (cordis_mount /
// cordis_unmount / cordis_inspect): the "model modifies its own runtime"
// flagship capability. Renders a purpose-built card instead of the generic
// raw-text result the family would otherwise fall through to.
//
// Wire truth (verified against packages/cordis/tool-cordis/src in the sibling
// runtime repo): all three tools declare a `generic` render intent and return
// PLAIN-TEXT content blocks — there is no structured result object on the
// wire. So this card PARSES the tool's own completed text, never fabricating a
// shape the runtime didn't emit:
// mount → `mounted dyn-1 (plugin "name", state: ACTIVE[ — waiting for service(s): …])`
// unmount → `unmounted dyn-1 (plugin "name")`
// inspect → markdown-ish `## section\n- item\n…` blocks (six sections, or one when `what` is set)
// Error results arrive with isError=true and the thrown Error's message as the
// text; we surface that verbatim rather than guessing an operation shape.
//
// Everything textual goes through `textContent`; no `innerHTML` from any
// tool-controlled field — same safety edge as tool-cards.js / widgets.js.
//
// Detection is by tool name only (the family map in tool-cards.js already
// identifies the three names); the renderer keeps its `meta.card` switch
// untouched for every other tool, and an unknown cordis-family name falls
// back to the generic renderer.
'use strict'
;(function () {
// The three model-visible cordis tool names. Kept local (not imported from
// TOOL_FAMILIES) so this module stands alone under node:test without loading
// the whole tool-cards surface; the two lists are asserted consistent in the
// unit tests.
const CORDIS_TOOLS = Object.freeze(['cordis_mount', 'cordis_unmount', 'cordis_inspect'])
// Per-operation header glyph (single monochrome column, matching the family
// glyph convention in tool-cards.js). mount/unmount/inspect read as
// add-to-runtime / remove-from-runtime / read-runtime.
const OP_GLYPH = Object.freeze({
cordis_mount: '⊕', // ⊕
cordis_unmount: '⊖', // ⊖
cordis_inspect: '⊙', // ⊙
})
/**
* Whether a tool name is one of the three cordis self-inspection tools.
* @param {string} name
* @returns {boolean}
*/
function isCordisTool(name) {
return typeof name === 'string' && CORDIS_TOOLS.indexOf(name) !== -1
}
/**
* Parse a `cordis_mount` success text into its fields. Returns null when the
* text doesn't match the mount shape (e.g. it's an error message) so the
* caller can fall back to raw text.
* Shape: `mounted <id> (plugin "<name>", state: <STATE>[ — waiting for service(s): a, b (activates when provided)])`
* @param {string} text
* @returns {{ id: string, pluginName: string, state: string, waiting: string[] } | null}
*/
function parseMountResult(text) {
if (typeof text !== 'string') return null
const m = text.match(/^mounted\s+(\S+)\s+\(plugin\s+"([^"]*)",\s+state:\s+(\w+)/)
if (!m) return null
const waitingMatch = text.match(/waiting for service\(s\):\s+([^(]+?)\s*\(activates when provided\)/)
const waiting = waitingMatch
? waitingMatch[1].split(',').map((s) => s.trim()).filter(Boolean)
: []
return { id: m[1], pluginName: m[2], state: m[3], waiting }
}
/**
* Parse a `cordis_unmount` success text. Returns null on non-match.
* Shape: `unmounted <id> (plugin "<name>")`
* @param {string} text
* @returns {{ id: string, pluginName: string } | null}
*/
function parseUnmountResult(text) {
if (typeof text !== 'string') return null
const m = text.match(/^unmounted\s+(\S+)\s+\(plugin\s+"([^"]*)"\)/)
if (!m) return null
return { id: m[1], pluginName: m[2] }
}
/**
* Parse `cordis_inspect` text into a `{ section: lines[] }` object suitable for
* the Fields-tree widget. Splits on lines beginning with `## `; each section's
* body becomes an array of its non-blank lines (leading `- ` bullets kept as-is
* so the tree reads like the tool's own report). Returns an empty object when
* no `## ` heading is present.
* @param {string} text
* @returns {Record<string, string[]>}
*/
function parseInspectSections(text) {
const out = {}
if (typeof text !== 'string' || text.length === 0) return out
// No `## ` heading anywhere → not a sectioned report; caller falls back to raw.
if (!/^##\s+/m.test(text)) return out
// Split on a heading line; the first chunk before any `## ` (usually empty)
// is dropped by the falsy-heading guard below.
const parts = text.split(/^##\s+/m)
for (const part of parts) {
if (!part || !part.trim()) continue
const nl = part.indexOf('\n')
const heading = (nl === -1 ? part : part.slice(0, nl)).trim()
if (!heading) continue
const body = nl === -1 ? '' : part.slice(nl + 1)
const lines = body.split('\n').map((l) => l.replace(/\s+$/, '')).filter((l) => l.length > 0)
out[heading] = lines
}
return out
}
// Resolve the shared Fields-tree builder. The inspect body reuses
// trace-detail-pane's buildJsonTree (task rule: "no new tree"). Injectable so
// the unit tests can pass a stub under the node DOM shim.
function resolveBuildTree(explicit) {
if (typeof explicit === 'function') return explicit
const tdp = (typeof window !== 'undefined') ? window.__dshTraceDetailPane : null
return tdp && typeof tdp.buildJsonTree === 'function' ? tdp.buildJsonTree : null
}
// Small helper: a key-value row `key value` inside the mount/unmount block.
function kvRow(doc, key, value) {
const row = doc.createElement('div')
row.className = 'card-cordis-kv-row'
const k = doc.createElement('span')
k.className = 'card-cordis-kv-key'
k.textContent = String(key)
const v = doc.createElement('span')
v.className = 'card-cordis-kv-val'
v.textContent = String(value)
row.append(k, v)
return row
}
// A `entry` / `entry` delta line. `kind` is 'add' | 'del'. This is the ONLY
// place we assert a runtime change, and only because the operation itself is
// definitionally an add (mount) or remove (unmount) of exactly this id — never
// a synthesised before/after diff.
function deltaLine(doc, kind, entry) {
const row = doc.createElement('div')
row.className = 'card-cordis-delta ' + (kind === 'del' ? 'del' : 'add')
const sig = doc.createElement('span')
sig.className = 'card-cordis-delta-sig'
sig.textContent = kind === 'del' ? '' : '' //
const label = doc.createElement('span')
label.className = 'card-cordis-delta-entry'
label.textContent = String(entry)
row.append(sig, label)
return row
}
/**
* Render a cordis tool result into a purpose-built card.
*
* @param {object} spec
* @param {string} spec.name — the tool name (cordis_mount|unmount|inspect).
* @param {object} [spec.argsObj] — the parsed `tool/call.arguments` (code/id/what).
* @param {string} [spec.text] — the tool/result text content (already flattened).
* @param {boolean} [spec.isError] — the tool/result isError flag.
* @param {Function} [spec.buildTree] — override for buildJsonTree (tests).
* @param {Document} [spec.doc] — override document (tests); defaults to global.
* @returns {HTMLElement}
*/
function renderCordisCard(spec) {
const s = spec || {}
const doc = s.doc || (typeof document !== 'undefined' ? document : null)
const name = s.name
const argsObj = s.argsObj && typeof s.argsObj === 'object' ? s.argsObj : {}
const text = typeof s.text === 'string' ? s.text : ''
const isError = !!s.isError
const el = doc.createElement('div')
el.className = 'card-cordis'
el.setAttribute('data-tool-card-family', 'cordis')
el.setAttribute('data-cordis-op', String(name))
// -- header: op glyph + id + status dot ------------------------------------
const header = doc.createElement('div')
header.className = 'card-cordis-header'
const glyph = doc.createElement('span')
glyph.className = 'card-cordis-op-glyph'
glyph.textContent = OP_GLYPH[name] || '~'
header.appendChild(glyph)
// The most identifying token for the header, per operation. mount's id lives
// in the result (runtime-assigned dyn-N), so parse it; unmount/inspect carry
// it in args.
const mount = name === 'cordis_mount' && !isError ? parseMountResult(text) : null
const unmount = name === 'cordis_unmount' && !isError ? parseUnmountResult(text) : null
let headerId
if (name === 'cordis_mount') headerId = mount ? mount.id : 'mount'
else if (name === 'cordis_unmount') headerId = argsObj.id != null ? String(argsObj.id) : (unmount ? unmount.id : 'unmount')
else headerId = argsObj.what != null ? String(argsObj.what) : 'all sections'
const idEl = doc.createElement('span')
idEl.className = 'card-cordis-id'
idEl.textContent = String(headerId)
header.appendChild(idEl)
const status = doc.createElement('span')
status.className = 'card-cordis-status ' + (isError ? 'err' : 'ok')
status.setAttribute('aria-hidden', 'true')
status.textContent = isError ? '●' : '●' // ● (colour carries meaning; class-driven)
status.setAttribute('title', isError ? 'error' : 'ok')
header.appendChild(status)
el.appendChild(header)
const body = doc.createElement('div')
body.className = 'card-cordis-body'
el.appendChild(body)
// -- error path: surface the tool's own message verbatim ------------------
if (isError) {
const err = doc.createElement('div')
err.className = 'card-cordis-error'
err.textContent = text || '[error]'
body.appendChild(err)
return el
}
// -- mount -----------------------------------------------------------------
if (name === 'cordis_mount') {
if (mount) {
const kv = doc.createElement('div')
kv.className = 'card-cordis-kv'
kv.appendChild(kvRow(doc, 'id', mount.id))
kv.appendChild(kvRow(doc, 'name', mount.pluginName))
kv.appendChild(kvRow(doc, 'state', mount.state))
if (mount.waiting.length > 0) kv.appendChild(kvRow(doc, 'waiting', mount.waiting.join(', ')))
body.appendChild(kv)
// A mount definitionally adds exactly this entry.
body.appendChild(deltaLine(doc, 'add', mount.id))
} else {
// Unrecognised success text: show what IS there rather than an empty card.
appendRawText(doc, body, text)
}
// The mount source (`code`) is the closest thing the entry carries to a
// config; keep it behind a fold so a dozen-line plugin body doesn't drown
// the card. Absent when the arg wasn't captured.
if (typeof argsObj.code === 'string' && argsObj.code.length > 0) {
body.appendChild(codeFold(doc, argsObj.code))
}
return el
}
// -- unmount ---------------------------------------------------------------
if (name === 'cordis_unmount') {
const removedId = unmount ? unmount.id : (argsObj.id != null ? String(argsObj.id) : null)
if (unmount) {
const kv = doc.createElement('div')
kv.className = 'card-cordis-kv'
kv.appendChild(kvRow(doc, 'id', unmount.id))
kv.appendChild(kvRow(doc, 'name', unmount.pluginName))
body.appendChild(kv)
} else {
appendRawText(doc, body, text)
}
if (removedId) body.appendChild(deltaLine(doc, 'del', removedId))
return el
}
// -- inspect ---------------------------------------------------------------
// Reuse the shared Fields-tree over the parsed sections. Falls back to raw
// text when either the tree builder is unavailable (early boot) or the text
// carried no `## ` sections to parse.
const sections = parseInspectSections(text)
const buildTree = resolveBuildTree(s.buildTree)
if (buildTree && Object.keys(sections).length > 0) {
const treeHost = doc.createElement('div')
treeHost.className = 'card-cordis-tree'
// openDepth:1 shows the section names folded; the reader expands a section
// to see its lines — matches the "collapsible list" the task asked for.
treeHost.appendChild(buildTree(doc, sections, { rootName: null, openDepth: 1 }))
body.appendChild(treeHost)
} else {
appendRawText(doc, body, text)
}
return el
}
// A monospace fold holding the mount `code`. Closed by default.
function codeFold(doc, code) {
const det = doc.createElement('details')
det.className = 'card-cordis-code'
const sum = doc.createElement('summary')
sum.className = 'card-cordis-code-summary'
sum.textContent = 'plugin source'
det.appendChild(sum)
const pre = doc.createElement('pre')
pre.className = 'card-cordis-code-body'
pre.textContent = code
det.appendChild(pre)
return det
}
// Fallback: raw text in a monospace block (the generic result view, scoped so
// it still reads as part of the cordis card).
function appendRawText(doc, host, text) {
const pre = doc.createElement('pre')
pre.className = 'card-cordis-raw'
pre.textContent = text || '[ok]'
host.appendChild(pre)
}
// -- exports -----------------------------------------------------------------
// Dual export shape mirrors tool-cards.js / widgets.js.
const api = {
CORDIS_TOOLS,
OP_GLYPH,
isCordisTool,
parseMountResult,
parseUnmountResult,
parseInspectSections,
renderCordisCard,
}
if (typeof module !== 'undefined' && module.exports) module.exports = api
if (typeof window !== 'undefined') window.__dshCordisCard = api
})()

View File

@@ -337,6 +337,9 @@
<button id="mock-card-diff-write" class="ghost small">card · diff (write)</button>
<button id="mock-card-diff-multi" class="ghost small">card · diff (3 files · hunks)</button>
<button id="mock-code-dispatch" class="ghost small">code-dispatch fan-out</button>
<button id="mock-cordis-mount" class="ghost small">cordis · mount</button>
<button id="mock-cordis-inspect" class="ghost small">cordis · inspect</button>
<button id="mock-cordis-unmount" class="ghost small">cordis · unmount</button>
<button id="mock-web-search" class="ghost small">web · search</button>
<button id="mock-skill" class="ghost small">skill loaded</button>
<button id="mock-workflow" class="ghost small">workflow card</button>
@@ -1455,6 +1458,13 @@
from any tab. -->
<script src="./fork-compare.js"></script>
<script src="./tool-cards.js"></script>
<!-- lane-cordis-card: dedicated card for the self-referential cordis
toolset (mount/unmount/inspect). Loads after tool-cards.js (shares the
family-map convention); the inspect body resolves
trace-detail-pane.buildJsonTree lazily at render, so parse order vs.
that script doesn't matter. Defines window.__dshCordisCard, consumed by
the renderer's tool/result dispatch. -->
<script src="./cordis-card.js"></script>
<!-- artifacts-board.js defines window.__dshArtifactsBoard (Board/Timeline/
Evolution renderers) and MUST load before artifacts.js so the L0 row
version-chip click handler + view switcher find the module ready. -->

View File

@@ -282,6 +282,74 @@ function mockCodeDispatch() {
}
}
// -- cordis dedicated-card mocks (lane-cordis-card) --------------------------
// Drive the three cordis operations through the SAME onSessionEvent dispatch
// path a real Vibe run would, using PLAIN-TEXT result content (the real wire
// shape — all three tools are generic render intents). Text strings mirror
// packages/cordis/tool-cordis/src (index.ts execute + inspect.ts renderers).
function mockCordisMount() {
injectMockToolResult({
name: 'cordis_mount',
args: {
code: "return {\n name: 'change-logger',\n inject: ['tools'],\n apply(ctx) {\n ctx.on('tools/change', () => console.log('tools changed'))\n },\n}",
},
meta: undefined,
content: [{ type: 'text', text: 'mounted dyn-1 (plugin "change-logger", state: active)' }],
isError: false,
})
}
function mockCordisInspect() {
const report = [
'## services',
'- tools (provided by ToolRegistry)',
'- systemPrompt (provided by SystemPrompt)',
'- bash (provided by LocalBash)',
'',
'## plugins',
'- cordis-dynamic [active]',
'- tool-cordis [active]',
'',
'## tools',
'- cordis_inspect',
'- cordis_mount',
'- cordis_unmount',
'- bash',
'- read',
'',
'## dynamic',
'- dyn-1: change-logger [active]',
'',
'## api',
'- tools — the model-facing tool registry',
' register(definition: ToolDefinition)',
'inherited ctx API:',
'- ctx.effect — register a disposable effect',
'',
'## events',
'- tools/change [emit] — fired when the tool registry changes',
" 'tools/change'(): void",
'waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.',
].join('\n')
injectMockToolResult({
name: 'cordis_inspect',
args: {},
meta: undefined,
content: [{ type: 'text', text: report }],
isError: false,
})
}
function mockCordisUnmount() {
injectMockToolResult({
name: 'cordis_unmount',
args: { id: 'dyn-1' },
meta: undefined,
content: [{ type: 'text', text: 'unmounted dyn-1 (plugin "change-logger")' }],
isError: false,
})
}
// -- context lane mocks ------------------------------------------
// Drive the recall card + compact card DOM without a live daemon. Same
// event-injection shape as the other mocks — feeds through onSessionEvent

View File

@@ -1503,6 +1503,18 @@ function safePretty(v) {
try { return JSON.stringify(v, null, 2) } catch { return String(v) }
}
// Parse a tool/call `arguments` value (JSON string on the wire, or already an
// object on some synthesized paths) into a plain object. Returns {} on any
// failure so consumers can read fields without a null guard. Used by the
// cordis card to reach `code` / `id` / `what` for its header + source fold.
function safeParseArgs(v) {
if (v && typeof v === 'object') return v
if (typeof v === 'string' && v) {
try { const p = JSON.parse(v); return p && typeof p === 'object' ? p : {} } catch { return {} }
}
return {}
}
// Concatenate a content-block array's text-type entries. Reasoning blocks
// are streamed into their own frame via `reasoning-delta` chunks; tool_use
// / tool-call blocks are rendered as tool cards from the `tool/call` event
@@ -5525,6 +5537,21 @@ function onSessionEvent(sessionId, event) {
// `view.exitCode`, not `isError`.
resBox.textContent = ''
resBox.appendChild(tc.renderTerminalCard(view))
} else if (window.__dshCordisCard && window.__dshCordisCard.isCordisTool(toolName)) {
// cordis self-referential toolset (mount/unmount/inspect): purpose-
// built card parsed from the tool's own text (all three are generic
// render intents with plain-text results — see cordis-card.js). This
// sits AFTER the meta.card switch so a cordis tool that ever grows a
// structured widget/diff view still takes that path first; here we
// own only the generic-text case the family would otherwise dump raw.
resBox.textContent = ''
const argsObj = safeParseArgs(payload && payload.args)
resBox.appendChild(window.__dshCordisCard.renderCordisCard({
name: toolName,
argsObj,
text: textFromContentBlocks(content),
isError: !!isError,
}))
} else {
resBox.textContent = textFromContentBlocks(content) || (isError ? '[error]' : '[ok]')
if (isError) resBox.style.color = 'var(--error)'
@@ -7099,6 +7126,14 @@ document.getElementById('mock-card-diff-write').addEventListener('click', mockCa
const mockCardDiffMultiBtn = document.getElementById('mock-card-diff-multi')
if (mockCardDiffMultiBtn) mockCardDiffMultiBtn.addEventListener('click', mockCardDiffMulti)
document.getElementById('mock-code-dispatch').addEventListener('click', mockCodeDispatch)
// cordis dedicated-card mocks (lane-cordis-card) — guarded like the P1 batch
// below because the buttons are optional (two-file change to add them).
const mockCordisMountBtn = document.getElementById('mock-cordis-mount')
if (mockCordisMountBtn) mockCordisMountBtn.addEventListener('click', mockCordisMount)
const mockCordisInspectBtn = document.getElementById('mock-cordis-inspect')
if (mockCordisInspectBtn) mockCordisInspectBtn.addEventListener('click', mockCordisInspect)
const mockCordisUnmountBtn = document.getElementById('mock-cordis-unmount')
if (mockCordisUnmountBtn) mockCordisUnmountBtn.addEventListener('click', mockCordisUnmount)
// P1 batch C mock buttons — guarded because the IDs are optional (adding
// them is a two-file change: index.html + here). Missing buttons are a
// silent no-op instead of a boot-time TypeError.

View File

@@ -1807,6 +1807,96 @@ body.layout-monitor .stream {
.card-code-dispatch-row.ok .card-code-dispatch-dot { color: var(--ok); }
.card-code-dispatch-row.err .card-code-dispatch-dot { color: var(--error); }
/* Expandable sub-call rows (lane-cordis-card): the row is now a <details>.
* The summary keeps the compact one-line look; the body reveals args + result
* on expand. Marker hidden so the branch glyph reads as the sole affordance. */
.card-code-dispatch-row { cursor: default; }
.card-code-dispatch-summary-line {
display: flex; align-items: center; gap: 6px; padding: 2px 0;
font-family: var(--mono); font-size: 12px; cursor: pointer;
list-style: none;
}
.card-code-dispatch-summary-line::-webkit-details-marker { display: none; }
.card-code-dispatch-summary-line::marker { content: ''; }
.card-code-dispatch-summary-line .inspect-badge { margin-left: auto; }
.card-code-dispatch-detail {
margin: 2px 0 4px 18px; padding-left: 8px;
border-left: 1px dashed var(--border);
display: flex; flex-direction: column; gap: 4px;
}
.card-code-dispatch-detail-label {
color: var(--muted); font-size: 10px; letter-spacing: 0.03em;
text-transform: uppercase;
}
.card-code-dispatch-detail-body {
margin: 0; font-family: var(--mono); font-size: 11px;
white-space: pre-wrap; word-break: break-word;
color: var(--text); max-height: 200px; overflow: auto;
}
.card-code-dispatch-row.nested { border-left: 1px solid var(--border); }
/* -- cordis dedicated card (lane-cordis-card) --------------------------------
* The self-referential toolset (mount/unmount/inspect): the model editing its
* own runtime. Uses the cordis family accent (--muted band on the tool-block)
* plus a purpose-built header + body. */
.card-cordis {
border-top: 1px dashed var(--border); margin-top: 4px; padding-top: 6px;
font-size: 12px;
}
.card-cordis-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
}
.card-cordis-op-glyph {
font-family: var(--mono); font-size: 13px; color: var(--accent);
width: 1.2em; text-align: center; flex: 0 0 auto;
}
.card-cordis-id {
font-family: var(--mono); font-weight: 600; color: var(--text);
}
.card-cordis-status { font-size: 9px; line-height: 1; flex: 0 0 auto; }
.card-cordis-status.ok { color: var(--ok); }
.card-cordis-status.err { color: var(--error); }
.card-cordis-body { display: flex; flex-direction: column; gap: 6px; }
.card-cordis-kv {
display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px;
font-family: var(--mono); font-size: 12px;
}
.card-cordis-kv-row { display: contents; }
.card-cordis-kv-key { color: var(--muted); }
.card-cordis-kv-val { color: var(--text); word-break: break-word; }
.card-cordis-delta {
display: flex; align-items: center; gap: 6px;
font-family: var(--mono); font-size: 12px;
}
.card-cordis-delta-sig { width: 1em; text-align: center; flex: 0 0 auto; }
.card-cordis-delta.add .card-cordis-delta-sig { color: var(--ok); }
.card-cordis-delta.del .card-cordis-delta-sig { color: var(--error); }
.card-cordis-delta.add .card-cordis-delta-entry { color: var(--text); }
.card-cordis-delta.del .card-cordis-delta-entry {
color: var(--muted); text-decoration: line-through;
}
.card-cordis-error {
font-family: var(--mono); font-size: 12px; color: var(--error);
white-space: pre-wrap; word-break: break-word;
}
.card-cordis-raw {
margin: 0; font-family: var(--mono); font-size: 12px;
white-space: pre-wrap; word-break: break-word; color: var(--text);
}
.card-cordis-tree { font-size: 12px; }
.card-cordis-code { margin-top: 2px; }
.card-cordis-code-summary {
cursor: pointer; color: var(--muted); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.03em;
}
.card-cordis-code-body {
margin: 4px 0 0; font-family: var(--mono); font-size: 11px;
white-space: pre-wrap; word-break: break-word;
color: var(--text); max-height: 240px; overflow: auto;
background: var(--bg-elev); border: 1px solid var(--border);
border-radius: 4px; padding: 6px 8px;
}
/* --- visibility batch B (todo, prompt/blocked, turn/end, subagent finished, sandbox+preset badges, approval audit) --- */
/* Owned by visibility-controller.js; scoped to .visibility-* / .todo-* / .pb-* / .sas-* / .sandbox-* / .preset-* classes so the block adds surface without overriding anything above. */

View File

@@ -704,11 +704,26 @@ function formatJson(v, emptyMsg) {
// -- code-dispatch fan-out ---------------------------------------------------
/**
* Append a sub-call row to a parent `run_code` tool block. The row is a small
* indented line summarising the sub-tool call — a full nested card would drown
* the stream when Code Mode fans out to a dozen calls.
* Append a sub-call row to a parent `run_code` tool block. Each row is a
* `<details>` whose collapsed `<summary>` keeps the compact one-line shape
* (branch · status · name · result gist) so a dozen fan-out calls stay
* scannable; expanding it reveals that sub-call's arguments and result summary.
* A `{ }` inspector badge on the summary opens the unified Inspector anchored
* to a reconstructed `tool/call` event for the sub-call (same affordance the
* top-level tool cards carry).
*
* Wire shape (packages/core/tools/src/code-mode.ts): the `tool/code-dispatch`
* event carries `{parentCallId, subCallId, name, arguments, isError,
* resultSummary}` — arguments AND result land in ONE event (the event fires
* after the sub-call completes), so there is no separate result event to await.
*
* Nesting: the wire carries no depth field for dispatch-within-dispatch, so
* rows render flat. If a caller ever passes a finite `event.depth > 0` we
* indent one level per depth (reusing the fused-call-row inset), leaving the
* shape ready if the runtime starts emitting depth.
*
* @param {HTMLElement} parentResBox — the `.result` div of the enclosing run_code call
* @param {{ name: string, subCallId: string, isError: boolean, resultSummary?: string }} event
* @param {{ name: string, subCallId: string, arguments?: unknown, isError: boolean, resultSummary?: string, depth?: number }} event
* @returns {HTMLElement}
*/
function appendCodeDispatch(parentResBox, event) {
@@ -725,26 +740,98 @@ function appendCodeDispatch(parentResBox, event) {
list.appendChild(header)
parentResBox.appendChild(list)
}
const row = document.createElement('div')
row.className = 'card-code-dispatch-row ' + (event && event.isError ? 'err' : 'ok')
const isError = !!(event && event.isError)
const s = event && typeof event.resultSummary === 'string' ? event.resultSummary : ''
const subName = String(event && event.name != null ? event.name : '(unknown)')
// The row is a <details> so it expands in place. Class stays
// `card-code-dispatch-row ok|err` so existing probes/tests that select the
// row by class keep working across the collapsed→expandable change.
const row = document.createElement('details')
row.className = 'card-code-dispatch-row ' + (isError ? 'err' : 'ok')
if (event && event.subCallId != null) row.setAttribute('data-sub-call-id', String(event.subCallId))
// Optional depth inset (defensive; wire has no depth today).
const depth = event && Number.isFinite(event.depth) ? Math.max(0, Math.floor(event.depth)) : 0
if (depth > 0) {
row.classList.add('nested')
row.style.marginLeft = (depth * 12) + 'px'
}
const summary = document.createElement('summary')
summary.className = 'card-code-dispatch-summary-line'
const branch = document.createElement('span')
branch.className = 'card-code-dispatch-branch'
branch.textContent = '└─' // └─
const name = document.createElement('span')
name.className = 'card-code-dispatch-name'
name.textContent = String(event && event.name != null ? event.name : '(unknown)')
const dot = document.createElement('span')
dot.className = 'card-code-dispatch-dot'
dot.textContent = event && event.isError ? '✗' : '✓' // ✗/✓
const summary = document.createElement('span')
summary.className = 'card-code-dispatch-summary'
const s = event && typeof event.resultSummary === 'string' ? event.resultSummary : ''
summary.textContent = s
row.append(branch, dot, name, summary)
dot.textContent = isError ? '✗' : '✓' // ✗/✓
const name = document.createElement('span')
name.className = 'card-code-dispatch-name'
name.textContent = subName
// The collapsed one-line result gist keeps the original class name so the
// scannable summary text is unchanged when the row is closed.
const gist = document.createElement('span')
gist.className = 'card-code-dispatch-summary'
gist.textContent = s
summary.append(branch, dot, name, gist)
// { } inspector badge on the summary, anchored to a reconstructed tool/call
// event for this sub-call. Guarded: absent inspector (early boot / node
// tests) simply skips the badge — never throws.
const ins = (typeof window !== 'undefined') ? window.__dshInspector : null
if (ins && typeof ins.attachInspectBadge === 'function') {
const subEvent = {
type: 'tool/call',
__reconstructed: true,
data: {
callId: event && event.subCallId,
name: subName,
arguments: event ? event.arguments : undefined,
result: { isError, resultSummary: s },
},
}
ins.attachInspectBadge(summary, () => ({ event: subEvent, tab: 'json', title: `sub-call: ${subName}` }))
}
row.appendChild(summary)
// Expanded body: args + result summary. Built once; the <details> toggle
// shows/hides it with no rebuild.
const detail = document.createElement('div')
detail.className = 'card-code-dispatch-detail'
detail.appendChild(dispatchDetailBlock('args', codeDispatchArgsText(event && event.arguments)))
detail.appendChild(dispatchDetailBlock('result', s || (isError ? '[error]' : '[ok]')))
row.appendChild(detail)
list.appendChild(row)
return row
}
// A labelled monospace block inside an expanded sub-call row.
function dispatchDetailBlock(label, text) {
const wrap = document.createElement('div')
wrap.className = 'card-code-dispatch-detail-block'
const lab = document.createElement('div')
lab.className = 'card-code-dispatch-detail-label'
lab.textContent = label
const pre = document.createElement('pre')
pre.className = 'card-code-dispatch-detail-body'
pre.textContent = text
wrap.append(lab, pre)
return wrap
}
// Flatten a sub-call's `arguments` (any JS value or JSON string) into a pretty
// block for the expanded row. Mirrors formatJson's re-parse trick so a
// JSON-string arg reads indented, not as escape soup.
function codeDispatchArgsText(args) {
if (args == null) return '(no arguments)'
if (typeof args === 'string') {
if (args === '') return '(no arguments)'
try { return JSON.stringify(JSON.parse(args), null, 2) } catch { return args }
}
try { return JSON.stringify(args, null, 2) } catch { return String(args) }
}
// -- exports -----------------------------------------------------------------
// Dual export shape mirrors widgets.js: CommonJS for node:test, window for
// the renderer. The renderer script tag runs before renderer.js so the

View File

@@ -0,0 +1,277 @@
// Cordis dedicated card unit tests. Runs under `node --test`, no Electron.
// Shares the hand-rolled DOM shim with tool-cards.test.js / widgets.test.js
// (see those files for why we don't pull in jsdom). Fixtures mirror the REAL
// upstream wire shapes (test/fixtures/cordis-wire-shapes.json).
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const FIX = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures/cordis-wire-shapes.json'), 'utf8'))
function makeShim() {
function make(tagName) {
const el = {
tagName: String(tagName).toUpperCase(),
children: [],
attrs: {},
style: {},
dataset: {},
classList: {
_s: new Set(),
add(...names) { for (const n of names) this._s.add(n) },
remove(...names) { for (const n of names) this._s.delete(n) },
contains(n) { return this._s.has(n) },
toggle(n) { if (this._s.has(n)) this._s.delete(n); else this._s.add(n) },
},
_text: '',
get textContent() { return this._text },
set textContent(v) { this._text = String(v); this.children = [] },
set className(v) { this._className = String(v); for (const c of String(v).split(/\s+/)) this.classList.add(c) },
get className() { return this._className || '' },
setAttribute(k, v) { this.attrs[k] = String(v) },
getAttribute(k) { return this.attrs[k] },
appendChild(c) { this.children.push(c); return c },
append(...cs) { for (const c of cs) this.children.push(c) },
addEventListener() { /* no-op */ },
}
return el
}
const doc = { createElement: (t) => make(t) }
return { doc }
}
function walk(node, pred, out = []) {
if (!node || !node.tagName) return out
if (pred(node)) out.push(node)
for (const c of node.children || []) walk(c, pred, out)
return out
}
function byClass(node, cls) { return walk(node, (n) => n.classList && n.classList.contains(cls)) }
function firstText(node, cls) { const h = byClass(node, cls)[0]; return h ? h._text : undefined }
function loadCard() {
const p = require.resolve('../src/renderer/cordis-card.js')
delete require.cache[p]
return require('../src/renderer/cordis-card.js')
}
// ----- name detection --------------------------------------------------------
test('isCordisTool: only the three cordis names, and they match TOOL_FAMILIES', () => {
global.document = makeShim().doc
const { isCordisTool, CORDIS_TOOLS } = loadCard()
assert.ok(isCordisTool('cordis_mount'))
assert.ok(isCordisTool('cordis_unmount'))
assert.ok(isCordisTool('cordis_inspect'))
assert.ok(!isCordisTool('bash'))
assert.ok(!isCordisTool('cordis_frobnicate'))
assert.ok(!isCordisTool(null))
// consistency with the family map that also identifies these names
const tc = require('../src/renderer/tool-cards.js')
for (const n of CORDIS_TOOLS) assert.equal(tc.toolFamilyFor(n).className, 'family-cordis')
})
// ----- parsers (pure, over real result text) --------------------------------
test('parseMountResult: active mount, no waiting', () => {
global.document = makeShim().doc
const { parseMountResult } = loadCard()
const text = FIX.mount_ok.result.data.content[0].text
const m = parseMountResult(text)
assert.deepEqual(m, { id: 'dyn-1', pluginName: 'change-logger', state: 'active', waiting: [] })
})
test('parseMountResult: pending mount names the awaited services', () => {
global.document = makeShim().doc
const { parseMountResult } = loadCard()
const text = FIX.mount_pending_waiting.result.data.content[0].text
const m = parseMountResult(text)
assert.equal(m.id, 'dyn-2')
assert.equal(m.pluginName, 'greeter-consumer')
assert.equal(m.state, 'pending')
assert.deepEqual(m.waiting, ['greeter'])
})
test('parseMountResult: non-mount text yields null (falls back to raw)', () => {
global.document = makeShim().doc
const { parseMountResult } = loadCard()
assert.equal(parseMountResult('mount code returned `undefined`'), null)
assert.equal(parseMountResult(''), null)
assert.equal(parseMountResult(null), null)
})
test('parseUnmountResult: id + plugin name', () => {
global.document = makeShim().doc
const { parseUnmountResult } = loadCard()
const text = FIX.unmount_ok.result.data.content[0].text
assert.deepEqual(parseUnmountResult(text), { id: 'dyn-1', pluginName: 'change-logger' })
assert.equal(parseUnmountResult('no dynamic plugin with id "dyn-9"'), null)
})
test('parseInspectSections: splits `## ` headings into line arrays', () => {
global.document = makeShim().doc
const { parseInspectSections } = loadCard()
const text = FIX.inspect_all.result.data.content[0].text
const secs = parseInspectSections(text)
assert.deepEqual(Object.keys(secs), ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
assert.ok(secs.tools.includes('- cordis_mount'))
assert.ok(secs.dynamic.includes('- dyn-1: change-logger [active]'))
// no headings → empty object
assert.deepEqual(parseInspectSections('just some text'), {})
assert.deepEqual(parseInspectSections(''), {})
})
// ----- mount card ------------------------------------------------------------
test('renderCordisCard mount ok: header id + kv block + add delta', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
const el = renderCordisCard({
name: 'cordis_mount',
argsObj: JSON.parse(FIX.mount_ok.call.data.arguments),
text: FIX.mount_ok.result.data.content[0].text,
isError: false,
doc,
})
assert.equal(el.getAttribute('data-cordis-op'), 'cordis_mount')
assert.equal(el.getAttribute('data-tool-card-family'), 'cordis')
assert.equal(firstText(el, 'card-cordis-id'), 'dyn-1')
// status ok
assert.ok(byClass(el, 'card-cordis-status')[0].classList.contains('ok'))
// kv rows: id / name / state (no waiting)
const keys = byClass(el, 'card-cordis-kv-key').map((n) => n._text)
assert.deepEqual(keys, ['id', 'name', 'state'])
// add-delta line for the mounted id
const delta = byClass(el, 'card-cordis-delta')[0]
assert.ok(delta.classList.contains('add'))
assert.equal(firstText(delta, 'card-cordis-delta-entry'), 'dyn-1')
// plugin source fold present (code arg captured)
assert.equal(byClass(el, 'card-cordis-code').length, 1)
})
test('renderCordisCard mount pending: waiting row present', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
const el = renderCordisCard({
name: 'cordis_mount',
argsObj: JSON.parse(FIX.mount_pending_waiting.call.data.arguments),
text: FIX.mount_pending_waiting.result.data.content[0].text,
isError: false,
doc,
})
const keys = byClass(el, 'card-cordis-kv-key').map((n) => n._text)
assert.deepEqual(keys, ['id', 'name', 'state', 'waiting'])
const waitVal = byClass(el, 'card-cordis-kv-val').map((n) => n._text)
assert.ok(waitVal.includes('greeter'))
})
test('renderCordisCard mount error: verbatim message, err status, no delta', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
const el = renderCordisCard({
name: 'cordis_mount',
argsObj: JSON.parse(FIX.mount_error.call.data.arguments),
text: FIX.mount_error.result.data.content[0].text,
isError: true,
doc,
})
assert.ok(byClass(el, 'card-cordis-status')[0].classList.contains('err'))
const err = byClass(el, 'card-cordis-error')[0]
assert.ok(err._text.includes('did you forget `return`'))
assert.equal(byClass(el, 'card-cordis-delta').length, 0)
assert.equal(byClass(el, 'card-cordis-kv').length, 0)
})
// ----- unmount card ----------------------------------------------------------
test('renderCordisCard unmount ok: kv + del delta on the removed id', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
const el = renderCordisCard({
name: 'cordis_unmount',
argsObj: JSON.parse(FIX.unmount_ok.call.data.arguments),
text: FIX.unmount_ok.result.data.content[0].text,
isError: false,
doc,
})
assert.equal(firstText(el, 'card-cordis-id'), 'dyn-1')
const delta = byClass(el, 'card-cordis-delta')[0]
assert.ok(delta.classList.contains('del'))
assert.equal(firstText(delta, 'card-cordis-delta-entry'), 'dyn-1')
})
test('renderCordisCard unmount error: header id from args, message verbatim', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
const el = renderCordisCard({
name: 'cordis_unmount',
argsObj: JSON.parse(FIX.unmount_error.call.data.arguments),
text: FIX.unmount_error.result.data.content[0].text,
isError: true,
doc,
})
// header id still resolves from args even when the result is an error
assert.equal(firstText(el, 'card-cordis-id'), 'dyn-9')
assert.ok(byClass(el, 'card-cordis-error')[0]._text.includes('no dynamic plugin'))
assert.equal(byClass(el, 'card-cordis-delta').length, 0)
})
// ----- inspect card ----------------------------------------------------------
test('renderCordisCard inspect: reuses injected buildJsonTree over parsed sections', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
let seenValue = null
let seenOpts = null
const buildTree = (d, value, opts) => { seenValue = value; seenOpts = opts; const n = d.createElement('div'); n.className = 'stub-tree'; return n }
const el = renderCordisCard({
name: 'cordis_inspect',
argsObj: {},
text: FIX.inspect_all.result.data.content[0].text,
isError: false,
buildTree,
doc,
})
// header id defaults to "all sections" when `what` is absent
assert.equal(firstText(el, 'card-cordis-id'), 'all sections')
// the tree host holds the stub tree (no new tree built here)
assert.equal(byClass(el, 'stub-tree').length, 1)
// buildJsonTree was fed the parsed section object + openDepth 1
assert.deepEqual(Object.keys(seenValue), ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'])
assert.equal(seenOpts.openDepth, 1)
})
test('renderCordisCard inspect with `what`: header shows the section, tree over one key', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
let seenValue = null
const buildTree = (d, value) => { seenValue = value; const n = d.createElement('div'); n.className = 'stub-tree'; return n }
const el = renderCordisCard({
name: 'cordis_inspect',
argsObj: JSON.parse(FIX.inspect_dynamic.call.data.arguments),
text: FIX.inspect_dynamic.result.data.content[0].text,
isError: false,
buildTree,
doc,
})
assert.equal(firstText(el, 'card-cordis-id'), 'dynamic')
assert.deepEqual(Object.keys(seenValue), ['dynamic'])
})
test('renderCordisCard inspect: falls back to raw text when no tree builder', () => {
const { doc } = makeShim(); global.document = doc
const { renderCordisCard } = loadCard()
const el = renderCordisCard({
name: 'cordis_inspect',
argsObj: {},
text: FIX.inspect_all.result.data.content[0].text,
isError: false,
buildTree: null,
doc,
})
assert.equal(byClass(el, 'card-cordis-raw').length, 1)
})

View File

@@ -0,0 +1,284 @@
{
"_comment": "Realistic tool/call + tool/result wire shapes for the three cordis tools, mirroring the REAL upstream package (packages/cordis/tool-cordis/src in the sibling runtime repo). All three declare a `generic` render intent and return PLAIN-TEXT content blocks — there is no structured result object on the wire. Result text strings below are the exact formats the tool's execute() returns (index.ts) and inspect renderers emit (inspect.ts); STATE_LABELS are lowercase upstream (`active`/`pending`). meta is omitted (generic card carries none). These feed the cordis-card unit tests + the QA shoot's event-injection seam.",
"mount_ok": {
"call": {
"type": "tool/call",
"seq": 101,
"time": 1721400000000,
"data": {
"turn": 0,
"step": 0,
"callId": "cordis-mount-1",
"name": "cordis_mount",
"arguments": "{\"code\":\"return {\\n name: 'change-logger',\\n inject: ['tools'],\\n apply(ctx) {\\n ctx.on('tools/change', () => console.log('tools changed'))\\n },\\n}\"}"
}
},
"result": {
"type": "tool/result",
"seq": 102,
"time": 1721400001200,
"data": {
"turn": 0,
"step": 0,
"callId": "cordis-mount-1",
"isError": false,
"durationMs": 1180,
"content": [
{ "type": "text", "text": "mounted dyn-1 (plugin \"change-logger\", state: active)" }
]
}
}
},
"mount_pending_waiting": {
"call": {
"type": "tool/call",
"seq": 111,
"time": 1721400010000,
"data": {
"turn": 1,
"step": 0,
"callId": "cordis-mount-2",
"name": "cordis_mount",
"arguments": "{\"code\":\"return {\\n name: 'greeter-consumer',\\n inject: ['greeter'],\\n apply(ctx) { ctx.greeter.hello() },\\n}\"}"
}
},
"result": {
"type": "tool/result",
"seq": 112,
"time": 1721400010900,
"data": {
"turn": 1,
"step": 0,
"callId": "cordis-mount-2",
"isError": false,
"durationMs": 860,
"content": [
{ "type": "text", "text": "mounted dyn-2 (plugin \"greeter-consumer\", state: pending — waiting for service(s): greeter (activates when provided))" }
]
}
}
},
"mount_error": {
"call": {
"type": "tool/call",
"seq": 121,
"time": 1721400020000,
"data": {
"turn": 2,
"step": 0,
"callId": "cordis-mount-3",
"name": "cordis_mount",
"arguments": "{\"code\":\"const x = 1\"}"
}
},
"result": {
"type": "tool/result",
"seq": 122,
"time": 1721400020400,
"data": {
"turn": 2,
"step": 0,
"callId": "cordis-mount-3",
"isError": true,
"durationMs": 40,
"content": [
{ "type": "text", "text": "mount code returned `undefined` — did you forget `return`?\n ✓ return (ctx) => { … }\n ✓ return { name: '…', inject: […], apply(ctx) { … } }" }
]
}
}
},
"unmount_ok": {
"call": {
"type": "tool/call",
"seq": 131,
"time": 1721400030000,
"data": {
"turn": 3,
"step": 0,
"callId": "cordis-unmount-1",
"name": "cordis_unmount",
"arguments": "{\"id\":\"dyn-1\"}"
}
},
"result": {
"type": "tool/result",
"seq": 132,
"time": 1721400030500,
"data": {
"turn": 3,
"step": 0,
"callId": "cordis-unmount-1",
"isError": false,
"durationMs": 520,
"content": [
{ "type": "text", "text": "unmounted dyn-1 (plugin \"change-logger\")" }
]
}
}
},
"unmount_error": {
"call": {
"type": "tool/call",
"seq": 141,
"time": 1721400040000,
"data": {
"turn": 4,
"step": 0,
"callId": "cordis-unmount-2",
"name": "cordis_unmount",
"arguments": "{\"id\":\"dyn-9\"}"
}
},
"result": {
"type": "tool/result",
"seq": 142,
"time": 1721400040100,
"data": {
"turn": 4,
"step": 0,
"callId": "cordis-unmount-2",
"isError": true,
"durationMs": 12,
"content": [
{ "type": "text", "text": "no dynamic plugin with id \"dyn-9\" (list mounts with cordis_inspect what:\"dynamic\")" }
]
}
}
},
"inspect_all": {
"call": {
"type": "tool/call",
"seq": 151,
"time": 1721400050000,
"data": {
"turn": 5,
"step": 0,
"callId": "cordis-inspect-1",
"name": "cordis_inspect",
"arguments": "{}"
}
},
"result": {
"type": "tool/result",
"seq": 152,
"time": 1721400050700,
"data": {
"turn": 5,
"step": 0,
"callId": "cordis-inspect-1",
"isError": false,
"durationMs": 34,
"content": [
{ "type": "text", "text": "## services\n- tools (provided by ToolRegistry)\n- systemPrompt (provided by SystemPrompt)\n- bash (provided by LocalBash)\n\n## plugins\n- cordis-dynamic [active]\n- tool-cordis [active]\n\n## tools\n- cordis_inspect\n- cordis_mount\n- cordis_unmount\n- bash\n- read\n\n## dynamic\n- dyn-1: change-logger [active]\n\n## api\n- tools — the model-facing tool registry\n register(definition: ToolDefinition)\n- systemPrompt — the composed system prompt\ninherited ctx API:\n- ctx.effect — register a disposable effect\n\n## events\n- tools/change [emit] — fired when the tool registry changes\n 'tools/change'(): void\n- tools/pre-execute [waterfall] — intercept a tool call\n 'tools/pre-execute'(call, next): void\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain." }
]
}
}
},
"inspect_dynamic": {
"call": {
"type": "tool/call",
"seq": 161,
"time": 1721400060000,
"data": {
"turn": 6,
"step": 0,
"callId": "cordis-inspect-2",
"name": "cordis_inspect",
"arguments": "{\"what\":\"dynamic\"}"
}
},
"result": {
"type": "tool/result",
"seq": 162,
"time": 1721400060300,
"data": {
"turn": 6,
"step": 0,
"callId": "cordis-inspect-2",
"isError": false,
"durationMs": 18,
"content": [
{ "type": "text", "text": "## dynamic\n- dyn-1: change-logger [active]\n- dyn-2: greeter-consumer [pending] — waiting for: greeter" }
]
}
}
},
"code_dispatch_run": {
"parent_call": {
"type": "tool/call",
"seq": 201,
"time": 1721400070000,
"data": {
"turn": 7,
"step": 0,
"callId": "run-code-1",
"name": "run_code",
"arguments": "{\"code\":\"const files = await bash({command:'ls src'});\\nconst hosts = await read({file_path:'/etc/hosts'});\\nawait edit({file_path:'src/x.ts', old_string:'foo', new_string:'bar'})\"}"
}
},
"parent_result": {
"type": "tool/result",
"seq": 208,
"time": 1721400072500,
"data": {
"turn": 7,
"step": 0,
"callId": "run-code-1",
"isError": false,
"durationMs": 2480,
"content": [
{ "type": "text", "text": "[code mode dispatched 3 sub-calls]" }
]
}
},
"dispatches": [
{
"type": "tool/code-dispatch",
"seq": 202,
"time": 1721400070600,
"data": {
"parentCallId": "run-code-1",
"subCallId": "run-code-1:code:1",
"name": "bash",
"arguments": { "command": "ls src" },
"isError": false,
"resultSummary": "index.ts\nmount.ts\ninspect.ts\npresent.ts"
}
},
{
"type": "tool/code-dispatch",
"seq": 204,
"time": 1721400071300,
"data": {
"parentCallId": "run-code-1",
"subCallId": "run-code-1:code:2",
"name": "read",
"arguments": { "file_path": "/etc/hosts" },
"isError": false,
"resultSummary": "read 12 lines from /etc/hosts"
}
},
{
"type": "tool/code-dispatch",
"seq": 206,
"time": 1721400072100,
"data": {
"parentCallId": "run-code-1",
"subCallId": "run-code-1:code:3",
"name": "edit",
"arguments": { "file_path": "src/x.ts", "old_string": "foo", "new_string": "bar" },
"isError": true,
"resultSummary": "no match for old_string in src/x.ts"
}
}
]
}
}

View File

@@ -231,12 +231,15 @@ test('appendCodeDispatch: first call clears placeholder + creates the list heade
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
box.textContent = '…'
appendCodeDispatch(box, { name: 'bash', subCallId: 'sc1', isError: false, resultSummary: 'ok' })
appendCodeDispatch(box, { name: 'bash', subCallId: 'sc1', arguments: { command: 'ls' }, isError: false, resultSummary: 'ok' })
const header = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-header'))
assert.equal(header.length, 1)
const rows = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-row'))
assert.equal(rows.length, 1)
assert.ok(rows[0].classList.contains('ok'))
// Row is a <details> now (expandable) but keeps the row class + sub-call id.
assert.equal(rows[0].tagName, 'DETAILS')
assert.equal(rows[0].attrs['data-sub-call-id'], 'sc1')
const name = walk(rows[0], (n) => n.classList && n.classList.contains('card-code-dispatch-name'))[0]
assert.equal(name._text, 'bash')
})
@@ -254,6 +257,92 @@ test('appendCodeDispatch: second call reuses the same list (no double header)',
assert.ok(rows[1].classList.contains('err'))
})
test('appendCodeDispatch: expandable body carries args + result blocks', () => {
global.document = makeShim().doc
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
appendCodeDispatch(box, {
name: 'bash', subCallId: 'sc1',
arguments: { command: 'ls src' }, isError: false, resultSummary: 'listed 4 entries',
})
const blocks = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-detail-block'))
assert.equal(blocks.length, 2, 'args + result blocks')
const labels = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-detail-label')).map((n) => n._text)
assert.deepEqual(labels, ['args', 'result'])
const bodies = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-detail-body')).map((n) => n._text)
// args pretty-printed; result summary verbatim
assert.ok(bodies[0].includes('"command"'))
assert.ok(bodies[0].includes('ls src'))
assert.equal(bodies[1], 'listed 4 entries')
})
test('appendCodeDispatch: string (JSON) arguments re-parsed pretty; empty args labelled', () => {
global.document = makeShim().doc
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
appendCodeDispatch(box, { name: 'read', subCallId: 's', arguments: '{"file_path":"/x"}', isError: false, resultSummary: 'ok' })
appendCodeDispatch(box, { name: 'noop', subCallId: 't', isError: false, resultSummary: 'ok' })
const bodies = walk(box, (n) => n.classList && n.classList.contains('card-code-dispatch-detail-body')).map((n) => n._text)
assert.ok(bodies[0].includes('"file_path"'))
// second row's args block (index 2) is the "(no arguments)" fallback
assert.equal(bodies[2], '(no arguments)')
})
test('appendCodeDispatch: inspector badge anchored to a reconstructed sub-call event', () => {
global.document = makeShim().doc
global.window = {
__dshInspector: {
attachInspectBadge(el, getTarget) {
const badge = document.createElement('button')
badge.className = 'inspect-badge'
badge._getTarget = getTarget
el.appendChild(badge)
return badge
},
},
}
try {
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
appendCodeDispatch(box, {
name: 'edit', subCallId: 'run-code-1:code:3',
arguments: { file_path: 'src/x.ts' }, isError: true, resultSummary: 'no match',
})
const badge = walk(box, (n) => n.classList && n.classList.contains('inspect-badge'))[0]
assert.ok(badge, 'badge attached to the row summary')
const target = badge._getTarget()
assert.equal(target.tab, 'json')
assert.equal(target.event.type, 'tool/call')
assert.equal(target.event.__reconstructed, true)
assert.equal(target.event.data.callId, 'run-code-1:code:3')
assert.equal(target.event.data.name, 'edit')
assert.deepEqual(target.event.data.arguments, { file_path: 'src/x.ts' })
assert.equal(target.event.data.result.isError, true)
assert.equal(target.event.data.result.resultSummary, 'no match')
} finally {
delete global.window
}
})
test('appendCodeDispatch: no inspector present → no badge, no throw', () => {
global.document = makeShim().doc
delete global.window
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
appendCodeDispatch(box, { name: 'bash', subCallId: 's', isError: false, resultSummary: 'ok' })
const badges = walk(box, (n) => n.classList && n.classList.contains('inspect-badge'))
assert.equal(badges.length, 0)
})
test('appendCodeDispatch: finite depth indents the row (defensive; wire has none today)', () => {
global.document = makeShim().doc
const { appendCodeDispatch } = loadCards()
const box = document.createElement('div')
const row = appendCodeDispatch(box, { name: 'bash', subCallId: 's', isError: false, resultSummary: 'ok', depth: 2 })
assert.ok(row.classList.contains('nested'))
assert.equal(row.style.marginLeft, '24px')
})
// ----- durationMs pill (Ticket D) -------------------------------------------
test('formatDurationLabel: sub-second stays in ms, 1s < X < 60s uses N.Ns', () => {