feat(desktop): cordis dedicated card (mount/unmount/inspect) + nested code-dispatch tree
This commit is contained in:
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