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.
This commit is contained in:
@@ -1323,6 +1323,7 @@
|
||||
<script src="./inject-family.js"></script>
|
||||
<script src="./raw-inject.js"></script><!-- ticket #15 B: envelope:'raw' classifier -->
|
||||
<script src="./trace-aggregator.js"></script>
|
||||
<script src="./trace-signal-detect.js"></script><!-- lane-trace-signals: heuristic signal detector (see docs/upstream-ledger.md L-2) -->
|
||||
<script src="./trace-timeline.js"></script><!-- task #203: view B Gantt/waterfall -->
|
||||
<script src="./trace-graph.js"></script><!-- task #203: view C agent node graph -->
|
||||
<script src="./trace-detail-pane.js"></script><!-- task #205: right-side detail pane (4 tabs) -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <div id="onboarding" class="onboarding" hidden> and */
|
||||
/* onboarding-ui.js clears the [hidden] attribute only after a first-run */
|
||||
|
||||
@@ -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',
|
||||
|
||||
381
examples/desktop/src/renderer/trace-signal-detect.js
Normal file
381
examples/desktop/src/renderer/trace-signal-detect.js
Normal file
@@ -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<seq, Signal[]>, 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
|
||||
}
|
||||
})()
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user