')
+ 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 and */
/* onboarding-ui.js clears the [hidden] attribute only after a first-run */
diff --git a/examples/desktop/src/renderer/trace-graph.js b/examples/desktop/src/renderer/trace-graph.js
index 3fef1eca92..e9f7586df2 100644
--- a/examples/desktop/src/renderer/trace-graph.js
+++ b/examples/desktop/src/renderer/trace-graph.js
@@ -372,6 +372,36 @@ function renderGraph(doc, input, opts) {
class: `trace-graph-node-body family-${node.family}`,
})
nodeG.appendChild(circle)
+
+ // Signal ring — a colored outer stroke when this node's seq shows up
+ // in the passed signals map. Multiple signals share one ring but the
+ // tooltip enumerates them. See trace-signal-detect.js + L-2 RFC.
+ if (options.signals && typeof options.signals.get === 'function' && node.seq !== null) {
+ const sigs = options.signals.get(node.seq)
+ if (sigs && sigs.length) {
+ // Highest-priority signal wins the ring class (error > loop > redundant > plan).
+ const priority = ['tool-error', 'loop-detected', 'plan-restart', 'redundant-call', 'plan-update']
+ let winner = sigs[0]
+ let winnerRank = 999
+ for (const s of sigs) {
+ const r = priority.indexOf(s.signal)
+ if (r >= 0 && r < winnerRank) { winner = s; winnerRank = r }
+ }
+ const SD = (typeof window !== 'undefined' && window.__dshTraceSignalDetect)
+ || (typeof require !== 'undefined' ? require('./trace-signal-detect.js') : null)
+ const cls = SD ? SD.classFor(winner.signal) : 'sig-generic'
+ const ring = svgEl(doc, 'circle', {
+ cx: 0, cy: 0, r: NODE_R + 4,
+ class: `trace-graph-signal-ring ${cls}`,
+ fill: 'none',
+ })
+ const title = svgEl(doc, 'title', {})
+ title.textContent = SD ? sigs.map(s => SD.tooltipFor(s)).join('\n') : sigs.map(s => s.signal).join(', ')
+ ring.appendChild(title)
+ nodeG.appendChild(ring)
+ }
+ }
+
const glyph = svgEl(doc, 'text', {
x: 0, y: 4, class: 'trace-graph-node-glyph',
'text-anchor': 'middle',
diff --git a/examples/desktop/src/renderer/trace-signal-detect.js b/examples/desktop/src/renderer/trace-signal-detect.js
new file mode 100644
index 0000000000..8b977698b1
--- /dev/null
+++ b/examples/desktop/src/renderer/trace-signal-detect.js
@@ -0,0 +1,381 @@
+// trace-signal-detect.js — heuristic detector for "trace signals" that a
+// researcher wants highlighted in the trace tri-view + the main assistant
+// stream. This is a renderer-side workaround for a gap in the runtime wire:
+// the SDK does not (yet) emit `type: 'trace/signal'` events, so we scan the
+// event stream ourselves and mark four kinds of interesting nodes.
+//
+// See docs/upstream-ledger.md L-2 for the RFC that would remove this file
+// from the critical path — once upstream emits real signal events, this
+// module becomes dead code (the DOM overlay path already checks `_wireSignal`
+// first and only falls back to detected signals when the wire is silent).
+//
+// The four signal kinds:
+//
+// loop-detected — >= N consecutive tool/call events with the same tool
+// name AND the same args-prefix. Default N = 3.
+// Reported on the SECOND repeat's seq (so a reader sees
+// the badge as soon as the loop becomes suspicious).
+// redundant-call — a tool/call whose (name, args-prefix) exactly matches
+// a call within the last WINDOW seqs. Default WINDOW = 8.
+// A true duplicate is often benign (retries, polling),
+// but a researcher wants it flagged because it's the
+// shape of accidental re-work. Not reported when the
+// loop-detected badge already covers the same seq.
+// plan-update — assistant/message text mentions a new plan (keywords:
+// "new plan", "revised plan", "updated plan", "here's the
+// plan", "plan:", numbered "1." at line start). Heuristic
+// — never claimed to be authoritative. The DOM overlay
+// surfaces the badge with an "heuristic" tooltip.
+// plan-restart — the same tool retried after a tool/result error
+// (ok:false). Signals "the plan is being restarted after
+// a failure" — often the first place a reader wants to
+// look when debugging a stuck agent.
+// tool-error — tool/result with ok:false. Already visible via the ✗
+// glyph, but we surface a red badge on Timeline/Graph so
+// a full-session view has the error nodes stand out.
+//
+// The detector is pure: it takes `events` (a flat array from
+// session.cachedEvents), returns `{ bySeq: Map, all: Signal[] }`.
+// Each Signal is { signal, seq, meta } where `meta` names the offending
+// tools / prior seqs / snippet so the tooltip can be informative.
+//
+// Deliberately lightweight — no dep on trace-aggregator so the detector
+// can run on raw wire without step boundaries.
+
+'use strict'
+
+;(function () {
+ const DEFAULT_LOOP_N = 3
+ const DEFAULT_REDUNDANT_WINDOW = 8
+ const PLAN_KEYWORDS = [
+ 'new plan',
+ 'revised plan',
+ 'updated plan',
+ 'here\'s the plan',
+ 'here is the plan',
+ 'the plan is now',
+ 'let me revise',
+ 'let me update the plan',
+ ]
+
+ function detectSignals(events, opts) {
+ const options = opts || {}
+ const loopN = Number.isFinite(options.loopN) && options.loopN >= 2 ? options.loopN : DEFAULT_LOOP_N
+ const window = Number.isFinite(options.window) && options.window >= 2 ? options.window : DEFAULT_REDUNDANT_WINDOW
+ const list = Array.isArray(events) ? events : []
+ const all = []
+ const bySeq = new Map()
+
+ // Wire-pass first: if any event is already a signal from the runtime
+ // (post-RFC L-2), consume it verbatim and skip heuristic detection for
+ // that seq. Keeps this module a no-op once upstream lands the fix.
+ for (const ev of list) {
+ if (ev && ev.type === 'trace/signal' && ev.data && typeof ev.data.signal === 'string') {
+ const sig = {
+ signal: ev.data.signal,
+ seq: typeof ev.seq === 'number' ? ev.seq : null,
+ meta: Object.assign({ source: 'wire' }, ev.data),
+ }
+ emit(all, bySeq, sig)
+ }
+ }
+
+ // Rolling window over tool/call events for loop + redundant detection.
+ const recentCalls = [] // { seq, name, argsKey }
+ let lastToolError = null // { seq, name, callId }
+
+ for (let i = 0; i < list.length; i++) {
+ const ev = list[i]
+ if (!ev || typeof ev !== 'object') continue
+
+ // Loop / redundant — tool/call ordering
+ if (ev.type === 'tool/call') {
+ const name = ev.data && typeof ev.data.name === 'string' ? ev.data.name : ''
+ const argsKey = _argsKey(ev.data)
+ const seq = typeof ev.seq === 'number' ? ev.seq : null
+ const cur = { seq, name, argsKey, callId: ev.data && ev.data.callId }
+
+ // loop-detected: look back at recentCalls tail for a run of
+ // (name, argsKey) matches. When length >= loopN including cur,
+ // flag cur's seq. Use `argsPrefix` (first 80 chars) so a call that
+ // varies only in a trailing timestamp still matches.
+ let run = 1
+ for (let j = recentCalls.length - 1; j >= 0; j--) {
+ const prev = recentCalls[j]
+ if (prev.name === name && prev.argsKey === argsKey) run++
+ else break
+ }
+ if (run >= loopN && seq !== null) {
+ emit(all, bySeq, {
+ signal: 'loop-detected',
+ seq,
+ meta: { source: 'heuristic', name, argsKey, run, priorSeqs: _lastNPriorSeqs(recentCalls, run - 1) },
+ })
+ }
+
+ // redundant-call: exact (name, argsKey) match within the last N calls,
+ // NOT counting the immediate consecutive run (that's loop-detected).
+ // Only flag when there is at least one intervening different call, so
+ // we don't double-flag a pure loop.
+ if (seq !== null && run < loopN) {
+ for (let j = recentCalls.length - 1; j >= 0 && recentCalls.length - j <= window; j--) {
+ const prev = recentCalls[j]
+ if (prev.name === name && prev.argsKey === argsKey && (recentCalls.length - 1 - j) >= 1) {
+ // Ensure at least one call between prev and cur has a different key.
+ let interleaved = false
+ for (let k = j + 1; k < recentCalls.length; k++) {
+ if (recentCalls[k].name !== name || recentCalls[k].argsKey !== argsKey) { interleaved = true; break }
+ }
+ if (interleaved) {
+ emit(all, bySeq, {
+ signal: 'redundant-call',
+ seq,
+ meta: { source: 'heuristic', name, argsKey, priorSeq: prev.seq },
+ })
+ break
+ }
+ }
+ }
+ }
+
+ // plan-restart: same tool re-invoked after a recent tool/result error.
+ // Guard: current callId must differ from the errored one (a wire replay
+ // of the same call shouldn't count as a restart).
+ if (lastToolError && lastToolError.name === name && seq !== null
+ && cur.callId !== lastToolError.callId) {
+ emit(all, bySeq, {
+ signal: 'plan-restart',
+ seq,
+ meta: { source: 'heuristic', name, priorErrorSeq: lastToolError.seq },
+ })
+ lastToolError = null
+ }
+
+ recentCalls.push(cur)
+ // Cap window to keep the scan O(N) — recentCalls only needs to hold
+ // the last `window` entries.
+ if (recentCalls.length > window * 2) recentCalls.splice(0, recentCalls.length - window * 2)
+ continue
+ }
+
+ // Tool errors
+ if (ev.type === 'tool/result' && ev.data && ev.data.ok === false) {
+ const seq = typeof ev.seq === 'number' ? ev.seq : null
+ const name = ev.data && typeof ev.data.name === 'string' ? ev.data.name
+ : _findCallName(list, ev.data && ev.data.callId)
+ // Emit on the result seq AND the matching call seq. Timeline pairs
+ // call+result into one bar keyed by the call's seq, and the Graph
+ // absorbs the result into the call node — so the call seq is the
+ // seq a reader actually sees. Result seq is kept for tree/detail
+ // rendering that lists events individually.
+ const callSeq = _findCallSeq(list, ev.data && ev.data.callId)
+ const errorMeta = { source: 'wire', name, callId: ev.data.callId, error: ev.data.error || null }
+ if (seq !== null) {
+ emit(all, bySeq, { signal: 'tool-error', seq, meta: errorMeta })
+ }
+ if (callSeq !== null && callSeq !== seq) {
+ emit(all, bySeq, { signal: 'tool-error', seq: callSeq, meta: errorMeta })
+ }
+ lastToolError = { seq, name, callId: ev.data && ev.data.callId }
+ continue
+ }
+
+ // plan-update: assistant text mentioning "new plan" / "revised plan".
+ if (ev.type === 'assistant/message') {
+ const seq = typeof ev.seq === 'number' ? ev.seq : null
+ const text = _assistantText(ev.data)
+ if (seq !== null && _looksLikePlanUpdate(text)) {
+ emit(all, bySeq, {
+ signal: 'plan-update',
+ seq,
+ meta: { source: 'heuristic', snippet: _trim(text, 80) },
+ })
+ }
+ continue
+ }
+ }
+
+ return { bySeq, all }
+ }
+
+ function emit(all, bySeq, sig) {
+ // Dedup: don't emit the same signal kind on the same seq twice.
+ const list = bySeq.get(sig.seq) || []
+ for (const prev of list) if (prev.signal === sig.signal) return
+ list.push(sig)
+ bySeq.set(sig.seq, list)
+ all.push(sig)
+ }
+
+ function _argsKey(data) {
+ if (!data) return ''
+ // Prefer a stable JSON of the arguments; fall back to string coercion.
+ // Truncate to 80 chars so we don't burn memory on huge blobs, and so
+ // small trailing-timestamp diffs still coalesce.
+ try {
+ const raw = data.arguments != null ? data.arguments
+ : data.args != null ? data.args : ''
+ const s = typeof raw === 'string' ? raw : JSON.stringify(raw)
+ return _trim(String(s || ''), 80)
+ } catch (_) {
+ return _trim(String(data.arguments || data.args || ''), 80)
+ }
+ }
+
+ function _assistantText(data) {
+ if (!data) return ''
+ if (Array.isArray(data.content)) {
+ const parts = []
+ for (const block of data.content) {
+ if (block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
+ parts.push(block.text)
+ }
+ }
+ return parts.join('\n')
+ }
+ if (typeof data.text === 'string') return data.text
+ return ''
+ }
+
+ function _looksLikePlanUpdate(text) {
+ if (typeof text !== 'string' || !text) return false
+ const lower = text.toLowerCase()
+ for (const kw of PLAN_KEYWORDS) if (lower.includes(kw)) return true
+ // Numbered plan intro: "1. …\n2. …" occurring in the first 200 chars.
+ // Requires two adjacent numbered lines to reduce false positives from
+ // enumerations inside prose.
+ const head = text.slice(0, 400)
+ const m = head.match(/(^|\n)\s*1\.\s.+\n\s*2\.\s/)
+ if (m) return true
+ return false
+ }
+
+ function _findCallName(events, callId) {
+ if (!callId) return ''
+ for (let i = events.length - 1; i >= 0; i--) {
+ const ev = events[i]
+ if (ev && ev.type === 'tool/call' && ev.data && ev.data.callId === callId) {
+ return typeof ev.data.name === 'string' ? ev.data.name : ''
+ }
+ }
+ return ''
+ }
+
+ function _findCallSeq(events, callId) {
+ if (!callId) return null
+ for (let i = events.length - 1; i >= 0; i--) {
+ const ev = events[i]
+ if (ev && ev.type === 'tool/call' && ev.data && ev.data.callId === callId
+ && typeof ev.seq === 'number') return ev.seq
+ }
+ return null
+ }
+
+ function _lastNPriorSeqs(list, n) {
+ const out = []
+ for (let i = list.length - 1; i >= 0 && out.length < n; i--) {
+ if (typeof list[i].seq === 'number') out.unshift(list[i].seq)
+ }
+ return out
+ }
+
+ function _trim(s, n) {
+ if (typeof s !== 'string') return ''
+ if (s.length <= n) return s
+ return s.slice(0, n - 1) + '…'
+ }
+
+ // Given a step-record (or array of them from the aggregator), flatten
+ // the outputs+inputs+events into one seq-ordered event list and detect.
+ // Consumers on the tri-view side prefer this shape because they hold
+ // records, not raw events.
+ function detectSignalsFromRecords(records, opts) {
+ const list = Array.isArray(records) ? records : (records ? [records] : [])
+ const flat = []
+ const seen = new Set()
+ for (const rec of list) {
+ if (!rec) continue
+ for (const bucket of ['inputs', 'outputs', 'events']) {
+ const arr = rec[bucket]
+ if (!Array.isArray(arr)) continue
+ for (const ev of arr) {
+ if (!ev || typeof ev !== 'object') continue
+ const key = typeof ev.seq === 'number' ? `s${ev.seq}` : `t${ev.type}|${flat.length}`
+ if (seen.has(key)) continue
+ seen.add(key)
+ flat.push(ev)
+ }
+ }
+ }
+ flat.sort(function (a, b) {
+ const sa = typeof a.seq === 'number' ? a.seq : Infinity
+ const sb = typeof b.seq === 'number' ? b.seq : Infinity
+ return sa - sb
+ })
+ return detectSignals(flat, opts)
+ }
+
+ // Human-readable label for a signal — used by tooltip helpers on both
+ // Timeline/Graph badges and the main-flow chip.
+ function labelFor(signal) {
+ switch (signal) {
+ case 'loop-detected': return 'Loop detected'
+ case 'redundant-call': return 'Redundant call'
+ case 'plan-update': return 'Plan update'
+ case 'plan-restart': return 'Plan restart'
+ case 'tool-error': return 'Tool error'
+ default: return signal || 'Signal'
+ }
+ }
+
+ // CSS class-name suffix — kept aligned with the badge styles in style.css.
+ function classFor(signal) {
+ switch (signal) {
+ case 'loop-detected': return 'sig-loop'
+ case 'redundant-call': return 'sig-redundant'
+ case 'plan-update': return 'sig-plan'
+ case 'plan-restart': return 'sig-plan-restart'
+ case 'tool-error': return 'sig-error'
+ default: return 'sig-generic'
+ }
+ }
+
+ function tooltipFor(sig) {
+ if (!sig) return ''
+ const parts = [labelFor(sig.signal)]
+ const m = sig.meta || {}
+ if (sig.signal === 'loop-detected' && m.name) {
+ parts.push(`${m.run || '?'} × ${m.name}`)
+ if (Array.isArray(m.priorSeqs) && m.priorSeqs.length) {
+ parts.push(`prior seq ${m.priorSeqs.join(', ')}`)
+ }
+ } else if (sig.signal === 'redundant-call' && m.name) {
+ parts.push(`${m.name}, matches seq ${m.priorSeq}`)
+ } else if (sig.signal === 'plan-update' && m.snippet) {
+ parts.push(`“${m.snippet}”`)
+ } else if (sig.signal === 'plan-restart' && m.name) {
+ parts.push(`retry ${m.name} after seq ${m.priorErrorSeq}`)
+ } else if (sig.signal === 'tool-error' && m.name) {
+ parts.push(m.name + (m.error ? `: ${_trim(String(m.error), 60)}` : ''))
+ }
+ if (m.source === 'heuristic') parts.push('(heuristic)')
+ return parts.join(' · ')
+ }
+
+ const api = {
+ detectSignals,
+ detectSignalsFromRecords,
+ labelFor,
+ classFor,
+ tooltipFor,
+ // Exposed for tests / diagnostics only.
+ _internals: { _argsKey, _looksLikePlanUpdate },
+ }
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api
+ }
+ if (typeof window !== 'undefined') {
+ window.__dshTraceSignalDetect = api
+ }
+})()
diff --git a/examples/desktop/src/renderer/trace-timeline.js b/examples/desktop/src/renderer/trace-timeline.js
index e92566cb24..5a92e0a28a 100644
--- a/examples/desktop/src/renderer/trace-timeline.js
+++ b/examples/desktop/src/renderer/trace-timeline.js
@@ -338,6 +338,45 @@ function renderTimeline(doc, input, opts) {
svg.appendChild(g)
}
+ // Signal badges — small filled circles hanging off the right edge of the
+ // label column for any row whose seq matches a detected/emitted signal.
+ // The `signals` option is a bySeq Map from trace-signal-detect.js. We keep
+ // this render entirely optional so tests + non-signal callers stay
+ // untouched; when no map is supplied nothing is drawn. See L-2 in
+ // docs/upstream-ledger.md for the RFC that would move detection upstream.
+ if (options.signals && typeof options.signals.get === 'function') {
+ const badgesG = svgGroup(doc, 'trace-timeline-badges')
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i]
+ if (row.seq === null) continue
+ const sigs = options.signals.get(row.seq)
+ if (!sigs || !sigs.length) continue
+ const y = topPad + i * rowH + rowH * 0.5
+ // Stack badges horizontally so multiple signals on one seq stay
+ // readable. The seed x sits just inside the label column so the
+ // badge visually "labels" the row without eating bar space.
+ let bx = labelW - 10
+ for (const sig of sigs) {
+ const cls = (typeof window !== 'undefined' && window.__dshTraceSignalDetect)
+ ? window.__dshTraceSignalDetect.classFor(sig.signal)
+ : (typeof require !== 'undefined' ? require('./trace-signal-detect.js').classFor(sig.signal) : 'sig-generic')
+ const tip = (typeof window !== 'undefined' && window.__dshTraceSignalDetect)
+ ? window.__dshTraceSignalDetect.tooltipFor(sig)
+ : (typeof require !== 'undefined' ? require('./trace-signal-detect.js').tooltipFor(sig) : sig.signal)
+ const badge = svgEl(doc, 'circle', {
+ cx: bx, cy: y, r: 4,
+ class: `trace-timeline-signal-badge ${cls}`,
+ })
+ const title = svgEl(doc, 'title', {})
+ title.textContent = tip
+ badge.appendChild(title)
+ badgesG.appendChild(badge)
+ bx -= 10
+ }
+ }
+ svg.appendChild(badgesG)
+ }
+
// Live cursor: hairline at nowMs during streaming.
if (typeof options.nowMs === 'number' && Number.isFinite(options.nowMs)) {
const nx = labelW + xScale(options.nowMs)
diff --git a/examples/desktop/src/renderer/trace-tri-view.js b/examples/desktop/src/renderer/trace-tri-view.js
index 2e9d0438b7..5f2be9a542 100644
--- a/examples/desktop/src/renderer/trace-tri-view.js
+++ b/examples/desktop/src/renderer/trace-tri-view.js
@@ -117,6 +117,12 @@
}
openDetailForSeq(seq, meta)
}
+ // Signals map (bySeq) is derived once from the tri-view's records and
+ // passed to both Timeline + Graph so badge/ring placement stays
+ // consistent across view switches. See trace-signal-detect.js and
+ // docs/upstream-ledger.md L-2 for the RFC that would replace this
+ // renderer-side detection with wire-emitted `trace/signal` events.
+ const signalMap = _computeSignals(spec.records)
function ensureTimeline() {
if (builtTimeline) return
builtTimeline = true
@@ -126,6 +132,7 @@
onSeqClick: handleNodeClick,
nowMs: typeof spec.nowMs === 'number' ? spec.nowMs : undefined,
width: spec.scope === 'session' ? 860 : 720,
+ signals: signalMap,
})
panelEls.timeline.appendChild(el)
}
@@ -134,7 +141,10 @@
builtGraph = true
const G = (typeof window !== 'undefined' && window.__dshTraceGraph) || null
if (!G) { panelEls.graph.textContent = 'trace-graph.js not loaded'; return }
- const el = G.renderGraph(doc, spec.records || [], { onSeqClick: handleNodeClick })
+ const el = G.renderGraph(doc, spec.records || [], {
+ onSeqClick: handleNodeClick,
+ signals: signalMap,
+ })
panelEls.graph.appendChild(el)
}
@@ -254,6 +264,18 @@
return agg.aggregateSteps(Array.isArray(events) ? events : [])
}
+ // Derive the signals bySeq map from a set of step-records. Falls back to
+ // an empty Map when the detector module isn't loaded, so a lean test env
+ // that only pulls tri-view keeps working.
+ function _computeSignals(records) {
+ const SD = (typeof window !== 'undefined' && window.__dshTraceSignalDetect) || null
+ if (!SD || typeof SD.detectSignalsFromRecords !== 'function') return new Map()
+ try {
+ const res = SD.detectSignalsFromRecords(records)
+ return (res && res.bySeq) || new Map()
+ } catch (_) { return new Map() }
+ }
+
// Pick the step-record whose seq range contains `seq`, preferring an
// exact startSeq match. Falls back to the closest containing range, then
// to the last record before `seq`. Returns null when nothing matches.
diff --git a/examples/desktop/test/trace-signal-detect.test.js b/examples/desktop/test/trace-signal-detect.test.js
new file mode 100644
index 0000000000..44ca293d17
--- /dev/null
+++ b/examples/desktop/test/trace-signal-detect.test.js
@@ -0,0 +1,194 @@
+// trace-signal-detect.test.js — heuristic detector unit tests.
+//
+// Detector is pure and used by both the tri-view SVG overlays and the main
+// flow marker chips (see renderer.js `applyTurnSignalChips`). Every branch
+// has a fixture-driven assertion so a future refactor breaks visibly.
+
+'use strict'
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const SD = require('../src/renderer/trace-signal-detect.js')
+
+test('loop-detected fires after N consecutive same-tool same-args calls', () => {
+ const events = []
+ for (let i = 0; i < 3; i++) {
+ events.push({
+ type: 'tool/call', seq: 10 + i,
+ data: { name: 'fs.read', arguments: '{"path":"a.ts"}', callId: `c${i}` },
+ })
+ }
+ const { bySeq, all } = SD.detectSignals(events, { loopN: 3 })
+ const loops = all.filter(s => s.signal === 'loop-detected')
+ assert.strictEqual(loops.length, 1, 'exactly one loop-detected on the 3rd call')
+ assert.strictEqual(loops[0].seq, 12)
+ assert.strictEqual(loops[0].meta.run, 3)
+ assert.strictEqual(loops[0].meta.name, 'fs.read')
+ assert.ok(bySeq.get(12).find(s => s.signal === 'loop-detected'))
+})
+
+test('loop-detected does not fire when args differ', () => {
+ const events = [
+ { type: 'tool/call', seq: 1, data: { name: 'fs.read', arguments: '{"path":"a"}', callId: '1' } },
+ { type: 'tool/call', seq: 2, data: { name: 'fs.read', arguments: '{"path":"b"}', callId: '2' } },
+ { type: 'tool/call', seq: 3, data: { name: 'fs.read', arguments: '{"path":"c"}', callId: '3' } },
+ ]
+ const { all } = SD.detectSignals(events, { loopN: 3 })
+ const loops = all.filter(s => s.signal === 'loop-detected')
+ assert.strictEqual(loops.length, 0, 'name matches but args differ — not a loop')
+})
+
+test('redundant-call fires when a call repeats with an interleaved other call between', () => {
+ const events = [
+ { type: 'tool/call', seq: 1, data: { name: 'fs.read', arguments: '{"path":"a"}', callId: '1' } },
+ { type: 'tool/call', seq: 2, data: { name: 'bash', arguments: '{"cmd":"ls"}', callId: '2' } },
+ { type: 'tool/call', seq: 3, data: { name: 'fs.read', arguments: '{"path":"a"}', callId: '3' } },
+ ]
+ const { all } = SD.detectSignals(events)
+ const red = all.filter(s => s.signal === 'redundant-call')
+ assert.strictEqual(red.length, 1)
+ assert.strictEqual(red[0].seq, 3)
+ assert.strictEqual(red[0].meta.priorSeq, 1)
+})
+
+test('redundant-call does NOT fire when the repeat is consecutive (that\'s a loop, not redundancy)', () => {
+ const events = [
+ { type: 'tool/call', seq: 1, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: '1' } },
+ { type: 'tool/call', seq: 2, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: '2' } },
+ ]
+ const { all } = SD.detectSignals(events, { loopN: 3 })
+ const red = all.filter(s => s.signal === 'redundant-call')
+ assert.strictEqual(red.length, 0, '2 same-in-a-row is not yet a loop and not yet redundant')
+})
+
+test('plan-update fires on assistant text with "new plan" keyword', () => {
+ const events = [
+ {
+ type: 'assistant/message', seq: 20,
+ data: { content: [{ type: 'text', text: 'Okay, here is the new plan: first read the file, then edit it.' }] },
+ },
+ ]
+ const { all } = SD.detectSignals(events)
+ const plan = all.filter(s => s.signal === 'plan-update')
+ assert.strictEqual(plan.length, 1)
+ assert.strictEqual(plan[0].seq, 20)
+ assert.match(plan[0].meta.snippet, /new plan/i)
+ assert.strictEqual(plan[0].meta.source, 'heuristic')
+})
+
+test('plan-update fires on a numbered-plan intro (two adjacent numbered lines)', () => {
+ const events = [
+ {
+ type: 'assistant/message', seq: 30,
+ data: { content: [{ type: 'text', text: '1. Read main.ts\n2. Edit imports\n3. Verify.' }] },
+ },
+ ]
+ const { all } = SD.detectSignals(events)
+ const plan = all.filter(s => s.signal === 'plan-update')
+ assert.strictEqual(plan.length, 1)
+ assert.strictEqual(plan[0].seq, 30)
+})
+
+test('plan-update does NOT fire on plain prose without plan keywords', () => {
+ const events = [
+ {
+ type: 'assistant/message', seq: 40,
+ data: { content: [{ type: 'text', text: 'The file looks fine. No changes needed.' }] },
+ },
+ ]
+ const { all } = SD.detectSignals(events)
+ assert.strictEqual(all.filter(s => s.signal === 'plan-update').length, 0)
+})
+
+test('tool-error signal fires on tool/result with ok:false — on BOTH the result seq and the matching call seq', () => {
+ const events = [
+ { type: 'tool/call', seq: 5, data: { name: 'bash', arguments: 'ls /nope', callId: 'c1' } },
+ { type: 'tool/result', seq: 6, data: { callId: 'c1', ok: false, error: 'ENOENT: /nope' } },
+ ]
+ const { all, bySeq } = SD.detectSignals(events)
+ const err = all.filter(s => s.signal === 'tool-error')
+ assert.strictEqual(err.length, 2, 'one badge on the call seq, one on the result seq')
+ const seqs = err.map(e => e.seq).sort()
+ assert.deepStrictEqual(seqs, [5, 6])
+ assert.ok(bySeq.get(5).find(s => s.signal === 'tool-error'), 'call seq carries the tool-error signal')
+ assert.ok(bySeq.get(6).find(s => s.signal === 'tool-error'), 'result seq carries the tool-error signal')
+ for (const e of err) assert.strictEqual(e.meta.name, 'bash')
+})
+
+test('plan-restart fires when the same tool is re-invoked after an error', () => {
+ const events = [
+ { type: 'tool/call', seq: 1, data: { name: 'bash', arguments: 'ls /nope', callId: 'c1' } },
+ { type: 'tool/result', seq: 2, data: { callId: 'c1', ok: false, error: 'ENOENT' } },
+ { type: 'tool/call', seq: 3, data: { name: 'bash', arguments: 'ls /tmp', callId: 'c2' } },
+ ]
+ const { all } = SD.detectSignals(events)
+ const restart = all.filter(s => s.signal === 'plan-restart')
+ assert.strictEqual(restart.length, 1)
+ assert.strictEqual(restart[0].seq, 3)
+ assert.strictEqual(restart[0].meta.priorErrorSeq, 2)
+})
+
+test('wire-side signals (trace/signal events) are consumed verbatim and marked source:wire', () => {
+ const events = [
+ {
+ type: 'trace/signal', seq: 100,
+ data: { signal: 'loop-detected', name: 'fs.read', run: 5 },
+ },
+ ]
+ const { all } = SD.detectSignals(events)
+ assert.strictEqual(all.length, 1)
+ assert.strictEqual(all[0].signal, 'loop-detected')
+ assert.strictEqual(all[0].seq, 100)
+ assert.strictEqual(all[0].meta.source, 'wire')
+})
+
+test('detectSignalsFromRecords flattens step records back to a seq-ordered event list', () => {
+ const rec = {
+ turn: 1, step: 0, startSeq: 10, endSeq: 15,
+ inputs: [],
+ outputs: [
+ { type: 'tool/call', seq: 12, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c1' } },
+ ],
+ events: [
+ { type: 'tool/call', seq: 12, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c1' } },
+ { type: 'tool/call', seq: 13, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c2' } },
+ { type: 'tool/call', seq: 14, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c3' } },
+ ],
+ }
+ const { all } = SD.detectSignalsFromRecords(rec, { loopN: 3 })
+ const loops = all.filter(s => s.signal === 'loop-detected')
+ assert.strictEqual(loops.length, 1, 'dedup on seq means only one entry per event')
+ assert.strictEqual(loops[0].seq, 14)
+})
+
+test('labelFor and classFor return the expected mappings', () => {
+ assert.strictEqual(SD.labelFor('loop-detected'), 'Loop detected')
+ assert.strictEqual(SD.labelFor('redundant-call'), 'Redundant call')
+ assert.strictEqual(SD.labelFor('plan-update'), 'Plan update')
+ assert.strictEqual(SD.labelFor('plan-restart'), 'Plan restart')
+ assert.strictEqual(SD.labelFor('tool-error'), 'Tool error')
+
+ assert.strictEqual(SD.classFor('loop-detected'), 'sig-loop')
+ assert.strictEqual(SD.classFor('redundant-call'), 'sig-redundant')
+ assert.strictEqual(SD.classFor('plan-update'), 'sig-plan')
+ assert.strictEqual(SD.classFor('plan-restart'), 'sig-plan-restart')
+ assert.strictEqual(SD.classFor('tool-error'), 'sig-error')
+})
+
+test('tooltipFor produces a readable tooltip for each signal kind', () => {
+ const tip1 = SD.tooltipFor({
+ signal: 'loop-detected', seq: 12,
+ meta: { source: 'heuristic', name: 'fs.read', run: 3, priorSeqs: [10, 11] },
+ })
+ assert.match(tip1, /Loop detected/)
+ assert.match(tip1, /fs\.read/)
+ assert.match(tip1, /heuristic/)
+
+ const tip2 = SD.tooltipFor({
+ signal: 'plan-update', seq: 20,
+ meta: { source: 'heuristic', snippet: 'here is the new plan…' },
+ })
+ assert.match(tip2, /Plan update/)
+ assert.match(tip2, /new plan/i)
+})
diff --git a/examples/desktop/test/trace-signal-overlay.test.js b/examples/desktop/test/trace-signal-overlay.test.js
new file mode 100644
index 0000000000..1b3c9b0c80
--- /dev/null
+++ b/examples/desktop/test/trace-signal-overlay.test.js
@@ -0,0 +1,141 @@
+// trace-signal-overlay.test.js — Timeline + Graph rendering with a
+// `signals` bySeq Map should drop badges/rings and expose the tooltip.
+
+'use strict'
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const T = require('../src/renderer/trace-timeline.js')
+const G = require('../src/renderer/trace-graph.js')
+const SD = require('../src/renderer/trace-signal-detect.js')
+
+function makeDoc() {
+ function makeEl(nsOrTag, tag) {
+ const cls = { _s: new Set(),
+ add(c) { this._s.add(c) }, remove(c) { this._s.delete(c) },
+ toggle(c, on) { if (on) this._s.add(c); else this._s.delete(c) },
+ contains(c) { return this._s.has(c) },
+ }
+ const el = {
+ tagName: (tag || nsOrTag).toUpperCase(),
+ _children: [],
+ _attrs: {},
+ _listeners: {},
+ dataset: {},
+ style: {},
+ textContent: '',
+ hidden: false,
+ get className() { return Array.from(cls._s).join(' ') },
+ set className(v) {
+ cls._s.clear()
+ String(v || '').split(/\s+/).forEach(x => x && cls._s.add(x))
+ },
+ classList: cls,
+ appendChild(c) { this._children.push(c); return c },
+ append(...cs) { for (const c of cs) this._children.push(c) },
+ setAttribute(k, v) {
+ this._attrs[k] = String(v)
+ if (k === 'class') {
+ cls._s.clear()
+ String(v || '').split(/\s+/).forEach(x => x && cls._s.add(x))
+ }
+ },
+ getAttribute(k) { return this._attrs[k] },
+ addEventListener(name, fn) { (this._listeners[name] = this._listeners[name] || []).push(fn) },
+ querySelector(sel) { return null },
+ querySelectorAll() { return [] },
+ firstChild: null,
+ insertBefore(node) { this._children.unshift(node); return node },
+ outerHTML: '',
+ }
+ return el
+ }
+ return {
+ createElement(tag) { return makeEl(tag) },
+ createElementNS(ns, tag) { return makeEl(ns, tag) },
+ body: makeEl('body'),
+ }
+}
+
+function collectByClass(root, cls) {
+ const found = []
+ const stack = [root]
+ while (stack.length) {
+ const cur = stack.pop()
+ if (cur && cur.classList && cur.classList.contains(cls)) found.push(cur)
+ if (cur && cur._children) for (const c of cur._children) stack.push(c)
+ }
+ return found
+}
+
+test('renderTimeline drops one signal badge per row whose seq appears in the map', () => {
+ const doc = makeDoc()
+ const rec = {
+ turn: 1, step: 0, startSeq: 10, endSeq: 14,
+ startTime: 1000, endTime: 1500, durationMs: 500,
+ summary: 'read', inputs: [], outputs: [],
+ events: [
+ { type: 'tool/call', seq: 11, time: 1050, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c1' } },
+ { type: 'tool/call', seq: 12, time: 1100, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c2' } },
+ { type: 'tool/call', seq: 13, time: 1150, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c3' } },
+ ],
+ }
+ const { bySeq } = SD.detectSignalsFromRecords(rec, { loopN: 3 })
+ const el = T.renderTimeline(doc, rec, { signals: bySeq })
+ const badges = collectByClass(el, 'trace-timeline-signal-badge')
+ assert.ok(badges.length >= 1, 'at least one signal badge drawn for the loop-detected seq (12)')
+ // The badge for the loop-detected signal on seq 12 should carry the sig-loop class.
+ const loopBadges = badges.filter(b => b.classList.contains('sig-loop'))
+ assert.strictEqual(loopBadges.length, 1)
+ // Tooltip child is present
+ const title = loopBadges[0]._children.find(c => c.tagName === 'TITLE')
+ assert.ok(title, 'badge has a tooltip child')
+ assert.match(title.textContent, /Loop/)
+})
+
+test('renderTimeline draws no badges when signals map is empty', () => {
+ const doc = makeDoc()
+ const rec = {
+ turn: 1, step: 0, startSeq: 10, endSeq: 12,
+ startTime: 1000, endTime: 1200,
+ inputs: [], outputs: [],
+ events: [
+ { type: 'tool/call', seq: 11, time: 1050, data: { name: 'fs.read', arguments: '{}', callId: 'c1' } },
+ ],
+ }
+ const el = T.renderTimeline(doc, rec, { signals: new Map() })
+ const badges = collectByClass(el, 'trace-timeline-signal-badge')
+ assert.strictEqual(badges.length, 0)
+})
+
+test('renderGraph puts a colored ring around a node whose seq matches a signal', () => {
+ const doc = makeDoc()
+ const rec = {
+ turn: 1, step: 0, startSeq: 10, endSeq: 14,
+ startTime: 1000, endTime: 1500,
+ inputs: [], outputs: [],
+ events: [
+ { type: 'tool/call', seq: 11, time: 1050, data: { name: 'fs.read', arguments: '{"p":"a"}', callId: 'c1' } },
+ { type: 'tool/result', seq: 12, time: 1080, data: { callId: 'c1', ok: false, error: 'ENOENT' } },
+ ],
+ }
+ const { bySeq } = SD.detectSignalsFromRecords(rec)
+ const el = G.renderGraph(doc, rec, { signals: bySeq })
+ const rings = collectByClass(el, 'trace-graph-signal-ring')
+ assert.ok(rings.length >= 1, 'at least one signal ring around the tool-error seq')
+ const errRings = rings.filter(r => r.classList.contains('sig-error'))
+ assert.strictEqual(errRings.length, 1, 'ring wears the sig-error class')
+})
+
+test('renderGraph draws no rings without a signals map', () => {
+ const doc = makeDoc()
+ const rec = {
+ turn: 1, step: 0, startSeq: 10, endSeq: 12,
+ startTime: 1000, endTime: 1100,
+ inputs: [], outputs: [],
+ events: [{ type: 'tool/call', seq: 11, time: 1050, data: { name: 'x', arguments: '{}', callId: 'c' } }],
+ }
+ const el = G.renderGraph(doc, rec, {})
+ assert.strictEqual(collectByClass(el, 'trace-graph-signal-ring').length, 0)
+})