transcript-viewer 0.11.0__tar.gz → 0.11.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/PKG-INFO +9 -3
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/README.md +8 -2
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/pyproject.toml +1 -1
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/page.html +171 -25
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/page.test.js +179 -5
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/uv.lock +1 -1
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.coverage +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.github/workflows/publish.yml +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.github/workflows/test.yml +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.gitignore +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Anchor.dc.html +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Dense.dc.html +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Hover.dc.html +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Main.dc.html +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Quiet.dc.html +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/canvas.json +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/delegation-timeline.html +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/__init__.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/ai.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/cli.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/config.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/corpus.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/fetch.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/library.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/store.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/viewer.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/fixtures/big-command.json +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_ai.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_cli.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_config.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_corpus.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_fetch.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_library.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_page.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_readme.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_store.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_stream.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_style.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_tidiness.py +0 -0
- {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_viewer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: transcript-viewer
|
|
3
|
-
Version: 0.11.
|
|
3
|
+
Version: 0.11.2
|
|
4
4
|
Summary: Browse agent transcripts in a local, dependency-free web viewer.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -182,8 +182,14 @@ so. Nothing is left behind and nothing is copied twice.
|
|
|
182
182
|
A run that delegated shows a **timeline** above its steps: one lane per
|
|
183
183
|
subagent, laid out on the clock. It exists for the one thing a nested box
|
|
184
184
|
cannot say — that two subagents were running at the same moment, which 25 pairs
|
|
185
|
-
in the corpus this was built against were.
|
|
186
|
-
|
|
185
|
+
in the corpus this was built against were. Two bars in the same column of the
|
|
186
|
+
chart is what says it; overlap gets no colour of its own, since a second
|
|
187
|
+
encoding of the same fact is noise.
|
|
188
|
+
|
|
189
|
+
Clicking a lane opens the box the transcript already has for it rather than
|
|
190
|
+
showing the same thing twice, drawing the run out far enough to reach it first:
|
|
191
|
+
a long run paints 250 steps at a time, and in one real session every delegation
|
|
192
|
+
happens after step 1,231.
|
|
187
193
|
|
|
188
194
|
Where a session spans days but holds hours of work — one runs 10.3 days and
|
|
189
195
|
contains 5.3 hours, with 31 gaps over an hour — the quiet stretches are skipped
|
|
@@ -169,8 +169,14 @@ so. Nothing is left behind and nothing is copied twice.
|
|
|
169
169
|
A run that delegated shows a **timeline** above its steps: one lane per
|
|
170
170
|
subagent, laid out on the clock. It exists for the one thing a nested box
|
|
171
171
|
cannot say — that two subagents were running at the same moment, which 25 pairs
|
|
172
|
-
in the corpus this was built against were.
|
|
173
|
-
|
|
172
|
+
in the corpus this was built against were. Two bars in the same column of the
|
|
173
|
+
chart is what says it; overlap gets no colour of its own, since a second
|
|
174
|
+
encoding of the same fact is noise.
|
|
175
|
+
|
|
176
|
+
Clicking a lane opens the box the transcript already has for it rather than
|
|
177
|
+
showing the same thing twice, drawing the run out far enough to reach it first:
|
|
178
|
+
a long run paints 250 steps at a time, and in one real session every delegation
|
|
179
|
+
happens after step 1,231.
|
|
174
180
|
|
|
175
181
|
Where a session spans days but holds hours of work — one runs 10.3 days and
|
|
176
182
|
contains 5.3 hours, with 31 gaps over an hour — the quiet stretches are skipped
|
|
@@ -371,6 +371,8 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
|
|
|
371
371
|
.branch .inner{padding:6px 14px 4px 22px;border-top:1px solid var(--line);background:var(--sunk)}
|
|
372
372
|
.branch .inner .step{border-left-color:var(--line)}
|
|
373
373
|
.miss{padding:9px 12px;color:var(--dim);font-size:12.5px}
|
|
374
|
+
/* Where a lane just sent you. Long enough to notice, short enough to forget. */
|
|
375
|
+
.branch.found{box-shadow:0 0 0 2px var(--accent)}
|
|
374
376
|
|
|
375
377
|
/* ---- delegation timeline ----
|
|
376
378
|
Nested boxes cannot show that two subagents ran at once, and 25 pairs in the
|
|
@@ -396,7 +398,6 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
|
|
|
396
398
|
.lane.tight .track{height:13px}
|
|
397
399
|
.track .rule{position:absolute;left:0;right:0;top:50%;height:1px;background:var(--line)}
|
|
398
400
|
.track .tick{position:absolute;top:0;bottom:0;width:1px;background:var(--line);opacity:.45}
|
|
399
|
-
.track .band{position:absolute;top:0;bottom:0;background:var(--hl)}
|
|
400
401
|
/* A floor, so a four-minute subagent in a thirty-hour run is still a target.
|
|
401
402
|
Where a bar is at the floor its width is not its duration — the card and
|
|
402
403
|
the tooltip carry the real one. */
|
|
@@ -420,6 +421,9 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
|
|
|
420
421
|
.gapmark:after{content:"";position:absolute;left:-2px;top:0;bottom:0;width:5px;
|
|
421
422
|
background:var(--bg);opacity:.9;border-left:1px dashed var(--muted);
|
|
422
423
|
border-right:1px dashed var(--muted);opacity:.35}
|
|
424
|
+
/* Conversations that belong to the record rather than to any step. */
|
|
425
|
+
.loose{margin-top:18px;padding-top:12px;border-top:1px solid var(--line)}
|
|
426
|
+
.loose-head{font:11px var(--font-mono);color:var(--muted);margin-bottom:8px}
|
|
423
427
|
.lanes-legend{display:flex;align-items:center;gap:14px;margin-top:10px;padding-top:9px;
|
|
424
428
|
border-top:1px solid var(--line);font-size:11px;color:var(--muted);flex-wrap:wrap}
|
|
425
429
|
.lanes-legend i{display:inline-block;width:16px;height:8px;border-radius:2px;
|
|
@@ -2366,6 +2370,9 @@ function haystack(s){
|
|
|
2366
2370
|
Where they do not, the same lanes are laid out by step position and the axis
|
|
2367
2371
|
says so, rather than inventing a duration. */
|
|
2368
2372
|
|
|
2373
|
+
/* "1 subagent" and "2 subagents", said once rather than at each call site. */
|
|
2374
|
+
const many = (n, one, more) => `${num(n)} ${n === 1 ? one : more || one + "s"}`;
|
|
2375
|
+
|
|
2369
2376
|
const at = (s) => {
|
|
2370
2377
|
const t = s && s.timestamp;
|
|
2371
2378
|
if (!t) return null;
|
|
@@ -2419,6 +2426,36 @@ function laneIndex(t) {
|
|
|
2419
2426
|
});
|
|
2420
2427
|
};
|
|
2421
2428
|
walk(t, 0);
|
|
2429
|
+
|
|
2430
|
+
// A subagent that no call refers to. ATIF links delegation from the result of
|
|
2431
|
+
// the call that spawned it, but not every format has a call to link from:
|
|
2432
|
+
// MALT's records carry an advisor, an agent and its raters as trajectories in
|
|
2433
|
+
// their own right, spawned by nothing. Walking only the references missed all
|
|
2434
|
+
// of them, and a record with ten subagents drew no timeline at all.
|
|
2435
|
+
const linked = new Set(lanes.map((l) => l.id));
|
|
2436
|
+
const loose = (parent, depth) => {
|
|
2437
|
+
for (const sub of parent.subagent_trajectories || []) {
|
|
2438
|
+
if (!linked.has(sub.trajectory_id)) {
|
|
2439
|
+
const x = sub.extra || {};
|
|
2440
|
+
lanes.push({
|
|
2441
|
+
id: sub.trajectory_id,
|
|
2442
|
+
kind: x.agentType || sub.agent?.name || "subagent",
|
|
2443
|
+
said: x.description || "",
|
|
2444
|
+
steps: (sub.steps || []).length,
|
|
2445
|
+
tools: [...new Set((sub.steps || []).flatMap(
|
|
2446
|
+
(s) => (s.tool_calls || []).map((c) => c.function_name)))],
|
|
2447
|
+
span: spanMs(sub.steps),
|
|
2448
|
+
fromStep: null,
|
|
2449
|
+
fromIndex: 0,
|
|
2450
|
+
depth,
|
|
2451
|
+
});
|
|
2452
|
+
linked.add(sub.trajectory_id);
|
|
2453
|
+
}
|
|
2454
|
+
loose(sub, depth + 1);
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
loose(t, 0);
|
|
2458
|
+
|
|
2422
2459
|
return lanes;
|
|
2423
2460
|
}
|
|
2424
2461
|
|
|
@@ -2529,10 +2566,17 @@ function drawLanes(t) {
|
|
|
2529
2566
|
width:${Math.max(100 / Math.max(1, steps), 0.6)}%" title="${esc(lane.kind)}"
|
|
2530
2567
|
onmouseenter="laneCard(event,'${esc(lane.id)}')" onmouseleave="laneCard(null)"
|
|
2531
2568
|
onclick="openLane('${esc(lane.id)}')"></button>`)).join("");
|
|
2532
|
-
return laneShell(
|
|
2533
|
-
|
|
2569
|
+
return laneShell({
|
|
2570
|
+
count: LANES.length,
|
|
2571
|
+
rows,
|
|
2572
|
+
mainRow: laneRow({ kind: "main thread", steps, main: true }, tight,
|
|
2534
2573
|
`<span class="bar main" style="left:0;width:100%"></span>`),
|
|
2535
|
-
"",
|
|
2574
|
+
marks: "",
|
|
2575
|
+
// No clock to lay anything against, so no duration is claimed and no
|
|
2576
|
+
// overlap either: without times, two bars sitting side by side would
|
|
2577
|
+
// say something the log never recorded.
|
|
2578
|
+
note: "laid out by step · these steps carry no times",
|
|
2579
|
+
});
|
|
2536
2580
|
}
|
|
2537
2581
|
|
|
2538
2582
|
// Everything the run touched, parent and children alike.
|
|
@@ -2547,11 +2591,6 @@ function drawLanes(t) {
|
|
|
2547
2591
|
const scale = timeScale(lo, hi, busy);
|
|
2548
2592
|
const bands = together(timed);
|
|
2549
2593
|
|
|
2550
|
-
const bandLayer = bands.map(([a, b]) => {
|
|
2551
|
-
const left = scale.posOf(a);
|
|
2552
|
-
return `<span class="band" style="left:${left}%;width:${Math.max(scale.posOf(b) - left, 0.3)}%"></span>`;
|
|
2553
|
-
}).join("");
|
|
2554
|
-
|
|
2555
2594
|
const breaks = scale.gaps.map((g) =>
|
|
2556
2595
|
`<span class="gapmark" style="left:${g.at}%" title="nothing running for ${esc(g.took)}"></span>`
|
|
2557
2596
|
).join("");
|
|
@@ -2579,13 +2618,24 @@ function drawLanes(t) {
|
|
|
2579
2618
|
return { pos: (m.pos / 100) * scale.room, label: m.label };
|
|
2580
2619
|
});
|
|
2581
2620
|
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2621
|
+
// What the axis leaves out, in the reader's terms. How many separate gaps
|
|
2622
|
+
// were collapsed is a fact about the drawing, not about the run, so it is not
|
|
2623
|
+
// said; how much time is missing from the picture very much is.
|
|
2624
|
+
const hidden = scale.gaps.length ? `${duration(scale.skipped)} of waiting hidden` : "";
|
|
2625
|
+
const note = [hidden, "click a bar to open it"].filter(Boolean).join(" · ");
|
|
2626
|
+
|
|
2627
|
+
const [shownAs, wholeAs] = durationPair(scale.kept, hi - lo);
|
|
2628
|
+
return laneShell({
|
|
2629
|
+
count: LANES.length,
|
|
2630
|
+
shown: shownAs,
|
|
2631
|
+
whole: wholeAs,
|
|
2632
|
+
rows,
|
|
2633
|
+
mainRow,
|
|
2634
|
+
marks: marks.map((m) => `<span style="left:${m.pos}%">${esc(m.label)}</span>`).join(""),
|
|
2635
|
+
note,
|
|
2636
|
+
layer: breaks,
|
|
2637
|
+
overlaps: bands.length,
|
|
2638
|
+
});
|
|
2589
2639
|
}
|
|
2590
2640
|
|
|
2591
2641
|
/* One row: who, how many steps, and the track it sits on. */
|
|
@@ -2602,14 +2652,35 @@ function laneRow(lane, tight, inner) {
|
|
|
2602
2652
|
}
|
|
2603
2653
|
|
|
2604
2654
|
/* The frame around the lanes: what it is, the axis, and what was left out. */
|
|
2605
|
-
|
|
2655
|
+
/* What the header says about a run. Every clause is computed, and each is true
|
|
2656
|
+
of any transcript rather than of the one it was written against:
|
|
2657
|
+
|
|
2658
|
+
- the count of subagents;
|
|
2659
|
+
- how much time the axis is showing, and — only when they differ — the whole
|
|
2660
|
+
stretch it was drawn from. Saying "31h" of a run that ran ten days would be
|
|
2661
|
+
wrong, and it is the axis that was shortened, not the run;
|
|
2662
|
+
- how many times two or more were going at once, which is what the view is
|
|
2663
|
+
for. Called "overlapped" rather than "stretches", because the skipped
|
|
2664
|
+
stretches below are the opposite thing and the word was doing both jobs.
|
|
2665
|
+
*/
|
|
2666
|
+
function laneHeader({ count, shown, whole, overlaps }) {
|
|
2667
|
+
const parts = [many(count, "subagent")];
|
|
2668
|
+
if (shown) parts.push(whole && whole !== shown ? `${shown} shown of ${whole}` : shown);
|
|
2669
|
+
if (overlaps) {
|
|
2670
|
+
parts.push(
|
|
2671
|
+
`<em>overlapped ${overlaps === 1 ? "once" : many(overlaps, "time")}</em>`,
|
|
2672
|
+
);
|
|
2673
|
+
}
|
|
2674
|
+
return parts;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function laneShell({ count, shown, whole, rows, mainRow, marks, note, layer, overlaps }) {
|
|
2678
|
+
const parts = laneHeader({ count, shown, whole, overlaps });
|
|
2606
2679
|
return `
|
|
2607
2680
|
<details id="lanes" open>
|
|
2608
2681
|
<summary>
|
|
2609
2682
|
<b>Delegation</b>
|
|
2610
|
-
<span class="fine">${
|
|
2611
|
-
overlaps ? ` · <em>${num(overlaps)} stretch${overlaps === 1 ? "" : "es"} with two or more at once</em>` : ""
|
|
2612
|
-
}</span>
|
|
2683
|
+
<span class="fine">${parts.join(" · ")}</span>
|
|
2613
2684
|
</summary>
|
|
2614
2685
|
<div class="lanes-body">
|
|
2615
2686
|
<div class="lane-axis${rows.includes("lane tight") ? " tight" : ""}">
|
|
@@ -2623,7 +2694,6 @@ function laneShell(count, span, rows, mainRow, marks, note, layer, overlaps) {
|
|
|
2623
2694
|
<div class="lanes-legend">
|
|
2624
2695
|
<span><i style="background:var(--ink);opacity:.85"></i>main thread</span>
|
|
2625
2696
|
<span><i style="background:var(--accent);opacity:.85"></i>subagent</span>
|
|
2626
|
-
${overlaps ? `<span><i style="background:var(--hl)"></i>two or more at once</span>` : ""}
|
|
2627
2697
|
<span class="lanes-note">${esc(note)}</span>
|
|
2628
2698
|
</div>
|
|
2629
2699
|
</div>
|
|
@@ -2671,16 +2741,73 @@ function laneCard(ev, id) {
|
|
|
2671
2741
|
/* Open the subagent a lane stands for: scroll to the step that called it and
|
|
2672
2742
|
unfold the box that is already there, rather than inventing a second place
|
|
2673
2743
|
for the same thing to live. */
|
|
2744
|
+
/* Open the subagent a lane stands for: reach the step that called it, unfold
|
|
2745
|
+
the box already there, and go to it.
|
|
2746
|
+
|
|
2747
|
+
A long run is drawn 250 steps at a time. In one real session all thirty
|
|
2748
|
+
delegations happen after step 1,231, so the box a lane pointed at had never
|
|
2749
|
+
been painted and the click did nothing at all. The run is drawn far enough
|
|
2750
|
+
first. A lane from a record where nothing spawned it is found in the block
|
|
2751
|
+
after the run instead — which only exists in the plain view, so a lens or a
|
|
2752
|
+
search is cleared to get back to it rather than failing quietly. */
|
|
2674
2753
|
function openLane(id) {
|
|
2675
2754
|
laneCard(null);
|
|
2676
|
-
const
|
|
2677
|
-
if (!
|
|
2755
|
+
const lane = LANES.find((l) => l.id === id);
|
|
2756
|
+
if (!lane) return;
|
|
2757
|
+
|
|
2758
|
+
const find = () =>
|
|
2759
|
+
document.querySelector(`details.branch[data-lane="${CSS.escape(id)}"]`);
|
|
2760
|
+
|
|
2761
|
+
let box = find();
|
|
2762
|
+
if (!box) {
|
|
2763
|
+
let repaint = false;
|
|
2764
|
+
if (lane.fromStep === null && (lens !== "all" || query)) {
|
|
2765
|
+
lens = "all";
|
|
2766
|
+
query = "";
|
|
2767
|
+
repaint = true;
|
|
2768
|
+
}
|
|
2769
|
+
if (lane.fromIndex >= limit) {
|
|
2770
|
+
// Far enough to include it, and a little beyond so it is not the last row.
|
|
2771
|
+
limit = lane.fromIndex + PAGE_SIZE;
|
|
2772
|
+
repaint = true;
|
|
2773
|
+
}
|
|
2774
|
+
if (repaint) render();
|
|
2775
|
+
box = find();
|
|
2776
|
+
}
|
|
2777
|
+
if (!box) {
|
|
2778
|
+
note("That subagent is not in this view.");
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2678
2782
|
box.open = true;
|
|
2679
|
-
box.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
2680
2783
|
box.classList.add("found");
|
|
2784
|
+
// After the fold, not before: opening changes the height, and scrolling to
|
|
2785
|
+
// where the box used to end leaves the reader above what they asked for.
|
|
2786
|
+
requestAnimationFrame(() =>
|
|
2787
|
+
box.scrollIntoView({ behavior: "smooth", block: "center" }),
|
|
2788
|
+
);
|
|
2681
2789
|
setTimeout(() => box.classList.remove("found"), 1400);
|
|
2682
2790
|
}
|
|
2683
2791
|
|
|
2792
|
+
/* Subagents that no call refers to. They are drawn after the run rather than
|
|
2793
|
+
inside it, because there is no step they belong to: MALT records an advisor,
|
|
2794
|
+
an agent and its raters as trajectories spawned by nothing, and until now the
|
|
2795
|
+
viewer held them without ever showing them. The timeline made that visible by
|
|
2796
|
+
pointing at boxes that were not there. */
|
|
2797
|
+
function looseSubagents(t){
|
|
2798
|
+
const linked=new Set();
|
|
2799
|
+
const mark=p=>{
|
|
2800
|
+
for(const s of p.steps||[]){
|
|
2801
|
+
for(const r of s.observation?.results||[]){
|
|
2802
|
+
for(const ref of r.subagent_trajectory_ref||[])linked.add(ref.trajectory_id);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
for(const sub of p.subagent_trajectories||[])mark(sub);
|
|
2806
|
+
};
|
|
2807
|
+
mark(t);
|
|
2808
|
+
return (t.subagent_trajectories||[]).filter(s=>!linked.has(s.trajectory_id));
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2684
2811
|
function branchIndex(t){
|
|
2685
2812
|
const out=[];
|
|
2686
2813
|
t.steps.forEach((s,i)=>{
|
|
@@ -2787,7 +2914,16 @@ function trajectoryTab(t,e,branches,branchSteps,counts,q){
|
|
|
2787
2914
|
${shown.map(({s,i})=>step(s,t,0,i,"")).join("")
|
|
2788
2915
|
||`<div class="empty">Nothing matches.</div>`}
|
|
2789
2916
|
${total>shown.length?`<div class="more"><button onclick="more()">Show more —
|
|
2790
|
-
${num(shown.length)} of ${num(total)} steps</button></div>`:""}
|
|
2917
|
+
${num(shown.length)} of ${num(total)} steps</button></div>`:""}
|
|
2918
|
+
${(()=>{
|
|
2919
|
+
// Only in the plain view: a lens or a search is a question about the
|
|
2920
|
+
// steps of this run, and answering it with whole other conversations
|
|
2921
|
+
// would be answering something else.
|
|
2922
|
+
if(lens!=="all"||q)return "";
|
|
2923
|
+
const loose=looseSubagents(t);
|
|
2924
|
+
return loose.length?`<div class="loose"><div class="loose-head">
|
|
2925
|
+
${many(loose.length,"other conversation")} in this record — no step spawned them</div>
|
|
2926
|
+
${loose.map(sub=>branch({trajectory_id:sub.trajectory_id},t,0)).join("")}</div>`:""})()}`;
|
|
2791
2927
|
}
|
|
2792
2928
|
|
|
2793
2929
|
/* The switch stays visible when AI is off for a transcript — otherwise there
|
|
@@ -2936,6 +3072,16 @@ function spanOf(steps){
|
|
|
2936
3072
|
return ms>0?ms:null;
|
|
2937
3073
|
}
|
|
2938
3074
|
|
|
3075
|
+
/* Two durations that will be read against each other, in one unit, so "75m
|
|
3076
|
+
shown of 1.6h" cannot happen — the pair is scaled by the larger of them. */
|
|
3077
|
+
function durationPair(small, large){
|
|
3078
|
+
if(small==null||large==null)return [duration(small),duration(large)];
|
|
3079
|
+
const unit=large/1000<90?1000:large/60000<90?60000:large/3600000<48?3600000:86400000;
|
|
3080
|
+
const suffix=unit===1000?"s":unit===60000?"m":unit===3600000?"h":"d";
|
|
3081
|
+
const show=v=>{const n=v/unit;return (n<10?n.toFixed(1).replace(/\.0$/,""):String(Math.round(n)))+suffix};
|
|
3082
|
+
return [show(small),show(large)];
|
|
3083
|
+
}
|
|
3084
|
+
|
|
2939
3085
|
function duration(ms){
|
|
2940
3086
|
if(ms==null)return null;
|
|
2941
3087
|
// A whole number reads better without the decimal: 2h, not 2.0h.
|
|
@@ -66,8 +66,31 @@ for (const id of [
|
|
|
66
66
|
const SAFE_FETCH = () =>
|
|
67
67
|
Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
68
68
|
|
|
69
|
+
/* Lookup by id, as the page expects. Reset with fetch on every run: a test that
|
|
70
|
+
stubs it to watch one element used to leave the next test's render() holding
|
|
71
|
+
an object with no focus(), which fails somewhere else entirely. */
|
|
72
|
+
const SAFE_BY_ID = () => null;
|
|
73
|
+
|
|
74
|
+
/* Looking for an element by selector, against what was actually painted. The
|
|
75
|
+
stub DOM holds markup as a string rather than a tree, so this answers the one
|
|
76
|
+
question the page asks — is this box on the page — from that string. */
|
|
77
|
+
const SAFE_QUERY = (sel) => {
|
|
78
|
+
const m = /\[data-lane="([^"]+)"\]/.exec(sel);
|
|
79
|
+
if (!m) return null;
|
|
80
|
+
const html = (global.main && global.main.innerHTML) || "";
|
|
81
|
+
return html.includes(`data-lane="${m[1]}"`)
|
|
82
|
+
? { open: false, classList: { add() {}, remove() {} }, scrollIntoView() {} }
|
|
83
|
+
: null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/* The page escapes an id before putting it in a selector; the stub DOM has no
|
|
87
|
+
CSS object, so provide the one method that is used. */
|
|
88
|
+
global.CSS = { escape: (v) => String(v).replace(/["\\]/g, "\\$&") };
|
|
89
|
+
|
|
69
90
|
const run = (body) => {
|
|
70
91
|
global.fetch = SAFE_FETCH;
|
|
92
|
+
global.document.getElementById = SAFE_BY_ID;
|
|
93
|
+
global.document.querySelector = SAFE_QUERY;
|
|
71
94
|
return eval(script + "\n;(() => {" + body + "})()");
|
|
72
95
|
};
|
|
73
96
|
|
|
@@ -1840,8 +1863,30 @@ test("the timeline says how many ran at once", () => {
|
|
|
1840
1863
|
const html = run(`return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`);
|
|
1841
1864
|
assert.match(html, /id="lanes"/);
|
|
1842
1865
|
assert.match(html, /2 subagents/);
|
|
1843
|
-
assert.match(html, /
|
|
1844
|
-
|
|
1866
|
+
assert.match(html, /overlapped once/, "one overlap reads as once, not '1 times'");
|
|
1867
|
+
// Overlap is not given a colour of its own: two bars in the same column of
|
|
1868
|
+
// the chart already say it, and a second encoding of the same fact is noise.
|
|
1869
|
+
assert.ok(!/class="band"/.test(html), "the lanes are the encoding");
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
test("the header counts, and counts in English", () => {
|
|
1873
|
+
const one = run(`return laneHeader({count: 1, shown: "5m", overlaps: 0}).join(" · ")`);
|
|
1874
|
+
assert.strictEqual(one, "1 subagent · 5m");
|
|
1875
|
+
|
|
1876
|
+
const some = run(`return laneHeader({count: 12, shown: "2h", overlaps: 4}).join(" · ")`);
|
|
1877
|
+
assert.match(some, /^12 subagents · 2h · /);
|
|
1878
|
+
assert.match(some, /overlapped 4 times/);
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
test("the header never passes off the drawn time as the whole run", () => {
|
|
1882
|
+
// The axis skips the waiting, so a run of ten days can show thirty-one hours.
|
|
1883
|
+
// Saying "31h" alone would be false about the run.
|
|
1884
|
+
const elided = run(`return laneHeader({count: 3, shown: "31h", whole: "10d"}).join(" · ")`);
|
|
1885
|
+
assert.match(elided, /31h shown of 10d/);
|
|
1886
|
+
|
|
1887
|
+
// Where nothing was skipped the two are the same and it says it once.
|
|
1888
|
+
const straight = run(`return laneHeader({count: 3, shown: "20m", whole: "20m"}).join(" · ")`);
|
|
1889
|
+
assert.strictEqual(straight, "3 subagents · 20m");
|
|
1845
1890
|
});
|
|
1846
1891
|
|
|
1847
1892
|
test("a transcript that delegated nothing gets no timeline at all", () => {
|
|
@@ -1856,8 +1901,10 @@ test("without timestamps the lanes fall back to step position and say so", () =>
|
|
|
1856
1901
|
for (const s of sub.steps) delete s.timestamp;
|
|
1857
1902
|
}
|
|
1858
1903
|
const html = run(`return drawLanes(${JSON.stringify(untimed)})`);
|
|
1859
|
-
assert.match(html, /no
|
|
1904
|
+
assert.match(html, /carry no times/, "it must not imply a duration it does not have");
|
|
1860
1905
|
assert.ok(!/class="band"/.test(html), "and must not claim overlap it cannot know");
|
|
1906
|
+
assert.ok(!/overlapped/.test(html), "nor say two things ran together");
|
|
1907
|
+
assert.ok(!/hidden/.test(html), "nor that anything was left out");
|
|
1861
1908
|
});
|
|
1862
1909
|
|
|
1863
1910
|
test("the axis is read in whole units, not seven of them", () => {
|
|
@@ -1946,7 +1993,134 @@ test("what was skipped is stated, not swallowed", () => {
|
|
|
1946
1993
|
] }],
|
|
1947
1994
|
};
|
|
1948
1995
|
const html = run(`return drawLanes(${JSON.stringify(t)})`);
|
|
1949
|
-
assert.match(html, /
|
|
1950
|
-
assert.match(html, /
|
|
1996
|
+
assert.match(html, /of waiting hidden/, "a reader must be told time was left out");
|
|
1997
|
+
assert.match(html, /shown of/, "and that the axis is shorter than the run");
|
|
1951
1998
|
assert.match(html, /class="gapmark"/, "and see where");
|
|
1999
|
+
// How many gaps were collapsed is a fact about the drawing, not the run.
|
|
2000
|
+
assert.ok(!/\d+ quiet stretch/.test(html), "that is not the reader's business");
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
test("every phrase in the timeline is computed, none of it written per run", () => {
|
|
2004
|
+
// The header and note are built from counts and durations alone: the same run
|
|
2005
|
+
// renders the same words, and a different one renders different ones for the
|
|
2006
|
+
// same reason. Nothing here is specific to any transcript.
|
|
2007
|
+
const twice = [1, 2].map(() =>
|
|
2008
|
+
run(`return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`),
|
|
2009
|
+
);
|
|
2010
|
+
assert.strictEqual(twice[0], twice[1], "the same run must read the same way");
|
|
2011
|
+
|
|
2012
|
+
const bigger = JSON.parse(JSON.stringify(TWO_AT_ONCE));
|
|
2013
|
+
bigger.subagent_trajectories[1].steps = [
|
|
2014
|
+
{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:30:00Z" },
|
|
2015
|
+
{ step_id: 2, source: "agent", timestamp: "2026-01-01T10:40:00Z" },
|
|
2016
|
+
];
|
|
2017
|
+
const changed = run(`return drawLanes(${JSON.stringify(bigger)})`);
|
|
2018
|
+
assert.notStrictEqual(changed, twice[0], "a different run must read differently");
|
|
2019
|
+
assert.ok(!/overlapped/.test(changed), "and this one no longer overlaps");
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
test("a subagent no call spawned still gets a lane and a box", () => {
|
|
2023
|
+
// MALT records an advisor, an agent and its raters as trajectories in their
|
|
2024
|
+
// own right. Walking only the call references found none of them, so a record
|
|
2025
|
+
// with ten subagents drew no timeline and showed none of them anywhere.
|
|
2026
|
+
const t = {
|
|
2027
|
+
steps: [{ step_id: 1, source: "user", message: "do the thing",
|
|
2028
|
+
timestamp: "2026-01-01T10:00:00Z" }],
|
|
2029
|
+
subagent_trajectories: [
|
|
2030
|
+
{ trajectory_id: "advisor", agent: { name: "metr-malt" }, extra: {},
|
|
2031
|
+
steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:00:05Z" },
|
|
2032
|
+
{ step_id: 2, source: "agent", timestamp: "2026-01-01T10:02:00Z" }] },
|
|
2033
|
+
{ trajectory_id: "rater", agent: { name: "metr-malt" }, extra: {},
|
|
2034
|
+
steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:01:00Z" },
|
|
2035
|
+
{ step_id: 2, source: "agent", timestamp: "2026-01-01T10:03:00Z" }] },
|
|
2036
|
+
],
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
const lanes = run(`return laneIndex(${JSON.stringify(t)})`);
|
|
2040
|
+
assert.strictEqual(lanes.length, 2, "both belong on the timeline");
|
|
2041
|
+
assert.deepStrictEqual(lanes.map((l) => l.fromStep), [null, null],
|
|
2042
|
+
"and neither claims a step spawned it");
|
|
2043
|
+
|
|
2044
|
+
const loose = run(`return looseSubagents(${JSON.stringify(t)}).map(s=>s.trajectory_id)`);
|
|
2045
|
+
assert.deepStrictEqual(loose, ["advisor", "rater"],
|
|
2046
|
+
"the transcript must show them somewhere, or the lanes point at nothing");
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
test("a subagent a call did spawn is not also shown loose", () => {
|
|
2050
|
+
const t = {
|
|
2051
|
+
steps: [{ step_id: 1, source: "agent",
|
|
2052
|
+
tool_calls: [{ tool_call_id: "c1", function_name: "Task" }],
|
|
2053
|
+
observation: { results: [{ source_call_id: "c1",
|
|
2054
|
+
subagent_trajectory_ref: [{ trajectory_id: "a" }] }] } }],
|
|
2055
|
+
subagent_trajectories: [{ trajectory_id: "a", agent: { name: "claude-code" },
|
|
2056
|
+
extra: {}, steps: [] }],
|
|
2057
|
+
};
|
|
2058
|
+
const loose = run(`return looseSubagents(${JSON.stringify(t)})`);
|
|
2059
|
+
assert.deepStrictEqual(loose, [], "it already has a home in the run");
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
test("two durations read against each other share a unit", () => {
|
|
2063
|
+
// "75m shown of 1.6h" is two scales in one sentence.
|
|
2064
|
+
const pair = run(`return durationPair(75*60000, 96*60000)`);
|
|
2065
|
+
assert.deepStrictEqual(pair, ["1.3h", "1.6h"]);
|
|
2066
|
+
|
|
2067
|
+
const wide = run(`return durationPair(31*3600000, 10*86400000)`);
|
|
2068
|
+
assert.deepStrictEqual(wide, ["1.3d", "10d"]);
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
test("a lane reaches its box even when the run has not been drawn that far", () => {
|
|
2072
|
+
// The bug: a long run paints 250 steps at a time, and in one real session all
|
|
2073
|
+
// thirty delegations happen after step 1,231 — so the box a lane pointed at
|
|
2074
|
+
// had never been painted and clicking did nothing whatever.
|
|
2075
|
+
const steps = [];
|
|
2076
|
+
for (let i = 0; i < 400; i++) {
|
|
2077
|
+
steps.push({ step_id: i + 1, source: "agent", message: "working" });
|
|
2078
|
+
}
|
|
2079
|
+
steps[300] = {
|
|
2080
|
+
step_id: 301, source: "agent",
|
|
2081
|
+
tool_calls: [{ tool_call_id: "c1", function_name: "Task" }],
|
|
2082
|
+
observation: { results: [{ source_call_id: "c1",
|
|
2083
|
+
subagent_trajectory_ref: [{ trajectory_id: "far" }] }] },
|
|
2084
|
+
};
|
|
2085
|
+
const t = {
|
|
2086
|
+
steps,
|
|
2087
|
+
subagent_trajectories: [{ trajectory_id: "far", agent: { name: "claude-code" },
|
|
2088
|
+
extra: { agentType: "Explore" }, steps: [] }],
|
|
2089
|
+
};
|
|
2090
|
+
|
|
2091
|
+
const out = run(
|
|
2092
|
+
setup({ ...ROW, key: "k" }) + `
|
|
2093
|
+
traj = ${JSON.stringify(t)};
|
|
2094
|
+
cur = "k"; lens = "all"; query = ""; limit = 250;
|
|
2095
|
+
render();
|
|
2096
|
+
const before = !!document.querySelector('details.branch[data-lane="far"]');
|
|
2097
|
+
LANES = laneIndex(traj);
|
|
2098
|
+
openLane("far");
|
|
2099
|
+
const after = !!document.querySelector('details.branch[data-lane="far"]');
|
|
2100
|
+
return { before, after, limit };
|
|
2101
|
+
`);
|
|
2102
|
+
|
|
2103
|
+
assert.strictEqual(out.before, false, "the box starts undrawn — this is the bug");
|
|
2104
|
+
assert.strictEqual(out.after, true, "and the click has to reach it");
|
|
2105
|
+
assert.ok(out.limit > 300, `the run was drawn far enough (limit ${out.limit})`);
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
test("a lane whose box is filtered away brings the view back rather than failing", () => {
|
|
2109
|
+
const t = {
|
|
2110
|
+
steps: [{ step_id: 1, source: "user", message: "go" }],
|
|
2111
|
+
subagent_trajectories: [{ trajectory_id: "loose", agent: { name: "metr-malt" },
|
|
2112
|
+
extra: {}, steps: [] }],
|
|
2113
|
+
};
|
|
2114
|
+
const out = run(
|
|
2115
|
+
setup({ ...ROW, key: "k" }) + `
|
|
2116
|
+
traj = ${JSON.stringify(t)};
|
|
2117
|
+
cur = "k"; lens = "tools"; query = "nothing matches this"; limit = 250;
|
|
2118
|
+
render();
|
|
2119
|
+
LANES = laneIndex(traj);
|
|
2120
|
+
openLane("loose");
|
|
2121
|
+
return { lens, query, found: !!document.querySelector('details.branch[data-lane="loose"]') };
|
|
2122
|
+
`);
|
|
2123
|
+
assert.strictEqual(out.lens, "all", "the lens is cleared to reach it");
|
|
2124
|
+
assert.strictEqual(out.query, "");
|
|
2125
|
+
assert.strictEqual(out.found, true);
|
|
1952
2126
|
});
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|