mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
fix(replay): real-run review — rows, system theme, fitted map, phone, export boot
From a review against real recorded runs: - Timeline: an agent's LLM span encloses its tool calls in real runs, so tool blocks covered the LLM label. Each lane now has an LLM row with a thinner tool row under it, blocks are packed into tracks in pixel space so nothing overlaps, and labels are ellipsized; the full label is in the title, the accessible name and a hover/focus tip. Visible labels sit in a decorative layer so names never contain a cut-off word. - Theme: defaults to the system setting (prefers-color-scheme, tracked live in CSS) with a System / Light / Dark menu as an override. - Map: fits the whole graph on open (shares -/Fit/+), drag to pan, never below a readable scale, and gets the full width with the step list underneath. Agents seen in events but not in the graph (e.g. a hand-off to an agent outside the team) are added by observedGraph() and laid out by layoutRunMap() as an "Outside the team" group. - Phone: 16 px gutter, stacked layout, no horizontal page scroll. - Standalone boot waits for DOMContentLoaded so the export works with either script order, takes data.run_id as the title, and keeps the documented #replay-root / window.__REPLAY_DATA__ contract. - New fixture team_outside.jsonl with the real-run shape; node tests for observedGraph/layoutRunMap; browser tests for no-overlap, the Why tab by role and keyboard, system theme, map fit, phone width and the wrapper-style export in both script orders. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0e26b0ec8d
commit
f1261958c8
6 files changed
+662
-107
No files matched your search
@@ -921,6 +921,109 @@ export function layoutMap(graph, opts = {}) {
|
||||
return { nodes, edges: outEdges, width, height: Math.max(height, backLane) + (backLane > height ? margin : 0), layers: layerCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph as recorded, completed with what actually happened: agents and
|
||||
* tools seen in the events but missing from the graph, and hand-offs the graph
|
||||
* does not draw. Agents from outside the graph (e.g. a hand-off to an agent
|
||||
* that is not a team member) are marked ``meta.outside`` so the map can group
|
||||
* them as "Outside the team". Without a recorded graph this is deriveGraph().
|
||||
*/
|
||||
export function observedGraph(graph, events) {
|
||||
const derived = deriveGraph(events);
|
||||
if (!graph || !Array.isArray(graph.nodes) || !graph.nodes.length) return derived;
|
||||
const nodes = graph.nodes.filter((n) => n && n.id).map((n) => ({ ...n, meta: { ...(n.meta || {}) } }));
|
||||
const edges = (Array.isArray(graph.edges) ? graph.edges : []).filter((e) => e && e.source && e.target).map((e) => ({ ...e }));
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const agentByLabel = new Map();
|
||||
for (const n of nodes) if (n.kind === 'agent' || n.kind === 'manager') agentByLabel.set(n.label || stripPrefix(n.id), n.id);
|
||||
const resolve = (id) => {
|
||||
if (ids.has(id)) return id;
|
||||
const name = stripPrefix(id);
|
||||
if ((id.startsWith('agent:') || id.startsWith('manager:')) && agentByLabel.has(name)) return agentByLabel.get(name);
|
||||
return id;
|
||||
};
|
||||
const outside = new Set();
|
||||
const recorded = new Set(ids);
|
||||
for (const n of derived.nodes) {
|
||||
const id = resolve(n.id);
|
||||
if (ids.has(id)) continue;
|
||||
if (n.kind === 'human' && nodes.some((x) => x.kind === 'human')) continue;
|
||||
const owner = n.kind === 'tool' ? `agent:${n.id.slice(5).split('/')[0]}` : null;
|
||||
const isOutside = n.kind === 'agent' || n.kind === 'manager' || Boolean(owner && !recorded.has(resolve(owner)));
|
||||
nodes.push({ ...n, meta: { ...(n.meta || {}), observed: true, outside: Boolean(isOutside) } });
|
||||
ids.add(id);
|
||||
if (isOutside) outside.add(id);
|
||||
}
|
||||
const has = new Set(edges.map((e) => `${e.source}|${e.target}|${e.kind}`));
|
||||
const linked = new Set(edges.map((e) => `${e.source}|${e.target}`));
|
||||
for (const e of derived.edges) {
|
||||
const source = resolve(e.source);
|
||||
const target = resolve(e.target);
|
||||
if (!ids.has(source) || !ids.has(target)) continue;
|
||||
if (has.has(`${source}|${target}|${e.kind}`)) continue;
|
||||
// a "uses"/"reviews" link the graph already draws in another form is not news
|
||||
if ((e.kind === 'uses' || e.kind === 'reviews') && linked.has(`${source}|${target}`)) continue;
|
||||
edges.push({ ...e, id: `${source}->${target}:${e.kind}`, source, target, meta: { observed: true } });
|
||||
has.add(`${source}|${target}|${e.kind}`);
|
||||
}
|
||||
return { ...graph, nodes, edges, outside: [...outside] };
|
||||
}
|
||||
|
||||
/**
|
||||
* layoutMap() for a run: the team/flow graph first, then the agents from
|
||||
* outside it as their own block underneath, framed as a group. Edges between
|
||||
* the two blocks are drawn straight. Returns layoutMap()'s shape plus
|
||||
* ``groups: [{id, label, x, y, w, h}]``.
|
||||
*/
|
||||
export function layoutRunMap(graph, events, opts = {}) {
|
||||
const g = observedGraph(graph, events);
|
||||
const outside = new Set(g.outside || []);
|
||||
if (!outside.size) return { ...layoutMap(g, opts), groups: [], graph: g };
|
||||
const inner = (set, keep) => ({
|
||||
nodes: g.nodes.filter((n) => keep(set.has(n.id))),
|
||||
edges: g.edges.filter((e) => keep(set.has(e.source)) && keep(set.has(e.target))),
|
||||
});
|
||||
const main = layoutMap(inner(outside, (x) => !x), opts);
|
||||
const out = layoutMap(inner(outside, (x) => x), opts);
|
||||
const gap = 40;
|
||||
const pad = 14;
|
||||
const dy = main.height + gap;
|
||||
const dx = 0;
|
||||
for (const n of out.nodes) { n.y += dy; n.x += dx; n.outside = true; }
|
||||
for (const e of out.edges) e.points = e.points.map(([x, y]) => [x + dx, y + dy]);
|
||||
const nodes = [...main.nodes, ...out.nodes];
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const cross = g.edges
|
||||
.filter((e) => outside.has(e.source) !== outside.has(e.target) && byId.has(e.source) && byId.has(e.target))
|
||||
.map((e) => {
|
||||
const s = byId.get(e.source);
|
||||
const t = byId.get(e.target);
|
||||
const down = t.y > s.y;
|
||||
return {
|
||||
id: e.id, source: e.source, target: e.target, kind: e.kind, label: e.label == null ? null : str(e.label), back: false, cross: true,
|
||||
points: [[s.x + s.w / 2, down ? s.y + s.h : s.y], [t.x + t.w / 2, down ? t.y : t.y + t.h]],
|
||||
};
|
||||
});
|
||||
const xs = out.nodes.map((n) => n.x);
|
||||
const group = {
|
||||
id: 'outside',
|
||||
label: `Outside the ${g.kind === 'flow' ? 'flow' : 'team'}`,
|
||||
x: Math.min(...xs) - pad,
|
||||
y: dy - pad + 6,
|
||||
w: Math.max(...out.nodes.map((n) => n.x + n.w)) - Math.min(...xs) + pad * 2,
|
||||
h: out.height - 12 + pad,
|
||||
};
|
||||
return {
|
||||
nodes,
|
||||
edges: [...main.edges, ...out.edges, ...cross],
|
||||
width: Math.max(main.width, out.width, group.x + group.w + 8),
|
||||
height: dy + out.height + 8,
|
||||
layers: Math.max(main.layers, out.layers),
|
||||
groups: [group],
|
||||
graph: g,
|
||||
};
|
||||
}
|
||||
|
||||
/** Words for edge kinds on the map (labels never rely on colour). */
|
||||
export const EDGE_WORDS = Object.freeze({
|
||||
handoff: 'hands off',
|
||||
@@ -942,7 +1045,7 @@ export function edgeText(edge) {
|
||||
|
||||
const api = {
|
||||
PALETTE, SHAPES, HUMAN_LANE, MANAGER_LANE, EDGE_WORDS,
|
||||
buildModel, narrate, layoutMap, deriveGraph, nodeIdsForEvent, pairEvents, sortEvents,
|
||||
buildModel, narrate, layoutMap, layoutRunMap, observedGraph, deriveGraph, nodeIdsForEvent, pairEvents, sortEvents,
|
||||
eventId, formatDuration, formatCost, argPreview, truncate, stripPrefix, edgeText,
|
||||
};
|
||||
if (typeof globalThis !== 'undefined') globalThis.PraisonReplayCore = api;
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
const GUTTER = 184;
|
||||
const LANE_H = 48;
|
||||
const SUB_H = 38;
|
||||
const CH_ROW = 24;
|
||||
const AXIS_H = 26;
|
||||
const PAD_R = 96; // room for arrow and cursor labels at the right edge
|
||||
@@ -81,12 +79,21 @@ function storageSet(key, value) {
|
||||
|
||||
async function getCore() {
|
||||
if (globalThis.PraisonReplayCore) return globalThis.PraisonReplayCore;
|
||||
if (globalThis.__REPLAY_DATA__) {
|
||||
// standalone page: the inlined core may simply not have run yet
|
||||
for (let i = 0; i < 50 && !globalThis.PraisonReplayCore; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
if (globalThis.PraisonReplayCore) return globalThis.PraisonReplayCore;
|
||||
}
|
||||
const mod = await import(new URL('./replay-core.js', import.meta.url).href);
|
||||
return mod.default || mod;
|
||||
}
|
||||
|
||||
// ── styles ────────────────────────────────────────────────────────────
|
||||
|
||||
const DARK = `--rp-bg:#16171B;--rp-surface:#1E2026;--rp-lane:#22252C;--rp-text:#F2F3F5;--rp-dim:#B4B8C2;--rp-border:#3A3E48;--rp-control:#8A909C;
|
||||
--rp-focus:#7FB0FF;--rp-agent-ink:#FF9A9A;--rp-tool-ink:#5FD0E3;--rp-human-ink:#F0BE63;--rp-manager-ink:#B3C0D3;--rp-outline:#E6E8EC;
|
||||
--rp-heat-lo:#B86A2B;--rp-heat-hi:#FF8A4C;--rp-live:#FF8A80;--rp-ok:#7BD88F;--rp-err:#FF8A80;--rp-chapter:#2A2E37;--rp-sel:#24344F;color-scheme:dark`;
|
||||
|
||||
const STYLE = `
|
||||
.rp-root{--rp-bg:#FFFFFF;--rp-surface:#F6F7F9;--rp-lane:#F1F3F6;--rp-text:#17181C;--rp-dim:#4F535C;--rp-border:#D5D9E0;--rp-control:#767B85;
|
||||
--rp-focus:#1A56DB;--rp-agent:#8B0000;--rp-tool:#0F7287;--rp-human:#8A5A00;--rp-manager:#3C4A5C;--rp-neutral:#5B6270;--rp-on-fill:#FFFFFF;
|
||||
@@ -94,9 +101,8 @@ const STYLE = `
|
||||
--rp-heat-lo:#C0772F;--rp-heat-hi:#8F2D07;--rp-live:#B3261E;--rp-ok:#1E6B34;--rp-err:#B3261E;--rp-chapter:#E9ECF1;--rp-sel:#DCE7FB;
|
||||
color:var(--rp-text);background:var(--rp-bg);font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
border:1px solid var(--rp-border);border-radius:12px;overflow:hidden;color-scheme:light}
|
||||
.rp-root[data-theme="dark"]{--rp-bg:#16171B;--rp-surface:#1E2026;--rp-lane:#22252C;--rp-text:#F2F3F5;--rp-dim:#B4B8C2;--rp-border:#3A3E48;--rp-control:#8A909C;
|
||||
--rp-focus:#7FB0FF;--rp-agent-ink:#FF9A9A;--rp-tool-ink:#5FD0E3;--rp-human-ink:#F0BE63;--rp-manager-ink:#B3C0D3;--rp-outline:#E6E8EC;
|
||||
--rp-heat-lo:#B86A2B;--rp-heat-hi:#FF8A4C;--rp-live:#FF8A80;--rp-ok:#7BD88F;--rp-err:#FF8A80;--rp-chapter:#2A2E37;--rp-sel:#24344F;color-scheme:dark}
|
||||
.rp-root[data-theme="dark"]{${DARK}}
|
||||
@media (prefers-color-scheme:dark){.rp-root[data-theme="system"]{${DARK}}}
|
||||
.rp-root *{box-sizing:border-box}
|
||||
.rp-root :focus{outline:none}
|
||||
.rp-root :focus-visible{outline:3px solid var(--rp-focus);outline-offset:2px}
|
||||
@@ -139,6 +145,8 @@ const STYLE = `
|
||||
.rp-err{color:var(--rp-err);font-size:13px;margin:4px 0 0;min-height:1em}
|
||||
.rp-note{color:var(--rp-dim);font-size:13px;margin:4px 0 0}
|
||||
.rp-stage{display:grid;grid-template-columns:minmax(0,1fr) 340px;min-height:360px}
|
||||
.rp-stage.rp-wide{grid-template-columns:minmax(0,1fr)}
|
||||
.rp-stage.rp-wide .rp-narr{border-left:0;border-top:1px solid var(--rp-border);max-height:300px}
|
||||
.rp-canvas{overflow:auto;position:relative;max-height:62vh;background:var(--rp-bg)}
|
||||
.rp-canvas svg{display:block}
|
||||
.rp-narr{position:relative;border-left:1px solid var(--rp-border);overflow:auto;max-height:62vh;background:var(--rp-surface)}
|
||||
@@ -153,6 +161,7 @@ const STYLE = `
|
||||
.rp-g-agent{border-radius:50%;background:var(--rp-agent)}.rp-g-manager{background:var(--rp-manager)}
|
||||
.rp-g-tool{border-radius:4px;background:var(--rp-tool);width:14px}.rp-g-human{background:var(--rp-human);transform:rotate(45deg) scale(.8)}
|
||||
.rp-root[data-theme="dark"] .rp-g{outline:2px solid var(--rp-outline);outline-offset:-1px}
|
||||
@media (prefers-color-scheme:dark){.rp-root[data-theme="system"] .rp-g{outline:2px solid var(--rp-outline);outline-offset:-1px}}
|
||||
.rp-scrub{display:flex;align-items:center;gap:10px;padding:8px 14px;border-top:1px solid var(--rp-border);flex-wrap:wrap}
|
||||
.rp-scrub input[type=range]{flex:1;min-width:160px;accent-color:var(--rp-focus)}
|
||||
.rp-scrub output{font-variant-numeric:tabular-nums;color:var(--rp-dim);font-size:13px;min-width:12ch}
|
||||
@@ -206,6 +215,8 @@ const STYLE = `
|
||||
.rp-svg .rp-arrow.rp-hl path{stroke-width:3.5}
|
||||
.rp-svg .rp-arrow text{font-size:11px;font-weight:600}
|
||||
.rp-svg .rp-labelbg{fill:var(--rp-bg);opacity:.92}
|
||||
.rp-svg .rp-labels{pointer-events:none}
|
||||
.rp-svg .rp-tip-bg{fill:var(--rp-text)}.rp-svg .rp-tip-text{fill:var(--rp-bg);font-size:12px;font-weight:600}
|
||||
.rp-svg .rp-cursor{stroke:var(--rp-focus);stroke-width:2}
|
||||
.rp-svg .rp-cursor-label{fill:var(--rp-focus);font-weight:700;font-size:11px}
|
||||
.rp-svg .rp-marker{fill:var(--rp-text)}.rp-svg .rp-marker-human{fill:var(--rp-human-ink)}
|
||||
@@ -223,11 +234,23 @@ const STYLE = `
|
||||
.rp-svg .rp-edge.rp-e-uses path,.rp-svg .rp-edge.rp-e-assigned path,.rp-svg .rp-edge.rp-e-depends_on path{stroke-dasharray:3 3}
|
||||
.rp-svg .rp-edge.rp-e-reviews path{stroke:var(--rp-human-ink);stroke-dasharray:4 2}
|
||||
.rp-svg .rp-edge text{font-size:11px;fill:var(--rp-dim)}
|
||||
.rp-svg .rp-group rect{fill:none;stroke:var(--rp-control);stroke-width:1.5;stroke-dasharray:6 4}
|
||||
.rp-map{cursor:grab}.rp-map.rp-panning{cursor:grabbing}
|
||||
@media (prefers-reduced-motion:no-preference){
|
||||
.rp-b-live .rp-dot{animation:rp-pulse 1.6s ease-in-out infinite}
|
||||
.rp-step,.rp-svg .rp-block,.rp-svg .rp-arrow{transition:opacity .15s ease}
|
||||
}
|
||||
@keyframes rp-pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
@media (max-width:600px){
|
||||
.rp-root{border-radius:8px}
|
||||
.rp-bar{padding:8px 10px}
|
||||
.rp-title{max-width:100%;flex-basis:100%}
|
||||
.rp-pending,.rp-scrub{padding:8px 10px}
|
||||
.rp-drawer{padding:0 10px 12px}
|
||||
.rp-tabs{margin:0 -10px 10px;padding:6px 10px 0}
|
||||
.rp-hint{display:none}
|
||||
.rp-step{grid-template-columns:50px 14px minmax(0,1fr)}
|
||||
}
|
||||
@media (max-width:960px){
|
||||
.rp-shell{grid-template-columns:minmax(0,1fr)}
|
||||
.rp-runs{max-height:220px;border-right:0;border-bottom:1px solid var(--rp-border)}
|
||||
@@ -246,35 +269,10 @@ function ensureStyle() {
|
||||
|
||||
// ── theme ─────────────────────────────────────────────────────────────
|
||||
|
||||
function luminance(color) {
|
||||
const c = String(color || '').trim();
|
||||
let rgb = null;
|
||||
const hex = c.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (hex) {
|
||||
const v = hex[1].length === 3 ? hex[1].split('').map((x) => x + x).join('') : hex[1];
|
||||
rgb = [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16));
|
||||
} else {
|
||||
const m = c.match(/rgba?\(([^)]+)\)/i);
|
||||
if (m) {
|
||||
const parts = m[1].split(/[ ,/]+/).filter(Boolean).map(Number);
|
||||
if (parts.length >= 4 && parts[3] === 0) return null; // transparent
|
||||
rgb = parts.slice(0, 3);
|
||||
}
|
||||
}
|
||||
if (!rgb || rgb.some((x) => Number.isNaN(x))) return null;
|
||||
const lin = rgb.map((v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; });
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||
}
|
||||
|
||||
/** Theme mode: "system" (default, follows prefers-color-scheme), "light" or "dark". */
|
||||
function detectTheme() {
|
||||
const saved = storageGet('rp-theme');
|
||||
if (saved === 'light' || saved === 'dark') return saved;
|
||||
if (!S.solo) {
|
||||
const css = getComputedStyle(document.documentElement).getPropertyValue('--db-bg').trim();
|
||||
const L = luminance(css) ?? luminance(getComputedStyle(document.body).backgroundColor);
|
||||
if (L != null) return L < 0.35 ? 'dark' : 'light';
|
||||
}
|
||||
try { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch { return 'light'; }
|
||||
return saved === 'light' || saved === 'dark' ? saved : 'system';
|
||||
}
|
||||
|
||||
// ── URL ───────────────────────────────────────────────────────────────
|
||||
@@ -379,6 +377,12 @@ export async function render(container, opts = {}) {
|
||||
else renderAll();
|
||||
S.timers.push(setInterval(() => { refreshRuns(true); }, 10000));
|
||||
}
|
||||
try {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onScheme = () => { if (S && S.theme === 'system') queueRender(); };
|
||||
mq.addEventListener('change', onScheme);
|
||||
S.unlisten = () => mq.removeEventListener('change', onScheme);
|
||||
} catch { /* old browsers */ }
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
S.ro = new ResizeObserver(() => {
|
||||
if (!S) return;
|
||||
@@ -397,6 +401,7 @@ export function cleanup() {
|
||||
if (S.es) { try { S.es.close(); } catch { /* ignore */ } }
|
||||
for (const t of S.timers) clearInterval(t);
|
||||
if (S.ro) { try { S.ro.disconnect(); } catch { /* ignore */ } }
|
||||
if (S.unlisten) { try { S.unlisten(); } catch { /* ignore */ } }
|
||||
S = null;
|
||||
}
|
||||
|
||||
@@ -471,6 +476,8 @@ async function loadRun(id) {
|
||||
S.runId = id;
|
||||
S.nodeSel = null;
|
||||
S.asks = new Map();
|
||||
S.zoom = null;
|
||||
S.mapZoom = null;
|
||||
writeRunToUrl(id);
|
||||
renderRunList();
|
||||
let data;
|
||||
@@ -598,6 +605,8 @@ function renderAll(errorText) {
|
||||
return;
|
||||
}
|
||||
renderPending();
|
||||
// the map needs the width; its step list moves underneath
|
||||
S.els.stage.classList.toggle('rp-wide', S.view === 'map');
|
||||
if (S.view === 'map') renderMap(); else renderTimeline();
|
||||
renderLegend();
|
||||
renderNarration();
|
||||
@@ -692,27 +701,31 @@ function buildBar(bar) {
|
||||
h('option', { value: 'latency', selected: S.heat === 'latency' }, 'Heat: latency'));
|
||||
bar.appendChild(sel);
|
||||
}
|
||||
if (S.view === 'timeline') {
|
||||
const z = h('div', { class: 'rp-seg', role: 'group', 'aria-label': 'Zoom' });
|
||||
z.append(
|
||||
h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom out', 'data-key': 'zoom-out', onclick: () => zoomBy(1 / 1.6) }, '−'),
|
||||
h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Fit to width', 'data-key': 'zoom-fit', onclick: () => { S.zoom = null; renderAll(); } }, 'Fit'),
|
||||
h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom in', 'data-key': 'zoom-in', onclick: () => zoomBy(1.6) }, '+'),
|
||||
);
|
||||
bar.appendChild(z);
|
||||
}
|
||||
const z = h('div', { class: 'rp-seg', role: 'group', 'aria-label': 'Zoom' });
|
||||
z.append(
|
||||
h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom out', 'data-key': 'zoom-out', onclick: () => zoomBy(1 / 1.5) }, '−'),
|
||||
h('button', { type: 'button', class: 'rp-btn', 'data-key': 'zoom-fit', onclick: () => { if (S.view === 'map') S.mapZoom = null; else S.zoom = null; renderAll(); } }, 'Fit'),
|
||||
h('button', { type: 'button', class: 'rp-btn', 'aria-label': 'Zoom in', 'data-key': 'zoom-in', onclick: () => zoomBy(1.5) }, '+'),
|
||||
);
|
||||
bar.appendChild(z);
|
||||
if (S.run?.live && S.cursor < m.narration.length - 1) {
|
||||
bar.appendChild(h('button', { type: 'button', class: 'rp-btn', 'data-key': 'golive', onclick: () => { S.follow = true; setCursor(m.narration.length - 1); } }, 'Jump to live'));
|
||||
}
|
||||
bar.appendChild(h('button', {
|
||||
type: 'button', class: 'rp-btn', 'data-key': 'theme', 'aria-label': `Switch to ${S.theme === 'dark' ? 'light' : 'dark'} theme`,
|
||||
onclick: () => { S.theme = S.theme === 'dark' ? 'light' : 'dark'; storageSet('rp-theme', S.theme); renderAll(); },
|
||||
}, S.theme === 'dark' ? 'Light' : 'Dark'));
|
||||
bar.appendChild(h('select', {
|
||||
class: 'rp-select', 'aria-label': 'Theme', 'data-key': 'theme',
|
||||
onchange: (e) => { S.theme = e.target.value; storageSet('rp-theme', S.theme); renderAll(); },
|
||||
},
|
||||
h('option', { value: 'system', selected: S.theme === 'system' }, 'Theme: system'),
|
||||
h('option', { value: 'light', selected: S.theme === 'light' }, 'Theme: light'),
|
||||
h('option', { value: 'dark', selected: S.theme === 'dark' }, 'Theme: dark')));
|
||||
}
|
||||
|
||||
function zoomBy(f) {
|
||||
const current = S.zoom || S.fitPps || 60;
|
||||
S.zoom = Math.min(4000, Math.max(4, current * f));
|
||||
if (S.view === 'map') {
|
||||
S.mapZoom = Math.min(2.5, Math.max(0.3, (S.mapZoom || S.mapFit || 1) * f));
|
||||
} else {
|
||||
S.zoom = Math.min(4000, Math.max(4, (S.zoom || S.fitPps || 60) * f));
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
|
||||
@@ -929,6 +942,37 @@ function glyph(kind, x, y, size, cls = 'rp-glyph') {
|
||||
return s('rect', { class: cls, x, y, width: size, height: size, rx: 1 });
|
||||
}
|
||||
|
||||
/** Lane rows: approvals/LLM calls on top, a thinner row of tool calls under them. */
|
||||
const ROW_H = { approval: 24, llm: 22, tool: 18 };
|
||||
const ROW_ORDER = ['approval', 'llm', 'tool'];
|
||||
const LANE_PAD = 7;
|
||||
|
||||
function gutterWidth(canvasWidth) {
|
||||
return canvasWidth && canvasWidth < 640 ? 116 : GUTTER;
|
||||
}
|
||||
|
||||
/** Text that fits `px` at roughly `charPx` per character, with an ellipsis. */
|
||||
function fitLabel(full, px, charPx = 7) {
|
||||
const text = String(full);
|
||||
if (text.length * charPx <= px) return text;
|
||||
const n = Math.floor((px - charPx) / charPx);
|
||||
return n >= 3 ? `${text.slice(0, n).trimEnd()}…` : '';
|
||||
}
|
||||
|
||||
/** Greedy interval packing in pixel space, so blocks never overlap. */
|
||||
function packTracks(list, x) {
|
||||
const ends = [];
|
||||
const track = new Map();
|
||||
for (const b of list) {
|
||||
const left = x(b.start);
|
||||
const right = Math.max(x(b.end), left + MIN_BLOCK) + 3;
|
||||
let k = ends.findIndex((e) => e <= left);
|
||||
if (k < 0) { ends.push(right); k = ends.length - 1; } else ends[k] = right;
|
||||
track.set(b.id, k);
|
||||
}
|
||||
return { track, count: ends.length };
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const m = S.model;
|
||||
const canvas = S.els.canvas;
|
||||
@@ -939,24 +983,45 @@ function renderTimeline() {
|
||||
S.tl = null;
|
||||
return;
|
||||
}
|
||||
const avail = Math.max(260, (canvas.clientWidth || 900) - GUTTER - PAD_R);
|
||||
const cw = canvas.clientWidth || 900;
|
||||
const gutter = gutterWidth(cw);
|
||||
const padR = cw < 640 ? 56 : PAD_R;
|
||||
const avail = Math.max(cw < 640 ? 200 : 260, cw - gutter - padR);
|
||||
const dur = Math.max(m.duration, 0.05);
|
||||
S.fitPps = avail / dur;
|
||||
const pps = S.zoom || S.fitPps;
|
||||
const plotW = Math.max(dur * pps, 120);
|
||||
const x = (t) => GUTTER + t * pps;
|
||||
const x = (t) => gutter + t * pps;
|
||||
const chapterH = m.chapterRows * CH_ROW;
|
||||
const lanesTop = AXIS_H + chapterH + (chapterH ? 8 : 4);
|
||||
const rows = laneRows();
|
||||
const rowGap = S.overlay ? 12 : 4;
|
||||
|
||||
// geometry: pack each lane's blocks per row kind, then stack the rows
|
||||
const byLane = new Map(rows.map((l) => [l.id, { approval: [], llm: [], tool: [] }]));
|
||||
for (const b of m.blocks) if (byLane.has(b.lane)) byLane.get(b.lane)[b.kind].push(b);
|
||||
const laneY = new Map();
|
||||
const geom = new Map();
|
||||
let y = lanesTop;
|
||||
for (const lane of rows) {
|
||||
const hgt = lane.parent ? SUB_H : LANE_H;
|
||||
const groups = byLane.get(lane.id);
|
||||
let cy = y + LANE_PAD;
|
||||
let used = false;
|
||||
for (const kind of ROW_ORDER) {
|
||||
const list = groups[kind].slice().sort((a, b) => a.start - b.start);
|
||||
if (!list.length) continue;
|
||||
const { track, count } = packTracks(list, x);
|
||||
for (const b of list) geom.set(b.id, { y: cy + track.get(b.id) * (ROW_H[kind] + rowGap), h: ROW_H[kind] });
|
||||
cy += count * (ROW_H[kind] + rowGap);
|
||||
used = true;
|
||||
}
|
||||
const minH = lane.parent ? 32 : 40;
|
||||
const hgt = Math.max(minH, (used ? cy - rowGap : cy) - y + LANE_PAD);
|
||||
laneY.set(lane.id, { top: y, h: hgt, mid: y + hgt / 2 });
|
||||
y += hgt;
|
||||
}
|
||||
const height = y + 10;
|
||||
const width = GUTTER + plotW + PAD_R;
|
||||
const width = gutter + plotW + padR;
|
||||
S.tl = { x, laneY, width };
|
||||
|
||||
const svg = s('svg', {
|
||||
@@ -975,20 +1040,20 @@ function renderTimeline() {
|
||||
lanesG.appendChild(s('rect', { class: i % 2 ? 'rp-lanebg-alt' : 'rp-lanebg', x: 0, y: ly.top, width, height: ly.h }));
|
||||
const label = s('g', null);
|
||||
if (lane.parent) {
|
||||
label.appendChild(s('text', { x: 30, y: ly.mid + 4, class: 'rp-dimtext', text: `↳ ${C().truncate(lane.label, 18)}` }));
|
||||
label.appendChild(s('text', { x: 24, y: ly.mid + 4, class: 'rp-dimtext', text: `↳ ${fitLabel(lane.label, gutter - 44, 6.6)}` }, s('title', { text: lane.label })));
|
||||
} else {
|
||||
label.appendChild(glyph(lane.kind, 10, ly.mid - 7, 14, `rp-body rp-f-${laneKindClass(lane)}`));
|
||||
const tot = m.totals.agents[lane.id];
|
||||
const showTot = S.overlay && tot && (tot.cost_usd || tot.busy_ms);
|
||||
label.appendChild(s('text', { x: 32, y: showTot ? ly.mid - 3 : ly.mid + 4, 'font-weight': '600', text: C().truncate(lane.label, 20) }));
|
||||
label.appendChild(s('text', { x: 32, y: showTot ? ly.mid - 3 : ly.mid + 4, 'font-weight': '600', text: fitLabel(lane.label, gutter - 40, 7.2) }, s('title', { text: lane.label })));
|
||||
if (showTot) {
|
||||
const bits = [C().formatCost(tot.cost_usd), C().formatDuration(tot.busy_ms) && `${C().formatDuration(tot.busy_ms)} busy`].filter(Boolean).join(' · ');
|
||||
label.appendChild(s('text', { x: 32, y: ly.mid + 12, class: 'rp-dimtext', 'font-size': '11', text: bits }));
|
||||
label.appendChild(s('text', { x: 32, y: ly.mid + 12, class: 'rp-dimtext', 'font-size': '11', text: fitLabel(bits, gutter - 40, 6) }));
|
||||
}
|
||||
}
|
||||
lanesG.appendChild(label);
|
||||
});
|
||||
lanesG.appendChild(s('line', { class: 'rp-grid', x1: GUTTER - 0.5, x2: GUTTER - 0.5, y1: 0, y2: height }));
|
||||
lanesG.appendChild(s('line', { class: 'rp-grid', x1: gutter - 0.5, x2: gutter - 0.5, y1: 0, y2: height }));
|
||||
svg.appendChild(lanesG);
|
||||
|
||||
// axis ticks
|
||||
@@ -1005,59 +1070,70 @@ function renderTimeline() {
|
||||
const chG = s('g', { 'aria-hidden': 'true' });
|
||||
for (const c of m.chapters) {
|
||||
const cx = x(c.start);
|
||||
const cw = Math.max(x(c.end) - cx, 10);
|
||||
const cwid = Math.max(x(c.end) - cx, 10);
|
||||
const cy = AXIS_H + c.row * CH_ROW;
|
||||
const prefix = c.kind === 'task' ? 'Task' : ({ parallel: 'Parallel', loop: 'Loop', repeat: 'Repeat', route: 'Route', if: 'If', discussion: 'Discussion' }[c.pattern] || 'Step');
|
||||
let text = `${prefix} ${c.label}${c.count > 1 ? ` ×${c.count}` : ''}${c.route ? ` → ${c.route}` : ''}${c.status === 'failed' ? ' (failed)' : ''}`;
|
||||
const g = s('g', { class: 'rp-chapter', 'data-chapter': c.id, 'data-start': c.start },
|
||||
s('rect', { x: cx, y: cy + 2, width: cw, height: CH_ROW - 4, rx: 4 }),
|
||||
cw > 30 ? clippedText(cx + 5, cy, cw - 8, CH_ROW, { x: 0, y: CH_ROW / 2 + 4, 'font-size': '11' }, text) : null,
|
||||
s('title', { text }));
|
||||
chG.appendChild(g);
|
||||
const text = `${prefix} ${c.label}${c.count > 1 ? ` ×${c.count}` : ''}${c.route ? ` → ${c.route}` : ''}${c.status === 'failed' ? ' (failed)' : ''}`;
|
||||
const shown = fitLabel(text, cwid - 10, 6.4);
|
||||
chG.appendChild(s('g', { class: 'rp-chapter', 'data-chapter': c.id, 'data-start': c.start },
|
||||
s('rect', { x: cx, y: cy + 2, width: cwid, height: CH_ROW - 4, rx: 4 }),
|
||||
shown ? clippedText(cx + 5, cy, cwid - 8, CH_ROW, { x: 0, y: CH_ROW / 2 + 4, 'font-size': '11' }, shown) : null,
|
||||
s('title', { text })));
|
||||
}
|
||||
svg.appendChild(chG);
|
||||
|
||||
// blocks
|
||||
const maxHeat = Math.max(1e-12, ...m.blocks.filter((b) => b.kind !== 'approval').map((b) => (S.heat === 'cost' ? b.cost : b.durationMs)));
|
||||
const blocksG = s('g', { role: 'group', 'aria-label': 'Work blocks' });
|
||||
// Visible (possibly ellipsized) labels live in their own decorative layer, so
|
||||
// each block's accessible name is its full label, not a cut-off word.
|
||||
const labelsG = s('g', { class: 'rp-labels', 'aria-hidden': 'true' });
|
||||
for (const b of m.blocks) {
|
||||
const ly = laneY.get(b.lane);
|
||||
if (!ly) continue;
|
||||
const gm = geom.get(b.id);
|
||||
if (!gm) continue;
|
||||
const lane = m.lanes.find((l) => l.id === b.lane);
|
||||
const owner = lane && lane.parent ? m.lanes.find((l) => l.id === lane.parent) : lane;
|
||||
const bx = x(b.start);
|
||||
const bw = Math.max(x(b.end) - bx, MIN_BLOCK);
|
||||
const bh = ly.h - 16;
|
||||
const by = ly.top + 8 - (S.overlay ? 3 : 0);
|
||||
const { y: by, h: bh } = gm;
|
||||
const fillKind = b.kind === 'tool' ? 'tool' : (b.kind === 'approval' ? 'human' : (owner && owner.kind === 'manager' ? 'manager' : 'agent'));
|
||||
const cls = ['rp-block', `rp-k-${b.kind}`, `rp-o-${fillKind}`];
|
||||
if (b.open) cls.push('rp-open');
|
||||
const text = blockText(b) + (b.status === 'error' ? ' failed' : '') + (b.kind === 'approval' && b.status === 'pending' ? ' — waiting for you' : '');
|
||||
const heatVal = S.heat === 'cost' ? C().formatCost(b.cost) : C().formatDuration(b.durationMs);
|
||||
const full = S.overlay && heatVal && b.kind !== 'approval' ? `${text} · ${heatVal}` : text;
|
||||
const showText = bw - 24 >= 20;
|
||||
const glyphW = bw >= 18 ? 18 : 4;
|
||||
const shown = fitLabel(full, bw - glyphW - 4, b.kind === 'tool' ? 6.4 : 7);
|
||||
const g = s('g', {
|
||||
class: cls.join(' '), 'data-block': b.id, 'data-key': `block:${b.id}`, 'data-start': b.start,
|
||||
role: 'button', tabindex: '-1', 'aria-label': showText ? `${full}. ${blockAria(b, lane)}` : blockAria(b, lane),
|
||||
role: 'button', tabindex: '-1', 'aria-label': `${full}. ${blockAria(b, lane)}`,
|
||||
onclick: () => selectBlock(b),
|
||||
...hoverHandlers({ blockId: b.id, id: b.eventIds[0] }),
|
||||
onmouseenter: () => { highlight({ blockId: b.id, id: b.eventIds[0] }); showTip(full, bx, by, bw); },
|
||||
onmouseleave: () => { highlight(null); hideTip(); },
|
||||
onfocus: () => { highlight({ blockId: b.id, id: b.eventIds[0] }); showTip(full, bx, by, bw); },
|
||||
onblur: () => { highlight(null); hideTip(); },
|
||||
});
|
||||
g.appendChild(s('rect', { class: 'rp-ring', x: bx - 3, y: by - 3, width: bw + 6, height: bh + 6, rx: 6 }));
|
||||
g.appendChild(s('rect', { class: `rp-body rp-f-${fillKind}`, x: bx, y: by, width: bw, height: bh, rx: b.kind === 'tool' ? 9 : 3 }));
|
||||
g.appendChild(s('rect', { class: `rp-body rp-f-${fillKind}`, x: bx, y: by, width: bw, height: bh, rx: b.kind === 'tool' ? bh / 2 : 3 }));
|
||||
const glyphKind = b.kind === 'tool' ? 'tool' : (b.kind === 'approval' ? 'human' : fillKind);
|
||||
if (bw >= 18) g.appendChild(glyph(glyphKind, bx + 5, by + bh / 2 - 5, 10));
|
||||
if (showText) g.appendChild(clippedText(bx + 20, by, bw - 24, bh, { class: 'rp-onfill', x: 0, y: bh / 2 + 4 }, full));
|
||||
else if (b.count > 1) g.appendChild(s('text', { x: bx + bw + 3, y: by + bh / 2 + 4, 'font-weight': '700', 'aria-hidden': 'true', text: `×${b.count}` }));
|
||||
if (shown) {
|
||||
labelsG.appendChild(s('g', { class: 'rp-label', 'data-start': b.start, 'data-label-for': b.id },
|
||||
clippedText(bx + glyphW, by, bw - glyphW - 2, bh, { class: 'rp-onfill', x: 0, y: bh / 2 + 4, 'font-size': b.kind === 'tool' ? '11' : '12' }, shown)));
|
||||
} else if (b.count > 1) {
|
||||
labelsG.appendChild(s('text', { x: bx + bw + 3, y: by + bh / 2 + 4, 'font-weight': '700', text: `×${b.count}` }));
|
||||
}
|
||||
if (S.overlay && b.kind !== 'approval') {
|
||||
const v = S.heat === 'cost' ? b.cost : b.durationMs;
|
||||
const r = Math.min(1, v / maxHeat);
|
||||
const hh = 2 + Math.round(6 * r);
|
||||
const hh = 2 + Math.round(7 * r);
|
||||
g.appendChild(s('rect', { class: 'rp-heat', x: bx, y: by + bh + 2, width: bw, height: hh, fill: heatColor(r) }));
|
||||
}
|
||||
g.appendChild(s('title', { text: full }));
|
||||
blocksG.appendChild(g);
|
||||
}
|
||||
svg.appendChild(blocksG);
|
||||
svg.appendChild(labelsG);
|
||||
|
||||
// arrows
|
||||
const arrowsG = s('g', { 'aria-hidden': 'true' }); // told by the step list
|
||||
@@ -1082,16 +1158,38 @@ function renderTimeline() {
|
||||
}
|
||||
svg.appendChild(arrowsG);
|
||||
|
||||
// cursor
|
||||
// cursor, then the hover/focus tip on top of everything
|
||||
const t = cursorTime();
|
||||
svg.appendChild(s('g', { class: 'rp-cursor-g', transform: `translate(${x(t)},0)`, 'aria-hidden': 'true' },
|
||||
s('line', { class: 'rp-cursor', x1: 0, x2: 0, y1: AXIS_H - 4, y2: height }),
|
||||
s('text', { class: 'rp-cursor-label', x: 4, y: AXIS_H + chapterH + 2, text: offset(t) })));
|
||||
svg.appendChild(s('g', { class: 'rp-tip', 'aria-hidden': 'true', visibility: 'hidden' },
|
||||
s('rect', { class: 'rp-tip-bg', rx: 4, height: 20 }), s('text', { class: 'rp-tip-text' })));
|
||||
canvas.appendChild(svg);
|
||||
canvas.scrollLeft = scroll;
|
||||
applyCursor();
|
||||
}
|
||||
|
||||
/** A visible full label for the hovered/focused block (titles only show on hover). */
|
||||
function showTip(text, bx, by, bw) {
|
||||
const tip = S.els.canvas.querySelector('.rp-tip');
|
||||
if (!tip || !S.tl) return;
|
||||
const tw = text.length * 6.8 + 12;
|
||||
const x = Math.max(4, Math.min(bx + bw / 2 - tw / 2, S.tl.width - tw - 4));
|
||||
const y = Math.max(2, by - 24);
|
||||
const bg = tip.querySelector('rect');
|
||||
bg.setAttribute('x', x); bg.setAttribute('y', y); bg.setAttribute('width', tw);
|
||||
const label = tip.querySelector('text');
|
||||
label.setAttribute('x', x + 6); label.setAttribute('y', y + 14);
|
||||
label.textContent = text;
|
||||
tip.setAttribute('visibility', 'visible');
|
||||
}
|
||||
|
||||
function hideTip() {
|
||||
const tip = S && S.els.canvas.querySelector('.rp-tip');
|
||||
if (tip) tip.setAttribute('visibility', 'hidden');
|
||||
}
|
||||
|
||||
function niceStep(dur, plotW) {
|
||||
const target = dur / Math.max(2, Math.floor(plotW / 90));
|
||||
const pow = 10 ** Math.floor(Math.log10(target || 1));
|
||||
@@ -1116,29 +1214,29 @@ function selectBlock(b) {
|
||||
|
||||
const KIND_WORD = { agent: 'agent', tool: 'tool', human: 'you', manager: 'manager', task: 'task', step: 'step', start: 'start', end: 'end', fork: 'fork', join: 'join', decision: 'decision' };
|
||||
|
||||
/** The recorded graph completed with what happened (outside agents etc.), laid out. */
|
||||
function mapLayout() {
|
||||
if (!S.layout) S.layout = C().layoutRunMap(S.graph, S.events, { nodeWidth: 136, gapX: 52, gapY: 22 });
|
||||
return S.layout;
|
||||
}
|
||||
|
||||
function mapGraph() {
|
||||
return S.graph && Array.isArray(S.graph.nodes) && S.graph.nodes.length ? S.graph : C().deriveGraph(S.events);
|
||||
return mapLayout().graph;
|
||||
}
|
||||
|
||||
function nodeVisits() {
|
||||
const counts = new Map();
|
||||
const lastAt = new Map();
|
||||
const m = S.model;
|
||||
m.events.forEach((ev, i) => {
|
||||
for (const nid of C().nodeIdsForEvent(ev)) {
|
||||
counts.set(nid, (counts.get(nid) || 0) + 1);
|
||||
if (!lastAt.has(nid)) lastAt.set(nid, []);
|
||||
lastAt.get(nid).push(i);
|
||||
}
|
||||
});
|
||||
for (const ev of m.events) {
|
||||
for (const nid of C().nodeIdsForEvent(ev)) counts.set(nid, (counts.get(nid) || 0) + 1);
|
||||
}
|
||||
// Brief/Delivered are reached when the run starts/ends, though no event names them.
|
||||
const graph = mapGraph();
|
||||
const ended = m.events.some((e) => e.event_type === 'workflow_end' || e.event_type === 'session_end');
|
||||
for (const n of graph.nodes || []) {
|
||||
for (const n of mapGraph().nodes || []) {
|
||||
if (n.kind === 'start' && m.events.length && !counts.has(n.id)) counts.set(n.id, 1);
|
||||
if (n.kind === 'end' && ended && !counts.has(n.id)) counts.set(n.id, 1);
|
||||
}
|
||||
return { counts, at: lastAt };
|
||||
return { counts };
|
||||
}
|
||||
|
||||
function shapePath(shape, x, y, w, h) {
|
||||
@@ -1163,31 +1261,42 @@ function nodeGlyph(node, gx, gy) {
|
||||
return s('rect', { class: cls, x: gx, y: gy, width: 22, height: 22, rx: 2 });
|
||||
}
|
||||
|
||||
const MAP_MIN_SCALE = 0.55; // below this, labels stop being readable: scroll instead
|
||||
|
||||
function renderMap() {
|
||||
const canvas = S.els.canvas;
|
||||
const prev = { left: canvas.scrollLeft, top: canvas.scrollTop };
|
||||
clear(canvas);
|
||||
S.tl = null;
|
||||
const graph = mapGraph();
|
||||
if (!S.layout) S.layout = C().layoutMap(graph);
|
||||
const lay = S.layout;
|
||||
const lay = mapLayout();
|
||||
const graph = lay.graph;
|
||||
if (!lay.nodes.length) {
|
||||
canvas.appendChild(h('div', { class: 'rp-empty' }, h('h2', { text: 'Nothing to map yet' }), h('p', { text: 'The map appears once agents start talking.' })));
|
||||
return;
|
||||
}
|
||||
const visits = nodeVisits();
|
||||
const width = lay.width + 20;
|
||||
const height = lay.height + 20;
|
||||
const W = lay.width + 20;
|
||||
const H = lay.height + 20;
|
||||
const cw = Math.max(200, (canvas.clientWidth || 900) - 8);
|
||||
const chMax = Math.max(240, Math.min(window.innerHeight * 0.62, 900) - 8);
|
||||
S.mapFit = Math.max(MAP_MIN_SCALE, Math.min(1, cw / W, chMax / H));
|
||||
const scale = S.mapZoom || S.mapFit;
|
||||
const svg = s('svg', {
|
||||
class: 'rp-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'group', tabindex: '-1',
|
||||
'aria-label': `${graph.derived ? 'Who talked to whom' : 'Map of the run'}: ${lay.nodes.length} nodes and ${lay.edges.length} connections. Use up and down arrows to move between nodes.`,
|
||||
class: 'rp-svg rp-map', width: Math.round(W * scale), height: Math.round(H * scale), viewBox: `0 0 ${W} ${H}`, role: 'group', tabindex: '-1',
|
||||
'aria-label': `${graph.derived ? 'Who talked to whom' : 'Map of the run'}: ${lay.nodes.length} nodes and ${lay.edges.length} connections. Use up and down arrows to move between nodes; drag to pan.`,
|
||||
});
|
||||
svg.appendChild(s('defs', null,
|
||||
s('marker', { id: 'rp-mhead', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse' }, s('path', { class: 'rp-marker', d: 'M0,0 L10,5 L0,10 z' }))));
|
||||
for (const grp of lay.groups || []) {
|
||||
svg.appendChild(s('g', { class: 'rp-group', 'aria-hidden': 'true' },
|
||||
s('rect', { x: grp.x, y: grp.y, width: grp.w, height: grp.h, rx: 10 }),
|
||||
s('text', { x: grp.x + 10, y: grp.y - 6, 'font-weight': '600', text: grp.label })));
|
||||
}
|
||||
const edgesG = s('g', { 'aria-hidden': 'true' });
|
||||
for (const e of lay.edges) {
|
||||
const p = e.points;
|
||||
let d = `M${p[0][0]},${p[0][1]}`;
|
||||
if (e.back) {
|
||||
if (e.back || e.cross) {
|
||||
d += p.slice(1).map((q) => ` L${q[0]},${q[1]}`).join('');
|
||||
} else {
|
||||
for (let j = 1; j < p.length; j++) {
|
||||
@@ -1205,8 +1314,9 @@ function renderMap() {
|
||||
s('text', { x: mid[0], y: mid[1], 'text-anchor': 'middle', text })));
|
||||
}
|
||||
svg.appendChild(edgesG);
|
||||
const nodesG = s('g', { role: 'group', 'aria-label': 'Nodes' });
|
||||
const ordered = [...lay.nodes].sort((a, b) => a.layer - b.layer || a.order - b.order);
|
||||
const ordered = [...lay.nodes].sort((a, b) => Number(Boolean(a.outside)) - Number(Boolean(b.outside)) || a.layer - b.layer || a.order - b.order);
|
||||
const groups = { main: s('g', { role: 'group', 'aria-label': 'Nodes' }), outside: null };
|
||||
if ((lay.groups || []).length) groups.outside = s('g', { role: 'group', 'aria-label': lay.groups[0].label });
|
||||
ordered.forEach((n, i) => {
|
||||
const count = visits.counts.get(n.id) || 0;
|
||||
const word = KIND_WORD[n.kind] || n.kind;
|
||||
@@ -1224,16 +1334,39 @@ function renderMap() {
|
||||
g.appendChild(path ? s('path', { class: 'rp-card', d: path }) : s('rect', { class: 'rp-card', x: n.x, y: n.y, width: n.w, height: n.h, rx: n.kind === 'tool' ? 14 : (n.kind === 'end' ? 2 : 6) }));
|
||||
if (n.kind === 'end') g.appendChild(s('rect', { class: 'rp-card', x: n.x + 3, y: n.y + 3, width: n.w - 6, height: n.h - 6, rx: 2, fill: 'none' }));
|
||||
g.appendChild(nodeGlyph(n, n.x + 12, n.y + n.h / 2 - 11));
|
||||
g.appendChild(clippedText(n.x + 44, n.y, n.w - 50, n.h, { x: 0, y: n.h / 2 - 2, 'font-weight': '600' }, n.label));
|
||||
g.appendChild(s('text', { x: n.x + 44, y: n.y + n.h / 2 - 2, 'font-weight': '600', 'aria-hidden': 'true', text: fitLabel(n.label, n.w - 52, 7.2) }));
|
||||
g.appendChild(s('text', { class: 'rp-kindword', x: n.x + 44, y: n.y + n.h / 2 + 13, 'aria-hidden': 'true', text: ` ${kindLine}` }));
|
||||
g.appendChild(s('title', { text: name }));
|
||||
nodesG.appendChild(g);
|
||||
(n.outside && groups.outside ? groups.outside : groups.main).appendChild(g);
|
||||
});
|
||||
svg.appendChild(nodesG);
|
||||
svg.appendChild(groups.main);
|
||||
if (groups.outside) svg.appendChild(groups.outside);
|
||||
enablePan(svg, canvas);
|
||||
canvas.appendChild(svg);
|
||||
canvas.scrollLeft = prev.left;
|
||||
canvas.scrollTop = prev.top;
|
||||
applyMapCursor();
|
||||
}
|
||||
|
||||
/** Drag the empty canvas to pan (the canvas box scrolls; nodes stay clickable). */
|
||||
function enablePan(svg, box) {
|
||||
let drag = null;
|
||||
svg.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || (e.target.closest && e.target.closest('.rp-node'))) return;
|
||||
drag = { x: e.clientX, y: e.clientY, left: box.scrollLeft, top: box.scrollTop };
|
||||
try { svg.setPointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||
svg.classList.add('rp-panning');
|
||||
});
|
||||
svg.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
box.scrollLeft = drag.left - (e.clientX - drag.x);
|
||||
box.scrollTop = drag.top - (e.clientY - drag.y);
|
||||
});
|
||||
const end = () => { drag = null; svg.classList.remove('rp-panning'); };
|
||||
svg.addEventListener('pointerup', end);
|
||||
svg.addEventListener('pointercancel', end);
|
||||
}
|
||||
|
||||
function applyMapCursor() {
|
||||
const item = S.model.narration[S.cursor];
|
||||
const ev = item ? S.model.events[S.model.eventIndex.get(item.id)] : null;
|
||||
@@ -1710,9 +1843,14 @@ async function decide(b, action, withArgs) {
|
||||
}
|
||||
|
||||
// ── standalone boot ───────────────────────────────────────────────────
|
||||
//
|
||||
// Contract (stable): an HTML page sets `window.__REPLAY_DATA__ = {events, graph,
|
||||
// run_id?, title?}` and inlines replay-core.js then replay.js as module
|
||||
// script elements; the view mounts into #replay-root (or a new main element in
|
||||
// the body), replacing whatever fallback markup is there. No network.
|
||||
|
||||
function bootStandalone() {
|
||||
if (globalThis.__REPLAY_BOOTED__) return;
|
||||
if (globalThis.__REPLAY_BOOTED__ || !document.body) return;
|
||||
globalThis.__REPLAY_BOOTED__ = true;
|
||||
const data = globalThis.__REPLAY_DATA__ || {};
|
||||
let mount = document.getElementById('replay-root');
|
||||
@@ -1721,11 +1859,18 @@ function bootStandalone() {
|
||||
mount.id = 'replay-root';
|
||||
document.body.appendChild(mount);
|
||||
}
|
||||
mount.style.padding = '12px';
|
||||
render(mount, { standalone: true, events: data.events || [], graph: data.graph || null, title: data.title || document.title });
|
||||
mount.style.padding = '16px';
|
||||
mount.style.boxSizing = 'border-box';
|
||||
mount.style.maxWidth = '100%';
|
||||
render(mount, { standalone: true, events: data.events || [], graph: data.graph || null, title: data.title || data.run_id || document.title });
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' && globalThis.__REPLAY_DATA__ && !(window.aiui && window.aiui.selectPage)) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootStandalone);
|
||||
else bootStandalone();
|
||||
// Module scripts run after parsing, before DOMContentLoaded; waiting for it
|
||||
// also lets an inlined replay-core.js placed after this file register first.
|
||||
if (document.readyState === 'complete') bootStandalone();
|
||||
else {
|
||||
document.addEventListener('DOMContentLoaded', bootStandalone, { once: true });
|
||||
window.addEventListener('load', bootStandalone, { once: true });
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ def _current_index(page) -> int:
|
||||
return int(page.get_attribute('.rp-step[aria-current="step"]', "data-i"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10"])
|
||||
@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10", "team_outside"])
|
||||
def test_standalone_steps_with_arrow_keys(browser, tmp_path, name):
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, name))
|
||||
@@ -154,7 +154,7 @@ def test_standalone_drawer_tabs_map_and_axe(browser, tmp_path):
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
|
||||
page.click("[data-key='theme']") # dark
|
||||
page.select_option("[data-key='theme']", "dark")
|
||||
assert page.get_attribute(".rp-root", "data-theme") == "dark"
|
||||
violations = _axe(page)
|
||||
assert violations == [], json.dumps(violations, indent=2)
|
||||
@@ -174,6 +174,154 @@ def test_reduced_motion_has_no_animation(browser, tmp_path):
|
||||
assert names == []
|
||||
|
||||
|
||||
def _boxes(page, selector: str) -> list[dict]:
|
||||
return page.eval_on_selector_all(
|
||||
selector,
|
||||
"els => els.map(e => { const r = e.getBoundingClientRect(); return {x: r.x, y: r.y, w: r.width, h: r.height, id: e.closest('[data-block]')?.getAttribute('data-block')}; })",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["team_outside", "team_handoff", "flow_parallel_loop"])
|
||||
def test_timeline_blocks_never_overlap(browser, tmp_path, name):
|
||||
"""An LLM span encloses its tool calls in real runs; tools go on their own row."""
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, name))
|
||||
page.wait_for_selector(".rp-block")
|
||||
boxes = _boxes(page, ".rp-block .rp-body")
|
||||
for i, a in enumerate(boxes):
|
||||
for b in boxes[i + 1:]:
|
||||
apart = a["x"] + a["w"] <= b["x"] + 0.5 or b["x"] + b["w"] <= a["x"] + 0.5 or a["y"] + a["h"] <= b["y"] + 0.5 or b["y"] + b["h"] <= a["y"] + 0.5
|
||||
assert apart, f"{a['id']} overlaps {b['id']}"
|
||||
# a label that does not fit is ellipsized, and the full text is in the title and name
|
||||
llm = page.locator(".rp-block.rp-k-llm").first
|
||||
name_attr = llm.get_attribute("aria-label")
|
||||
title = llm.locator("title").text_content()
|
||||
assert name_attr.startswith(title)
|
||||
llm.focus()
|
||||
assert page.locator(".rp-tip").get_attribute("visibility") == "visible"
|
||||
assert page.locator(".rp-tip text").text_content() == title
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
def test_why_tab_by_role_and_keyboard(browser, tmp_path):
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, "team_outside"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.locator(".rp-step", has_text="Triage handed off to Billing").click()
|
||||
page.get_by_role("tab", name="Why").click()
|
||||
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
|
||||
why = page.get_by_role("tabpanel").inner_text()
|
||||
assert "Hand-off reason" in why and "Billing question" in why and "Context passed" in why
|
||||
# the task input
|
||||
page.locator(".rp-step", has_text="Task 'research' went to Researcher").click()
|
||||
assert "Task description" in page.get_by_role("tabpanel").inner_text()
|
||||
assert "Get one fact about electric vehicles" in page.get_by_role("tabpanel").inner_text()
|
||||
# keyboard: focus the selected tab, arrow keys move between tabs
|
||||
page.get_by_role("tab", name="Why").focus()
|
||||
page.keyboard.press("ArrowLeft")
|
||||
assert page.get_by_role("tab", name="What happened").get_attribute("aria-selected") == "true"
|
||||
page.keyboard.press("ArrowRight")
|
||||
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
|
||||
assert page.evaluate("document.activeElement.getAttribute('role')") == "tab"
|
||||
# route chosen and approval decision
|
||||
page.goto(_standalone(tmp_path, "flow_parallel_loop"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.locator(".rp-step", has_text="chose route 'publish'").click()
|
||||
page.get_by_role("tab", name="Why").click()
|
||||
assert "Route chosen" in page.get_by_role("tabpanel").inner_text()
|
||||
page.goto(_standalone(tmp_path, "team_handoff"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.locator(".rp-step", has_text="You approved write_file").click()
|
||||
page.get_by_role("tab", name="Why").click()
|
||||
panel = page.get_by_role("tabpanel").inner_text()
|
||||
assert "Decision" in panel and "Approved with edited arguments" in panel
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
def test_theme_follows_system_with_override(browser, tmp_path):
|
||||
url = _standalone(tmp_path, "team_handoff")
|
||||
bg = "getComputedStyle(document.querySelector('.rp-root')).backgroundColor"
|
||||
page, _ = _page(browser, scheme="dark")
|
||||
page.goto(url)
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.get_attribute(".rp-root", "data-theme") == "system"
|
||||
assert page.evaluate(bg) == "rgb(22, 23, 27)"
|
||||
page.select_option("[data-key='theme']", "light")
|
||||
assert page.evaluate(bg) == "rgb(255, 255, 255)"
|
||||
page.close()
|
||||
page, _ = _page(browser, scheme="light")
|
||||
page.goto(url)
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.evaluate(bg) == "rgb(255, 255, 255)"
|
||||
|
||||
|
||||
def test_map_fits_on_open_and_groups_outside_agents(browser, tmp_path):
|
||||
page, errors = _page(browser)
|
||||
page.goto(_standalone(tmp_path, "team_outside"))
|
||||
page.wait_for_selector(".rp-step")
|
||||
page.get_by_role("button", name="Map").click()
|
||||
page.wait_for_selector(".rp-node")
|
||||
box = page.evaluate("(() => { const c = document.querySelector('.rp-canvas'); const r = c.getBoundingClientRect(); return {l: r.left, r: r.right, sw: c.scrollWidth, cw: c.clientWidth}; })()")
|
||||
assert box["sw"] <= box["cw"] + 1, "map overflows on open"
|
||||
for n in _boxes(page, ".rp-node .rp-card"):
|
||||
assert box["l"] - 1 <= n["x"] and n["x"] + n["w"] <= box["r"] + 1
|
||||
assert "Outside the team" in page.locator(".rp-group text").text_content()
|
||||
outside = page.get_by_role("group", name="Outside the team")
|
||||
assert outside.locator(".rp-node").count() == 3
|
||||
labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)")
|
||||
assert "hands off" in labels
|
||||
before = float(page.get_attribute(".rp-canvas svg", "width"))
|
||||
page.get_by_role("button", name="Zoom in").click()
|
||||
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before * 1.5, abs=2)
|
||||
page.get_by_role("button", name="Fit").click()
|
||||
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before, abs=2)
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["team_outside", "flow_parallel_loop"])
|
||||
def test_phone_width_has_no_page_scroll(browser, tmp_path, name):
|
||||
page, errors = _page(browser)
|
||||
page.set_viewport_size({"width": 400, "height": 860})
|
||||
page.goto(_standalone(tmp_path, name))
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.evaluate("document.documentElement.scrollWidth") <= 400
|
||||
r = page.evaluate("(() => { const b = document.querySelector('.rp-root').getBoundingClientRect(); return [b.left, b.right]; })()")
|
||||
assert r[0] == pytest.approx(16, abs=1) and r[1] == pytest.approx(384, abs=1)
|
||||
page.get_by_role("button", name="Map").click()
|
||||
assert page.evaluate("document.documentElement.scrollWidth") <= 400
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", ["core-first", "view-first"])
|
||||
def test_wrapper_style_export_boots(browser, tmp_path, order):
|
||||
"""The wrapper inlines both files as module scripts into #replay-root (which holds
|
||||
a fallback table) after window.__REPLAY_DATA__; the view must replace it."""
|
||||
views = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
|
||||
core = (views / "replay-core.js").read_text(encoding="utf-8").replace("</", "<\\/")
|
||||
view = (views / "replay.js").read_text(encoding="utf-8").replace("</", "<\\/")
|
||||
scripts = [core, view] if order == "core-first" else [view, core]
|
||||
data = json.dumps({"run_id": "team-outside-01", "events": _events("team_outside"), "graph": None}).replace("<", "\\u003c")
|
||||
html = (
|
||||
"<!doctype html><html lang=en><head><meta charset=utf-8><title>PraisonAI Replay: x</title></head><body>"
|
||||
"<div id=replay-root><table><tr><td>fallback</td></tr></table></div>"
|
||||
f"<script id=replay-data>window.__REPLAY_DATA__ = {data};</script>"
|
||||
+ "".join(f"<script type=module>\n{js}\n</script>" for js in scripts)
|
||||
+ "</body></html>"
|
||||
)
|
||||
out = tmp_path / f"export-{order}.html"
|
||||
out.write_text(html, encoding="utf-8")
|
||||
page, errors = _page(browser)
|
||||
page.goto(out.as_uri())
|
||||
page.wait_for_selector(".rp-step")
|
||||
assert page.locator("#replay-root table").count() == 0
|
||||
assert page.locator(".rp-title").inner_text() == "team-outside-01"
|
||||
# graph:null -> taken from workflow_start.data.graph
|
||||
page.get_by_role("button", name="Map").click()
|
||||
assert page.locator(".rp-node").count() == 10
|
||||
assert page.locator(".rp-ask").count() == 0
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
# ── dashboard + live run + approval through the You lane ─────────────
|
||||
|
||||
|
||||
|
||||
+76
@@ -306,7 +306,83 @@ def legacy_v10() -> None:
|
||||
r.write("legacy_v10.jsonl")
|
||||
|
||||
|
||||
def team_outside() -> None:
|
||||
"""Shape of a real recorded run: each agent's LLM span encloses its tool
|
||||
calls (llm_request -> tool -> llm_response), and after the team finishes a
|
||||
Triage agent hands off to Billing, neither of which is in the team graph.
|
||||
"""
|
||||
r = _Run("sess-outside-01", "team-outside-01")
|
||||
graph = {
|
||||
"schema": "praisonai.graph/1", "kind": "team", "name": "EV brief", "process": "sequential",
|
||||
"nodes": [
|
||||
{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}},
|
||||
{"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}},
|
||||
{"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}},
|
||||
{"id": "agent:Researcher", "kind": "agent", "label": "Researcher", "parent": None, "meta": {}},
|
||||
{"id": "agent:Writer", "kind": "agent", "label": "Writer", "parent": None, "meta": {}},
|
||||
{"id": "tool:Researcher/lookup_fact", "kind": "tool", "label": "lookup_fact", "parent": None, "meta": {}},
|
||||
{"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start:begin->task:research:next", "source": "start:begin", "target": "task:research", "kind": "next", "label": None},
|
||||
{"id": "task:research->task:write:next", "source": "task:research", "target": "task:write", "kind": "next", "label": None},
|
||||
{"id": "task:research->agent:Researcher:assigned", "source": "task:research", "target": "agent:Researcher", "kind": "assigned", "label": None},
|
||||
{"id": "task:write->agent:Writer:assigned", "source": "task:write", "target": "agent:Writer", "kind": "assigned", "label": None},
|
||||
{"id": "agent:Researcher->tool:Researcher/lookup_fact:uses", "source": "agent:Researcher", "target": "tool:Researcher/lookup_fact", "kind": "uses", "label": None},
|
||||
{"id": "task:write->end:done:next", "source": "task:write", "target": "end:done", "kind": "next", "label": None},
|
||||
],
|
||||
}
|
||||
s0 = r.emit("session_start", 0.0, "", {})
|
||||
w = r.emit("workflow_start", 0.01, "", {"kind": "team", "name": "EV brief", "process": "sequential", "graph": graph}, parent_seq=s0)
|
||||
t1 = r.emit("task_start", 0.02, "Researcher", {"name": "research", "agent": "Researcher",
|
||||
"description_preview": "Get one fact about electric vehicles"},
|
||||
parent_seq=w, node_id="task:research")
|
||||
a1 = r.emit("agent_start", 0.03, "Researcher", {}, parent_seq=t1, node_id="agent:Researcher")
|
||||
r.emit("llm_request", 0.05, "Researcher", {"model": "gpt-4o-mini"}, parent_seq=a1, node_id="agent:Researcher")
|
||||
ts = r.emit("tool_call_start", 2.6, "Researcher", {"tool_name": "lookup_fact", "tool_args": {"topic": "electric vehicles"}},
|
||||
parent_seq=a1, node_id="tool:Researcher/lookup_fact")
|
||||
r.emit("tool_call_end", 3.5, "Researcher", {"tool_name": "lookup_fact", "result": "EV sales passed 17 million in 2024",
|
||||
"duration_ms": 880.0, "error": None},
|
||||
parent_seq=ts, node_id="tool:Researcher/lookup_fact")
|
||||
r.emit("llm_response", 5.0, "Researcher", {"response_tokens": 14, "duration_ms": 4950.0, "model": "gpt-4o-mini",
|
||||
"response_content": "EV sales passed 17 million in 2024."},
|
||||
parent_seq=a1, node_id="agent:Researcher", prompt_tokens=310, completion_tokens=14, cost_usd=0.000055)
|
||||
r.emit("agent_end", 5.01, "Researcher", {}, parent_seq=a1, node_id="agent:Researcher")
|
||||
r.emit("task_end", 5.02, "Researcher", {"status": "completed", "retries": 0, "duration_ms": 5000.0,
|
||||
"output_preview": "EV sales passed 17 million in 2024."}, parent_seq=t1, node_id="task:research")
|
||||
t2 = r.emit("task_start", 5.03, "Writer", {"name": "write", "agent": "Writer",
|
||||
"description_preview": "Write one sentence using the fact"},
|
||||
parent_seq=w, node_id="task:write")
|
||||
a2 = r.emit("agent_start", 5.04, "Writer", {}, parent_seq=t2, node_id="agent:Writer")
|
||||
r.llm(5.05, "Writer", 1.05, a2, prompt=200, completion=25, cost=0.000045, node="agent:Writer",
|
||||
content="Global EV sales passed 17 million in 2024.")
|
||||
r.emit("agent_end", 6.11, "Writer", {}, parent_seq=a2, node_id="agent:Writer")
|
||||
r.emit("task_end", 6.12, "Writer", {"status": "completed", "retries": 0, "duration_ms": 1090.0,
|
||||
"output_preview": "Global EV sales passed 17 million in 2024."}, parent_seq=t2, node_id="task:write")
|
||||
r.emit("workflow_end", 6.13, "", {"status": "completed", "duration_ms": 6120.0}, parent_seq=w)
|
||||
a3 = r.emit("agent_start", 6.2, "Triage", {}, parent_seq=s0, node_id="agent:Triage")
|
||||
r.emit("llm_request", 6.25, "Triage", {"model": "gpt-4o-mini"}, parent_seq=a3, node_id="agent:Triage")
|
||||
tt = r.emit("tool_call_start", 7.7, "Triage", {"tool_name": "transfer_to_billing", "tool_args": {}},
|
||||
parent_seq=a3, node_id="tool:Triage/transfer_to_billing")
|
||||
r.emit("agent_handoff", 7.71, "Triage", {"from_agent": "Triage", "to_agent": "Billing", "kind": "handoff",
|
||||
"reason": "Billing question", "context_passed": {"context_policy": "summary"}},
|
||||
parent_seq=tt, node_id="agent:Billing")
|
||||
a4 = r.emit("agent_start", 7.72, "Billing", {}, parent_seq=tt, node_id="agent:Billing")
|
||||
r.llm(7.75, "Billing", 0.92, a4, prompt=150, completion=19, cost=0.00003, node="agent:Billing",
|
||||
content="Please check your statement for a duplicate charge.")
|
||||
r.emit("agent_end", 8.68, "Billing", {}, parent_seq=a4, node_id="agent:Billing")
|
||||
r.emit("tool_call_end", 8.7, "Triage", {"tool_name": "transfer_to_billing", "result": "Handoff successful",
|
||||
"duration_ms": 1000.0, "error": None},
|
||||
parent_seq=tt, node_id="tool:Triage/transfer_to_billing")
|
||||
r.emit("llm_response", 9.6, "Triage", {"response_tokens": 22, "duration_ms": 3350.0, "model": "gpt-4o-mini"},
|
||||
parent_seq=a3, node_id="agent:Triage", prompt_tokens=240, completion_tokens=22, cost_usd=0.00005)
|
||||
r.emit("agent_end", 9.61, "Triage", {}, parent_seq=a3, node_id="agent:Triage")
|
||||
r.emit("session_end", 9.7, "", {}, parent_seq=s0)
|
||||
r.write("team_outside.jsonl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
team_handoff()
|
||||
flow_parallel_loop()
|
||||
legacy_v10()
|
||||
team_outside()
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-outside-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01"}
|
||||
{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.01,"session_id":"sess-outside-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"team","name":"EV brief","process":"sequential","graph":{"schema":"praisonai.graph/1","kind":"team","name":"EV brief","process":"sequential","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"task:research","kind":"task","label":"research","parent":null,"meta":{}},{"id":"task:write","kind":"task","label":"write","parent":null,"meta":{}},{"id":"agent:Researcher","kind":"agent","label":"Researcher","parent":null,"meta":{}},{"id":"agent:Writer","kind":"agent","label":"Writer","parent":null,"meta":{}},{"id":"tool:Researcher/lookup_fact","kind":"tool","label":"lookup_fact","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->task:research:next","source":"start:begin","target":"task:research","kind":"next","label":null},{"id":"task:research->task:write:next","source":"task:research","target":"task:write","kind":"next","label":null},{"id":"task:research->agent:Researcher:assigned","source":"task:research","target":"agent:Researcher","kind":"assigned","label":null},{"id":"task:write->agent:Writer:assigned","source":"task:write","target":"agent:Writer","kind":"assigned","label":null},{"id":"agent:Researcher->tool:Researcher/lookup_fact:uses","source":"agent:Researcher","target":"tool:Researcher/lookup_fact","kind":"uses","label":null},{"id":"task:write->end:done:next","source":"task:write","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0}
|
||||
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225600.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"research","agent":"Researcher","description_preview":"Get one fact about electric vehicles"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:research"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.03,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":3,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.05,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225602.6,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","tool_args":{"topic":"electric vehicles"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"tool:Researcher/lookup_fact"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.5,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","result":"EV sales passed 17 million in 2024","duration_ms":880.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":5,"node_id":"tool:Researcher/lookup_fact"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225605.0,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":14,"duration_ms":4950.0,"model":"gpt-4o-mini","response_content":"EV sales passed 17 million in 2024."},"prompt_tokens":310,"completion_tokens":14,"cost_usd":5.5e-05,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225605.01,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
|
||||
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225605.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5000.0,"output_preview":"EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"task:research"}
|
||||
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225605.03,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"write","agent":"Writer","description_preview":"Write one sentence using the fact"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:write"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225605.04,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225605.05,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":12,"messages_count":3,"tokens_used":200,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225606.1,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":25,"duration_ms":1050.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Global EV sales passed 17 million in 2024."},"prompt_tokens":200,"completion_tokens":25,"cost_usd":4.5e-05,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225606.11,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
|
||||
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225606.12,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":1090.0,"output_preview":"Global EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"task:write"}
|
||||
{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225606.13,"session_id":"sess-outside-01","agent_name":"","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":6120.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225606.2,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":17,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225606.25,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225607.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","tool_args":{}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"tool:Triage/transfer_to_billing"}
|
||||
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225607.71,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Triage","to_agent":"Billing","kind":"handoff","reason":"Billing question","context_passed":{"context_policy":"summary"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225607.72,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":21,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225607.75,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":22,"messages_count":3,"tokens_used":150,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225608.67,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":19,"duration_ms":920.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Please check your statement for a duplicate charge."},"prompt_tokens":150,"completion_tokens":19,"cost_usd":3e-05,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225608.68,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":24,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
|
||||
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225608.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":25,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","result":"Handoff successful","duration_ms":1000.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"tool:Triage/transfer_to_billing"}
|
||||
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225609.6,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":22,"duration_ms":3350.0,"model":"gpt-4o-mini"},"prompt_tokens":240,"completion_tokens":22,"cost_usd":5e-05,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225609.61,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
|
||||
{"schema_version":"1.1","event_type":"session_end","timestamp":1767225609.7,"session_id":"sess-outside-01","agent_name":"","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0}
|
||||
@@ -343,3 +343,57 @@ test('deriveGraph counts repeats and includes you for approvals', () => {
|
||||
test('module also registers itself on globalThis for the standalone build', () => {
|
||||
assert.equal(typeof globalThis.PraisonReplayCore.buildModel, 'function');
|
||||
});
|
||||
|
||||
// ── observed graph / run map ─────────────────────────────────────────
|
||||
|
||||
test('observedGraph: agents seen in events but not in the graph are "outside"', () => {
|
||||
const { events, graph } = load('team_outside');
|
||||
const g = core.observedGraph(graph, events);
|
||||
assert.deepEqual([...g.outside].sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']);
|
||||
// team members keep their recorded ids; nothing duplicated
|
||||
assert.equal(g.nodes.filter((n) => n.id === 'agent:Researcher').length, 1);
|
||||
assert.ok(g.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff' && e.meta.observed));
|
||||
// the recorded uses-edge is not added twice
|
||||
assert.equal(g.edges.filter((e) => e.source === 'agent:Researcher' && e.target === 'tool:Researcher/lookup_fact').length, 1);
|
||||
});
|
||||
|
||||
test('observedGraph without a recorded graph is the derived map', () => {
|
||||
const { events } = load('legacy_v10');
|
||||
assert.equal(core.observedGraph(null, events).derived, true);
|
||||
});
|
||||
|
||||
test('layoutRunMap: outside agents form their own framed block under the team', () => {
|
||||
const { events, graph } = load('team_outside');
|
||||
const lay = core.layoutRunMap(graph, events);
|
||||
assert.equal(lay.groups.length, 1);
|
||||
const grp = lay.groups[0];
|
||||
assert.equal(grp.label, 'Outside the team');
|
||||
const inside = lay.nodes.filter((n) => !n.outside);
|
||||
const outside = lay.nodes.filter((n) => n.outside);
|
||||
assert.deepEqual(outside.map((n) => n.id).sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']);
|
||||
const teamBottom = Math.max(...inside.map((n) => n.y + n.h));
|
||||
for (const n of outside) {
|
||||
assert.ok(n.y > teamBottom, `${n.id} sits below the team`);
|
||||
assert.ok(n.x >= grp.x && n.x + n.w <= grp.x + grp.w && n.y >= grp.y && n.y + n.h <= grp.y + grp.h, `${n.id} inside the frame`);
|
||||
}
|
||||
for (const n of lay.nodes) assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${n.id} within bounds`);
|
||||
assert.ok(lay.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff'));
|
||||
});
|
||||
|
||||
test('layoutRunMap: no outside group when the graph covers everyone', () => {
|
||||
const { events, graph } = load('team_handoff');
|
||||
const lay = core.layoutRunMap(graph, events);
|
||||
assert.equal(lay.groups.length, 0);
|
||||
assert.equal(lay.nodes.length, graph.nodes.length);
|
||||
});
|
||||
|
||||
test('real-run shape: LLM span encloses the tool call in the same lane', () => {
|
||||
const { events, graph } = load('team_outside');
|
||||
const m = buildModel(events, graph);
|
||||
const llm = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'llm');
|
||||
const tool = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'tool');
|
||||
assert.equal(llm.lane, tool.lane);
|
||||
assert.ok(llm.start < tool.start && tool.end < llm.end); // the view must put them on separate rows
|
||||
assert.ok(m.lanes.some((l) => l.id === 'agent:Triage') && m.lanes.some((l) => l.id === 'agent:Billing'));
|
||||
assert.deepEqual(m.arrows.map((a) => `${a.kind}:${a.from}->${a.to}`), ['handoff:agent:Triage->agent:Billing']);
|
||||
});
|
||||
Reference in new issue
Block a user