feat(desktop): cordis dedicated card (mount/unmount/inspect) + nested code-dispatch tree
This commit is contained in:
@@ -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
|
||||
|
||||
BIN
examples/desktop/docs/qa-cordis-card/01-mount-card.png
Normal file
BIN
examples/desktop/docs/qa-cordis-card/01-mount-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
BIN
examples/desktop/docs/qa-cordis-card/02-inspect-fields.png
Normal file
BIN
examples/desktop/docs/qa-cordis-card/02-inspect-fields.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
BIN
examples/desktop/docs/qa-cordis-card/03-code-dispatch-open.png
Normal file
BIN
examples/desktop/docs/qa-cordis-card/03-code-dispatch-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
305
examples/desktop/scripts/qa-cdp-shoot-cordis-card.mjs
Normal file
305
examples/desktop/scripts/qa-cdp-shoot-cordis-card.mjs
Normal 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))
|
||||
321
examples/desktop/src/renderer/cordis-card.js
Normal file
321
examples/desktop/src/renderer/cordis-card.js
Normal 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
|
||||
|
||||
})()
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
277
examples/desktop/test/cordis-card.test.js
Normal file
277
examples/desktop/test/cordis-card.test.js
Normal 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)
|
||||
})
|
||||
284
examples/desktop/test/fixtures/cordis-wire-shapes.json
vendored
Normal file
284
examples/desktop/test/fixtures/cordis-wire-shapes.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user