Files
deepseek-harness/examples/desktop/scripts/qa-trace-signals-shoot.mjs
ZiyaZhang 47949244c8 feat(desktop): trace signal detection + tri-view badges
Adds a small signal-detection layer over trace payloads and surfaces
three kinds of semantic signals inline in the trace tri-view (Timeline
/ Graph / turn footer chips) so a researcher can spot repetitive
tool-call loops, redundant identical calls, and plan-completion beats
without hand-scanning the raw log:

- loop-detected  — three or more repeats of the same tool.method
                   signature inside a window, badge in Timeline gutter
                   + colored ring on the Graph node
- redundant-call — identical (tool, arg-hash) call twice without any
                   state change in between, badge + subdued styling
                   so it stays advisory not alarmist
- plan-* chips   — plan/subplan step-complete beats surfaced above the
                   assistant body in the turn footer, wired through
                   the finishTurnContainer tail

Pure rendering; no wire-format changes needed on the runtime side —
signal detection is fixture-driven off the same payload the tri-view
already consumes. Companion RFC L-2 in docs/upstream-ledger.md asks
the runtime to emit these signals natively rather than deriving them
in the shell, so once L-2 lands the shell will consume upstream signals
and this detector becomes a fallback.

Files:
  src/renderer/trace-signal-detect.js       new  (loop/redundant/plan)
  src/renderer/trace-tri-view.js            +24  (wire detector output)
  src/renderer/trace-timeline.js            +39  (badge in gutter)
  src/renderer/trace-graph.js               +30  (colored ring)
  src/renderer/renderer.js                  +81  (finishTurnContainer chips)
  src/renderer/index.html                    +1  (one script tag)
  src/renderer/style.css                    +58  (badge/ring/chip rules)
  docs/upstream-ledger.md                  +121  (append L-2 RFC)
  scripts/qa-trace-signals-fixture.mjs     new  (headless SVG proof)
  scripts/qa-trace-signals-shoot.mjs       new  (CDP live shoot)
  docs/trace-signals-shoot/*.html          new  (3 fixture-driven shots)
  test/trace-signal-detect.test.js         new  (detector unit tests)
  test/trace-signal-overlay.test.js        new  (renderer overlay tests)

Test suite: 1668/1668 pass (17 new). Fixture-driven signals-01-timeline-
loop.html regenerated byte-identical from the merged HEAD.
2026-07-19 00:57:19 -07:00

160 lines
7.4 KiB
JavaScript

// qa-trace-signals-shoot.mjs — lane-trace-signals selfie driver.
//
// Three shots proving the trace signal overlays (feat/trace-signals):
// signals-01 Timeline view — loop-detected + redundant-call badges
// in the left gutter of a loop-heavy step-record
// signals-02 Graph view — colored ring around a tool-error node
// signals-03 Turn footer — signal chip row above the assistant body
// with three signal kinds (loop/redundant/plan) chips
//
// Requires the desktop shell running via `pnpm --dir examples/desktop start`
// with --remote-debugging-port=<port> exposed. In an env without electron,
// see scripts/qa-trace-signals-fixture.mjs for a headless SVG render that
// exercises the exact rendering code paths — enough for a diff-review.
//
// Usage:
// node scripts/qa-trace-signals-shoot.mjs <port> <outdir>
import { writeFileSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
const [,, portArg, outdir] = process.argv
const port = portArg || '9241'
if (!outdir) {
console.error('usage: node scripts/qa-trace-signals-shoot.mjs <port> <outdir>')
process.exit(1)
}
mkdirSync(outdir, { recursive: true })
async function cdp() {
const listRes = await fetch(`http://localhost:${port}/json/list`)
const targets = await listRes.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((r, x) => { ws.onopen = r; ws.onerror = (e) => x(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 = {}, timeoutMs = 60000) => new Promise((ok, err) => {
const _id = id++
const t = setTimeout(() => { pending.delete(_id); err(new Error(`cdp timeout: ${m}`)) }, timeoutMs)
pending.set(_id, [(v) => { clearTimeout(t); ok(v) }, (e) => { clearTimeout(t); err(e) }])
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
})
const evjs = async (js) => {
const r = await call('Runtime.evaluate', { expression: js, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
return r.result?.value
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
return { call, evjs, sleep, close: () => ws.close() }
}
async function shoot(c, name, opts) {
const { fixture, prep, wait = 500 } = opts
await c.evjs(`(function () {
const fx = ${JSON.stringify(fixture)};
// Inject fixture into a fresh session and force a re-render.
const chat = window.__dshChat; if (!chat) throw new Error('__dshChat missing');
const id = chat.newSession ? chat.newSession('trace-signals-' + '${name}') : 'trace-signals-${name}';
const meta = window.__dshState && window.__dshState.sessions
? window.__dshState.sessions.get(id) : null;
if (meta) { meta.cachedEvents = fx.events.slice(); }
if (typeof window.__dshQaReplayFixture === 'function') {
window.__dshQaReplayFixture(id, fx.events);
}
return id;
})()`)
await c.sleep(wait)
if (typeof prep === 'function') await prep(c)
const shot = await c.call('Page.captureScreenshot', { format: 'png' })
const buf = Buffer.from(shot.data, 'base64')
const out = resolve(outdir, `${name}.png`)
writeFileSync(out, buf)
console.log('wrote', out)
}
async function main() {
const c = await cdp()
try {
// Fixture 1: three identical fs.read calls → loop-detected + redundant
await shoot(c, 'signals-01-timeline-loop', {
fixture: {
events: [
{ type: 'turn/start', seq: 1, time: 1000, data: { turn: 1 } },
{ type: 'step/start', seq: 2, time: 1010, data: { turn: 1, step: 0 } },
{ type: 'tool/call', seq: 3, time: 1050, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c1' } },
{ type: 'tool/result', seq: 4, time: 1080, data: { callId: 'c1', ok: true } },
{ type: 'tool/call', seq: 5, time: 1100, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c2' } },
{ type: 'tool/result', seq: 6, time: 1130, data: { callId: 'c2', ok: true } },
{ type: 'tool/call', seq: 7, time: 1150, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c3' } },
{ type: 'tool/result', seq: 8, time: 1180, data: { callId: 'c3', ok: true } },
{ type: 'step/end', seq: 9, time: 1200, data: { turn: 1, step: 0 } },
{ type: 'turn/end', seq: 10, time: 1210 },
],
},
// Open the trace drawer and switch to Timeline
prep: async (c) => {
await c.evjs(`(function () {
const drawer = document.querySelector('.turn-trace-drawer');
if (drawer) drawer.open = true;
const btn = document.querySelector('.trace-tri-chip.chip-timeline');
if (btn) btn.click();
})()`)
await c.sleep(300)
},
})
// Fixture 2: tool error → red ring on the graph node
await shoot(c, 'signals-02-graph-error', {
fixture: {
events: [
{ type: 'turn/start', seq: 1, time: 1000, data: { turn: 1 } },
{ type: 'step/start', seq: 2, time: 1010, data: { turn: 1, step: 0 } },
{ type: 'tool/call', seq: 3, time: 1050, data: { name: 'bash', arguments: 'ls /nope', callId: 'c1' } },
{ type: 'tool/result', seq: 4, time: 1080, data: { callId: 'c1', ok: false, error: 'ENOENT: no such file or directory' } },
{ type: 'tool/call', seq: 5, time: 1100, data: { name: 'bash', arguments: 'ls /tmp', callId: 'c2' } },
{ type: 'tool/result', seq: 6, time: 1130, data: { callId: 'c2', ok: true } },
{ type: 'step/end', seq: 7, time: 1200, data: { turn: 1, step: 0 } },
{ type: 'turn/end', seq: 8, time: 1210 },
],
},
prep: async (c) => {
await c.evjs(`(function () {
const drawer = document.querySelector('.turn-trace-drawer');
if (drawer) drawer.open = true;
const btn = document.querySelector('.trace-tri-chip.chip-graph');
if (btn) btn.click();
})()`)
await c.sleep(300)
},
})
// Fixture 3: plan-update + loop → chips above turn body
await shoot(c, 'signals-03-chips-plan', {
fixture: {
events: [
{ type: 'turn/start', seq: 1, time: 1000, data: { turn: 1 } },
{ type: 'assistant/message', seq: 2, time: 1050, data: { content: [{ type: 'text', text: 'Here is the new plan: 1. read main.ts\n2. edit imports\n3. verify' }] } },
{ type: 'tool/call', seq: 3, time: 1100, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c1' } },
{ type: 'tool/call', seq: 4, time: 1150, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c2' } },
{ type: 'tool/call', seq: 5, time: 1200, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c3' } },
{ type: 'turn/end', seq: 6, time: 1210 },
],
},
})
} finally {
c.close()
}
}
main().catch((e) => { console.error(e); process.exit(1) })