diff --git a/examples/desktop/docs/trace-signals-shoot/signals-01-timeline-loop.html b/examples/desktop/docs/trace-signals-shoot/signals-01-timeline-loop.html new file mode 100644 index 0000000000..443c28e12e --- /dev/null +++ b/examples/desktop/docs/trace-signals-shoot/signals-01-timeline-loop.html @@ -0,0 +1,50 @@ + +Signals 01 — Timeline: loop-detected badge + + +

Signals 01 — Timeline: loop-detected badge

+

Three consecutive fs.read calls with identical args. The detector fires loop-detected on the third call; its badge sits in the left gutter, colored red. Signals detected: loop-detected. See docs/upstream-ledger.md L-2.

+
0ms125ms250ms375ms500msstep 1.0 — read main.ts · 500msstep 1.0 — read main.ts · 500ms · 500mstool: fs.readtool: fs.read · 30mstool: fs.readtool: fs.read · 30mstool: fs.readtool: fs.read · 30msLoop detected · 3 × fs.read · prior seq 11, 12 · (heuristic)
+ \ No newline at end of file diff --git a/examples/desktop/docs/trace-signals-shoot/signals-02-graph-error.html b/examples/desktop/docs/trace-signals-shoot/signals-02-graph-error.html new file mode 100644 index 0000000000..0d782a7247 --- /dev/null +++ b/examples/desktop/docs/trace-signals-shoot/signals-02-graph-error.html @@ -0,0 +1,50 @@ + +Signals 02 — Graph: tool-error ring + + +

Signals 02 — Graph: tool-error ring

+

A failing bash call followed by a successful retry. The detector emits tool-error on the failing call's seq and plan-restart on the retry. The Graph node for the failing call gets a red outer ring; the retry node gets a blue ring. Signals detected: tool-error, tool-error, plan-restart.

+
step 1.0step 1.0 · step · 500ms · seq 10Tool error · bash: ENOENTbashbash · tool · 30ms · seq 11Plan restart · retry bash after seq 12 · (heuristic)bashbash · tool · 30ms · seq 13
+ \ No newline at end of file diff --git a/examples/desktop/docs/trace-signals-shoot/signals-03-chips-plan.html b/examples/desktop/docs/trace-signals-shoot/signals-03-chips-plan.html new file mode 100644 index 0000000000..84cc7fabea --- /dev/null +++ b/examples/desktop/docs/trace-signals-shoot/signals-03-chips-plan.html @@ -0,0 +1,63 @@ + +Signals 03 — Turn container: signal chip row + + +

Signals 03 — Turn container: signal chip row

+

The chip row sits above the assistant body. Each chip covers one signal kind detected in this turn (loop / redundant / plan-update). Clicking a chip auto-opens the trace drawer for a drill-in. Signals detected in this turn: plan-update, loop-detected, redundant-call.

+
+
+
+
+
Here is the new plan: 1. read main.ts, 2. edit imports, 3. verify.
+
fs.read(main.ts)
+
fs.read(main.ts)
+
fs.read(main.ts)
+
bash(ls)
+
fs.read(main.ts)
+
+
+ \ No newline at end of file diff --git a/examples/desktop/docs/upstream-ledger.md b/examples/desktop/docs/upstream-ledger.md index 05e7ac2af7..833310cc6f 100644 --- a/examples/desktop/docs/upstream-ledger.md +++ b/examples/desktop/docs/upstream-ledger.md @@ -79,3 +79,124 @@ so there's no coupling between land order. Future entries append below. Keep the numbering monotone (L-2, L-3…) so a cross-repo reference like "see upstream ledger L-1" stays stable. --> + +## L-2 Runtime should emit semantic trace signals (loop / redundant / plan-*) + +**Symptom.** A researcher watching a session can't see at a glance where +the interesting things happened: the agent got stuck in a tool-call loop, +called the same tool with the same args twice within a few turns, +mid-turn rewrote its plan, or restarted after a tool error. These are the +first four things a debugger wants highlighted, and today the trace tri-view ++ main assistant flow show every step at equal visual weight. The Tree +column has an ✗ glyph for tool errors — that's the only pre-existing signal. + +**Root cause.** The runtime wire has no dedicated "signal" event type. All +diagnostic annotations that today's UI could show — loop detection, +redundant-call detection, plan updates, plan restarts, ordinary tool errors +raised to signal status — are inferable from the flat event stream but not +themselves emitted. Concretely: + +- `packages/core/agent-loop/src/loop.ts` — the loop sees every tool/call + and every tool/result but never emits a `trace/signal` derived from them. +- `packages/core/tools/src/index.ts` — tool descriptors don't declare + loop/retry guardrails that could feed a signal emission. +- `packages/core/planner/` (or the equivalent — grep for `plan` in + `deepseek-harness-dev/packages/core/`) — plan updates are internal state, + not observable on the wire. + +The desktop renderer therefore has no signal to render. + +**Local workaround.** New `src/renderer/trace-signal-detect.js` is a +heuristic detector run over `meta.cachedEvents`. It emits five signal kinds: + +- `loop-detected` — ≥ N consecutive same-tool + same-args-prefix calls (N=3) +- `redundant-call` — same (name, args-prefix) reappearing within an 8-seq + window with at least one different call in between +- `plan-update` — assistant/message text matching "new/revised/updated + plan", "here's the plan", or a two-line numbered-list intro +- `plan-restart` — same tool re-invoked after a `tool/result` `ok:false` +- `tool-error` — surfaced from the already-visible `ok:false` result, but + also stamped on the matching call seq so the Graph node (which absorbs + the result into the call) has a place to hang the badge + +Signals are then rendered as: + +- Timeline bars: colored dots to the left of each affected row + (`.trace-timeline-signal-badge` in `style.css`) +- Graph nodes: outer ring around the node + (`.trace-graph-signal-ring`, highest-priority signal wins the color) +- Assistant turn container: a chip row above the body + (`.turn-signal-chip-row` / `.turn-signal-chip`), one chip per signal + kind observed in that turn, clicking a chip auto-opens the trace drawer + +The detector already special-cases wire-emitted signals: any event whose +`type === 'trace/signal'` is passed through verbatim (marked `source: 'wire'`) +and the heuristic scan skips seqs already covered by wire signals. This +means the shape is forward-compatible: once upstream lands the fix, the +runtime's signals win and the heuristic scan becomes dead code without +requiring a renderer change. + +Spot the workaround in code review by: + +- `src/renderer/trace-signal-detect.js` — the entire file +- `src/renderer/trace-timeline.js` — the `options.signals` badge loop + (around the "Signal badges" comment) +- `src/renderer/trace-graph.js` — the "Signal ring" block inside + `renderGraph`'s node loop +- `src/renderer/trace-tri-view.js` — the `_computeSignals(records)` call + passing a `bySeq` map to both Timeline and Graph +- `src/renderer/renderer.js` — `applyTurnSignalChips(sessionId, section)` + called at the end of `finishTurnContainer` +- `src/renderer/style.css` — the `.trace-timeline-signal-badge` / + `.trace-graph-signal-ring` / `.turn-signal-chip*` blocks at the tail + +**Upstream fix (needed).** Emit `trace/signal` events from the runtime with +this shape: + +``` +{ + type: 'trace/signal', + seq: , // seq the signal decorates (often the tool/call seq) + time: , + data: { + signal: 'loop-detected'|'redundant-call'|'plan-update'|'plan-restart'|'tool-error', + // signal-specific fields; the renderer uses these for tooltip content: + name?: string, // for tool-family signals: which tool + argsKey?: string, // 80-char args prefix + run?: number, // loop-detected: number of consecutive matches + priorSeqs?: number[], // loop-detected: earlier calls in the run + priorSeq?: number, // redundant-call / plan-restart pointer + priorErrorSeq?: number, + snippet?: string, // plan-update: first 80 chars of the plan text + error?: string, // tool-error + } +} +``` + +Suggested seams (grep upstream to confirm exact `packages/…` paths — the +comments below cite the same file family as L-1): + +1. **Loop/redundant detection** — add a small ring buffer of recent + `tool/call` (name, argsKey) pairs inside `agent-loop`'s emit path. When + the buffer trips the threshold, emit `trace/signal` before the offending + `tool/call` reaches the wire so the signal precedes the call in seq order. + Threshold defaults (loopN=3, window=8) can be config-flagged. +2. **Plan updates** — plumbed from the planner / plan-summary side. Ideal + shape: a `plan/updated` event upstream, with `trace-signal` derived from + it. If the planner state is internal, at minimum emit the "assistant + drafted a new plan" fact when it happens (the heuristic detector proves + the text is recoverable, but the wire truth is upstream). +3. **Plan restarts / tool errors** — pair `tool/result` `ok:false` with the + next same-tool `tool/call` and emit the paired signal at emit time. + +The detector-side dedup already covers the "signal already came from the +wire" case, so upstream can ship these one at a time without a big-bang +change: each signal kind lands, its heuristic branch becomes dead code, and +eventually `trace-signal-detect.js` reduces to a pass-through for the wire +signals. + +Once all five signal kinds land upstream, the renderer-side detector +becomes pure pass-through (roughly 20 lines) — a small doc-only PR at +that point removes the heuristic scan entirely. Until then, the tri-view +tabs read as "here's what the runtime is showing you" (wire signals) and +"here's what the shell inferred" (heuristic, tooltipped as such). diff --git a/examples/desktop/scripts/qa-trace-signals-fixture.mjs b/examples/desktop/scripts/qa-trace-signals-fixture.mjs new file mode 100644 index 0000000000..2a1008cdb6 --- /dev/null +++ b/examples/desktop/scripts/qa-trace-signals-fixture.mjs @@ -0,0 +1,248 @@ +// qa-trace-signals-fixture.mjs — headless SVG proof for lane-trace-signals. +// +// The Electron shoot (qa-trace-signals-shoot.mjs) can't run in the CI env +// (electron isn't installed there — that's the same story as the +// pre-existing artifact-server.test.js failure). This script drives the +// same rendering code paths through node's require system, then writes +// three standalone HTML files that show the exact SVG output the Timeline +// and Graph produce. Open them in a browser to see the badges/rings/chips +// exactly as the real app would render them. +// +// Output (default: docs/trace-signals-shoot/): +// signals-01-timeline-loop.html +// signals-02-graph-error.html +// signals-03-chips-plan.html + +import { writeFileSync, mkdirSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const outdir = process.argv[2] || resolve(__dirname, '..', 'docs', 'trace-signals-shoot') +mkdirSync(outdir, { recursive: true }) + +const T = require(resolve(__dirname, '..', 'src', 'renderer', 'trace-timeline.js')) +const G = require(resolve(__dirname, '..', 'src', 'renderer', 'trace-graph.js')) +const SD = require(resolve(__dirname, '..', 'src', 'renderer', 'trace-signal-detect.js')) + +// Node has no document; hand-roll a minimal serializer over the same shape +// the renderer builds. This mirrors the test-shim in test/trace-signal-*. +function makeDoc() { + function makeEl(tagOrNS, tag) { + const cls = new Set() + const el = { + tagName: (tag || tagOrNS).toLowerCase(), + isSvg: !!tag && tagOrNS === 'http://www.w3.org/2000/svg', + _children: [], _attrs: {}, _cls: cls, + textContent: '', + dataset: {}, + style: {}, + get className() { return Array.from(cls).join(' ') }, + set className(v) { cls.clear(); String(v || '').split(/\s+/).forEach(x => x && cls.add(x)) }, + classList: { + add(c) { cls.add(c) }, remove(c) { cls.delete(c) }, + toggle(c, on) { if (on) cls.add(c); else cls.delete(c) }, + contains(c) { return cls.has(c) }, + }, + appendChild(c) { this._children.push(c); return c }, + append(...cs) { for (const c of cs) this._children.push(c) }, + insertBefore(node) { this._children.unshift(node); return node }, + setAttribute(k, v) { + this._attrs[k] = String(v) + if (k === 'class') { + cls.clear() + String(v || '').split(/\s+/).forEach(x => x && cls.add(x)) + } + }, + getAttribute(k) { return this._attrs[k] }, + addEventListener() {}, removeEventListener() {}, + querySelector() { return null }, querySelectorAll() { return [] }, + } + return el + } + return { + createElement(t) { return makeEl(t) }, + createElementNS(ns, t) { return makeEl(ns, t) }, + body: makeEl('body'), + } +} + +function serialize(el) { + if (!el) return '' + if (typeof el === 'string') return el + const tag = el.tagName + const cls = el.className + const attrs = [] + for (const [k, v] of Object.entries(el._attrs || {})) { + if (k === 'class') continue + attrs.push(`${k}="${escapeHtml(v)}"`) + } + if (cls) attrs.push(`class="${escapeHtml(cls)}"`) + const attrStr = attrs.length ? ' ' + attrs.join(' ') : '' + const kids = (el._children || []).map(serialize).join('') + const text = el.textContent && (!el._children || !el._children.length) ? escapeHtml(el.textContent) : '' + return `<${tag}${attrStr}>${text}${kids}` +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''') +} + +function pageWrap(title, body, note) { + return ` +${escapeHtml(title)} + + +

${escapeHtml(title)}

+

${note}

+
${body}
+` +} + +// ─── shot 1 — Timeline with a loop-detected badge on seq 12 ────────── +{ + const doc = makeDoc() + const rec = { + turn: 1, step: 0, startSeq: 10, endSeq: 15, + startTime: 1000, endTime: 1500, durationMs: 500, + summary: 'read main.ts', + inputs: [], outputs: [], + events: [ + { type: 'tool/call', seq: 11, time: 1050, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c1' } }, + { type: 'tool/result', seq: 11.5, time: 1080, data: { callId: 'c1', ok: true } }, + { type: 'tool/call', seq: 12, time: 1150, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c2' } }, + { type: 'tool/result', seq: 12.5, time: 1180, data: { callId: 'c2', ok: true } }, + { type: 'tool/call', seq: 13, time: 1250, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c3' } }, + { type: 'tool/result', seq: 13.5, time: 1280, data: { callId: 'c3', ok: true } }, + ], + } + const { bySeq, all } = SD.detectSignalsFromRecords(rec, { loopN: 3 }) + const el = T.renderTimeline(doc, rec, { signals: bySeq, width: 820 }) + const html = pageWrap( + 'Signals 01 — Timeline: loop-detected badge', + serialize(el), + 'Three consecutive fs.read calls with identical args. The detector fires loop-detected on the third call; its badge sits in the left gutter, colored red. Signals detected: ' + all.map(s => s.signal).join(', ') + '. See docs/upstream-ledger.md L-2.', + ) + writeFileSync(resolve(outdir, 'signals-01-timeline-loop.html'), html) + console.log('wrote', resolve(outdir, 'signals-01-timeline-loop.html')) +} + +// ─── shot 2 — Graph with a tool-error ring around the failing call ─── +{ + const doc = makeDoc() + const rec = { + turn: 1, step: 0, startSeq: 10, endSeq: 14, + startTime: 1000, endTime: 1500, durationMs: 500, + summary: 'bash checks', + inputs: [], outputs: [], + events: [ + { type: 'tool/call', seq: 11, time: 1050, data: { name: 'bash', arguments: 'ls /nope', callId: 'c1' } }, + { type: 'tool/result', seq: 12, time: 1080, data: { callId: 'c1', ok: false, error: 'ENOENT' } }, + { type: 'tool/call', seq: 13, time: 1150, data: { name: 'bash', arguments: 'ls /tmp', callId: 'c2' } }, + { type: 'tool/result', seq: 14, time: 1180, data: { callId: 'c2', ok: true } }, + ], + } + const { bySeq, all } = SD.detectSignalsFromRecords(rec) + const el = G.renderGraph(doc, rec, { signals: bySeq }) + const html = pageWrap( + 'Signals 02 — Graph: tool-error ring', + serialize(el), + 'A failing bash call followed by a successful retry. The detector emits tool-error on the failing call\'s seq and plan-restart on the retry. The Graph node for the failing call gets a red outer ring; the retry node gets a blue ring. Signals detected: ' + all.map(s => s.signal).join(', ') + '.', + ) + writeFileSync(resolve(outdir, 'signals-02-graph-error.html'), html) + console.log('wrote', resolve(outdir, 'signals-02-graph-error.html')) +} + +// ─── shot 3 — Assistant turn with signal chip row ──────────────────── +{ + const 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: 'tool/call', seq: 6, time: 1250, data: { name: 'bash', arguments: 'ls', callId: 'c4' } }, + { type: 'tool/call', seq: 7, time: 1300, data: { name: 'fs.read', arguments: '{"path":"main.ts"}', callId: 'c5' } }, + { type: 'turn/end', seq: 8, time: 1350 }, + ] + const { all } = SD.detectSignals(events) + // Group by signal kind (matches renderer.js applyTurnSignalChips) + const seen = new Map() + for (const s of all) { + if (!seen.has(s.signal)) seen.set(s.signal, { signal: s.signal, count: 1, first: s }) + else seen.get(s.signal).count++ + } + const chips = Array.from(seen.values()).map(e => { + const cls = SD.classFor(e.signal) + const label = e.count > 1 ? `${SD.labelFor(e.signal)} × ${e.count}` : SD.labelFor(e.signal) + const title = SD.tooltipFor(e.first) + return `` + }).join('') + const body = ` +
+
+
${chips}
+
Here is the new plan: 1. read main.ts, 2. edit imports, 3. verify.
+
fs.read(main.ts)
+
fs.read(main.ts)
+
fs.read(main.ts)
+
bash(ls)
+
fs.read(main.ts)
+
+
` + const html = pageWrap( + 'Signals 03 — Turn container: signal chip row', + body, + 'The chip row sits above the assistant body. Each chip covers one signal kind detected in this turn (loop / redundant / plan-update). Clicking a chip auto-opens the trace drawer for a drill-in. Signals detected in this turn: ' + all.map(s => s.signal).join(', ') + '.', + ) + writeFileSync(resolve(outdir, 'signals-03-chips-plan.html'), html) + console.log('wrote', resolve(outdir, 'signals-03-chips-plan.html')) +} diff --git a/examples/desktop/scripts/qa-trace-signals-shoot.mjs b/examples/desktop/scripts/qa-trace-signals-shoot.mjs new file mode 100644 index 0000000000..9f530f85c2 --- /dev/null +++ b/examples/desktop/scripts/qa-trace-signals-shoot.mjs @@ -0,0 +1,159 @@ +// 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= 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 + +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 ') + 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) }) diff --git a/examples/desktop/src/renderer/index.html b/examples/desktop/src/renderer/index.html index 3b513d253c..261933a178 100644 --- a/examples/desktop/src/renderer/index.html +++ b/examples/desktop/src/renderer/index.html @@ -1323,6 +1323,7 @@ + diff --git a/examples/desktop/src/renderer/renderer.js b/examples/desktop/src/renderer/renderer.js index 04a102ad66..be00340a30 100644 --- a/examples/desktop/src/renderer/renderer.js +++ b/examples/desktop/src/renderer/renderer.js @@ -1553,9 +1553,90 @@ function finishTurnContainer(sessionId, { footerSpec, traceCard, traceSummaryTex } ct.section.appendChild(footer) ct.section.dataset.turnStatus = 'sealed' + // Signal marker chips: overlay any loop/redundant/plan/error signals + // detected in this turn's cached events onto the top of the turn + // section. The chip row sits above the assistant body so a reader + // scanning the stream sees "this turn had a loop" before deciding + // whether to open the trace drawer. See trace-signal-detect.js and + // docs/upstream-ledger.md L-2. + applyTurnSignalChips(sessionId, ct.section) state.currentTurn = null } +// Compute+attach the signal chip row for a just-sealed turn. Reads +// meta.cachedEvents (already populated) and detects signals whose seq +// falls inside this turn's range. When no signals fire, no chip row is +// added. +function applyTurnSignalChips(sessionId, section) { + try { + const SD = window.__dshTraceSignalDetect + if (!SD || typeof SD.detectSignals !== 'function') return + const meta = state.sessions.get(sessionId) + if (!meta || !Array.isArray(meta.cachedEvents) || !meta.cachedEvents.length) return + // Restrict to events whose seq falls inside this turn's range so the + // chip row reflects THIS turn, not the whole session. We use the last + // `turn/start`→`turn/end` bracket in the cache. When no bracket is + // findable, fall back to detecting on the whole cache (which will still + // produce meaningful chips at the session scope). + const range = _lastTurnSeqRange(meta.cachedEvents) + const scope = range + ? meta.cachedEvents.filter(ev => typeof ev.seq === 'number' + && ev.seq >= range.start && ev.seq <= range.end) + : meta.cachedEvents + const { all } = SD.detectSignals(scope) + if (!all.length) return + // Dedup by signal kind for the chip row: the row is a "kinds seen" + // summary; the badges in the drawer show the specific seqs. + const seen = new Map() + for (const sig of all) { + const key = sig.signal + if (!seen.has(key)) seen.set(key, { signal: sig.signal, count: 1, first: sig }) + else seen.get(key).count++ + } + const row = document.createElement('div') + row.className = 'turn-signal-chip-row' + for (const entry of seen.values()) { + const chip = document.createElement('button') + chip.type = 'button' + chip.className = `turn-signal-chip ${SD.classFor(entry.signal)}` + chip.dataset.signal = entry.signal + chip.textContent = entry.count > 1 + ? `${SD.labelFor(entry.signal)} × ${entry.count}` + : SD.labelFor(entry.signal) + chip.title = SD.tooltipFor(entry.first) + // Clicking a chip opens the trace drawer so the reader can drill in. + chip.addEventListener('click', function () { + const drawer = section.querySelector('.turn-trace-drawer') + if (drawer) { + drawer.open = true + if (typeof drawer.scrollIntoView === 'function') { + try { drawer.scrollIntoView({ block: 'nearest' }) } catch (_) {} + } + } + }) + row.appendChild(chip) + } + // Insert as the first body-child so it sits above assistant text/tool + // rows without breaking the turn-rule up top. + const body = section.querySelector('.turn-body') + if (body && body.firstChild) body.insertBefore(row, body.firstChild) + else if (body) body.appendChild(row) + else section.appendChild(row) + } catch (_) { /* chip row is a visual enhancement — never crash the stream */ } +} + +function _lastTurnSeqRange(events) { + let start = null, end = null + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i] + if (!ev || typeof ev.seq !== 'number') continue + if (end === null && ev.type === 'turn/end') end = ev.seq + if (ev.type === 'turn/start') { start = ev.seq; break } + } + if (start === null || end === null) return null + return { start, end } +} + function ensureStreamingBubble(sessionId) { if (state.streaming && state.streaming.sessionId === sessionId) return state.streaming.el // Ensure the turn container is open before the bubble drops in so diff --git a/examples/desktop/src/renderer/style.css b/examples/desktop/src/renderer/style.css index 0f98784ee6..ec5e34abd3 100644 --- a/examples/desktop/src/renderer/style.css +++ b/examples/desktop/src/renderer/style.css @@ -11510,6 +11510,64 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 .trace-detail-field-block[open] > .trace-detail-field-block-head::before { transform: rotate(90deg); } .trace-detail-field-block > .trace-detail-field-block-head:hover { background: var(--surface-hover); } +/* --- trace signal badges (lane-trace-signals; see docs/upstream-ledger.md L-2) -- + * The detector runs renderer-side until upstream emits `trace/signal` events. + * Colors follow the palette: red=error/loop, amber=redundant, blue=plan. + * Multiple signals stack horizontally on the Timeline; the Graph uses a single + * outer ring per node (highest-priority signal wins the color). + */ +.trace-timeline-signal-badge { stroke: rgba(0,0,0,0.15); stroke-width: 0.75; } +.trace-timeline-signal-badge.sig-error { fill: #dc2626; } +.trace-timeline-signal-badge.sig-loop { fill: #ef4444; } +.trace-timeline-signal-badge.sig-redundant{ fill: #f59e0b; } +.trace-timeline-signal-badge.sig-plan { fill: #2563eb; } +.trace-timeline-signal-badge.sig-plan-restart { fill: #1d4ed8; } +.trace-timeline-signal-badge.sig-generic { fill: #6b7280; } + +.trace-graph-signal-ring { stroke-width: 2.5; } +.trace-graph-signal-ring.sig-error { stroke: #dc2626; } +.trace-graph-signal-ring.sig-loop { stroke: #ef4444; } +.trace-graph-signal-ring.sig-redundant{ stroke: #f59e0b; } +.trace-graph-signal-ring.sig-plan { stroke: #2563eb; } +.trace-graph-signal-ring.sig-plan-restart { stroke: #1d4ed8; } +.trace-graph-signal-ring.sig-generic { stroke: #6b7280; } + +/* Main-flow marker chips — small pill row above the assistant turn body */ +.turn-signal-chip-row { + display: flex; flex-wrap: wrap; gap: 6px; + padding: 4px 0 6px 0; + align-items: center; +} +.turn-signal-chip { + font-size: 11px; line-height: 1; + padding: 3px 8px; border-radius: 10px; + border: 1px solid transparent; + background: rgba(0,0,0,0.03); + color: #1d1d1f; + cursor: pointer; + font-family: inherit; +} +.turn-signal-chip:hover { filter: brightness(0.96); } +.turn-signal-chip::before { + content: ''; display: inline-block; width: 6px; height: 6px; + border-radius: 50%; margin-right: 6px; vertical-align: 1px; + background: currentColor; +} +.turn-signal-chip.sig-error { color: #b91c1c; border-color: rgba(220,38,38,0.35); background: rgba(220,38,38,0.06); } +.turn-signal-chip.sig-loop { color: #b91c1c; border-color: rgba(239,68,68,0.35); background: rgba(239,68,68,0.06); } +.turn-signal-chip.sig-redundant { color: #92400e; border-color: rgba(245,158,11,0.4); background: rgba(245,158,11,0.08); } +.turn-signal-chip.sig-plan { color: #1d4ed8; border-color: rgba(37,99,235,0.35); background: rgba(37,99,235,0.06); } +.turn-signal-chip.sig-plan-restart{ color: #1e3a8a; border-color: rgba(29,78,216,0.4); background: rgba(29,78,216,0.08); } + +/* Dark theme reads the badges/chips against the dark surface, so bump the + * saturation a touch — the palette is designed for both. */ +@media (prefers-color-scheme: dark) { + .turn-signal-chip { background: rgba(255,255,255,0.05); color: #f3f4f6; } + .turn-signal-chip.sig-error, .turn-signal-chip.sig-loop { color: #fca5a5; } + .turn-signal-chip.sig-redundant { color: #fcd34d; } + .turn-signal-chip.sig-plan, .turn-signal-chip.sig-plan-restart { color: #93c5fd; } +} + /* --- onboarding overlay: first-frame FOUC guard ------------------------ */ /* index.html declares