diff --git a/examples/desktop/README.md b/examples/desktop/README.md index 57108b3b6f..433a378824 100644 --- a/examples/desktop/README.md +++ b/examples/desktop/README.md @@ -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 diff --git a/examples/desktop/docs/qa-cordis-card/01-mount-card.png b/examples/desktop/docs/qa-cordis-card/01-mount-card.png new file mode 100644 index 0000000000..04ca77faed Binary files /dev/null and b/examples/desktop/docs/qa-cordis-card/01-mount-card.png differ diff --git a/examples/desktop/docs/qa-cordis-card/02-inspect-fields.png b/examples/desktop/docs/qa-cordis-card/02-inspect-fields.png new file mode 100644 index 0000000000..f7e74019d1 Binary files /dev/null and b/examples/desktop/docs/qa-cordis-card/02-inspect-fields.png differ diff --git a/examples/desktop/docs/qa-cordis-card/03-code-dispatch-open.png b/examples/desktop/docs/qa-cordis-card/03-code-dispatch-open.png new file mode 100644 index 0000000000..2d4d46a12a Binary files /dev/null and b/examples/desktop/docs/qa-cordis-card/03-code-dispatch-open.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-cordis-card.mjs b/examples/desktop/scripts/qa-cdp-shoot-cordis-card.mjs new file mode 100644 index 0000000000..2099940f0f --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-cordis-card.mjs @@ -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= isolates Chromium userdata. +// 2. DSH_DESKTOP_HOME= 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)) diff --git a/examples/desktop/src/renderer/cordis-card.js b/examples/desktop/src/renderer/cordis-card.js new file mode 100644 index 0000000000..fc0a067516 --- /dev/null +++ b/examples/desktop/src/renderer/cordis-card.js @@ -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 (plugin "", 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 (plugin "")` + * @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} + */ +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 + +})() diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html index a4f9aed5d5..55bb70d4e3 100644 --- a/examples/desktop/src/renderer/index.html +++ b/examples/desktop/src/renderer/index.html @@ -337,6 +337,9 @@ + + + @@ -1455,6 +1458,13 @@ from any tab. --> + + diff --git a/examples/desktop/src/renderer/mock-fixtures.js b/examples/desktop/src/renderer/mock-fixtures.js index 7643f04781..6d4cfd86c4 100644 --- a/examples/desktop/src/renderer/mock-fixtures.js +++ b/examples/desktop/src/renderer/mock-fixtures.js @@ -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 diff --git a/examples/desktop/src/renderer/renderer.js b/examples/desktop/src/renderer/renderer.js index 31ba92d49d..3c1aab32e2 100644 --- a/examples/desktop/src/renderer/renderer.js +++ b/examples/desktop/src/renderer/renderer.js @@ -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. diff --git a/examples/desktop/src/renderer/style.css b/examples/desktop/src/renderer/style.css index b729fcda0b..16dc93c3f6 100644 --- a/examples/desktop/src/renderer/style.css +++ b/examples/desktop/src/renderer/style.css @@ -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
. + * 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. */ diff --git a/examples/desktop/src/renderer/tool-cards.js b/examples/desktop/src/renderer/tool-cards.js index c5c8234662..ea53a536a6 100644 --- a/examples/desktop/src/renderer/tool-cards.js +++ b/examples/desktop/src/renderer/tool-cards.js @@ -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 + * `
` whose collapsed `` 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
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
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 diff --git a/examples/desktop/test/cordis-card.test.js b/examples/desktop/test/cordis-card.test.js new file mode 100644 index 0000000000..c699579533 --- /dev/null +++ b/examples/desktop/test/cordis-card.test.js @@ -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) +}) diff --git a/examples/desktop/test/fixtures/cordis-wire-shapes.json b/examples/desktop/test/fixtures/cordis-wire-shapes.json new file mode 100644 index 0000000000..004852bc4e --- /dev/null +++ b/examples/desktop/test/fixtures/cordis-wire-shapes.json @@ -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" + } + } + ] + } +} diff --git a/examples/desktop/test/tool-cards.test.js b/examples/desktop/test/tool-cards.test.js index 15525f7009..e386b21349 100644 --- a/examples/desktop/test/tool-cards.test.js +++ b/examples/desktop/test/tool-cards.test.js @@ -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
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', () => {