transcript-viewer 0.11.0__tar.gz → 0.11.1__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.
Files changed (40) hide show
  1. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/PKG-INFO +1 -1
  2. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/pyproject.toml +1 -1
  3. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/page.html +129 -16
  4. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/page.test.js +96 -4
  5. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/uv.lock +1 -1
  6. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/.coverage +0 -0
  7. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/.github/workflows/publish.yml +0 -0
  8. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/.github/workflows/test.yml +0 -0
  9. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/.gitignore +0 -0
  10. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/README.md +0 -0
  11. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/design/Anchor.dc.html +0 -0
  12. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/design/Dense.dc.html +0 -0
  13. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/design/Hover.dc.html +0 -0
  14. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/design/Main.dc.html +0 -0
  15. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/design/Quiet.dc.html +0 -0
  16. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/design/canvas.json +0 -0
  17. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/design/delegation-timeline.html +0 -0
  18. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/__init__.py +0 -0
  19. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/ai.py +0 -0
  20. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/cli.py +0 -0
  21. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/config.py +0 -0
  22. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/corpus.py +0 -0
  23. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/fetch.py +0 -0
  24. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/library.py +0 -0
  25. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/store.py +0 -0
  26. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/src/transcript_viewer/viewer.py +0 -0
  27. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/fixtures/big-command.json +0 -0
  28. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_ai.py +0 -0
  29. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_cli.py +0 -0
  30. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_config.py +0 -0
  31. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_corpus.py +0 -0
  32. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_fetch.py +0 -0
  33. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_library.py +0 -0
  34. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_page.py +0 -0
  35. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_readme.py +0 -0
  36. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_store.py +0 -0
  37. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_stream.py +0 -0
  38. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_style.py +0 -0
  39. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/tests/test_tidiness.py +0 -0
  40. {transcript_viewer-0.11.0 → transcript_viewer-0.11.1}/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.0
3
+ Version: 0.11.1
4
4
  Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transcript-viewer"
3
- version = "0.11.0"
3
+ version = "0.11.1"
4
4
  description = "Browse agent transcripts in a local, dependency-free web viewer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -420,6 +420,9 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
420
420
  .gapmark:after{content:"";position:absolute;left:-2px;top:0;bottom:0;width:5px;
421
421
  background:var(--bg);opacity:.9;border-left:1px dashed var(--muted);
422
422
  border-right:1px dashed var(--muted);opacity:.35}
423
+ /* Conversations that belong to the record rather than to any step. */
424
+ .loose{margin-top:18px;padding-top:12px;border-top:1px solid var(--line)}
425
+ .loose-head{font:11px var(--font-mono);color:var(--muted);margin-bottom:8px}
423
426
  .lanes-legend{display:flex;align-items:center;gap:14px;margin-top:10px;padding-top:9px;
424
427
  border-top:1px solid var(--line);font-size:11px;color:var(--muted);flex-wrap:wrap}
425
428
  .lanes-legend i{display:inline-block;width:16px;height:8px;border-radius:2px;
@@ -2366,6 +2369,9 @@ function haystack(s){
2366
2369
  Where they do not, the same lanes are laid out by step position and the axis
2367
2370
  says so, rather than inventing a duration. */
2368
2371
 
2372
+ /* "1 subagent" and "2 subagents", said once rather than at each call site. */
2373
+ const many = (n, one, more) => `${num(n)} ${n === 1 ? one : more || one + "s"}`;
2374
+
2369
2375
  const at = (s) => {
2370
2376
  const t = s && s.timestamp;
2371
2377
  if (!t) return null;
@@ -2419,6 +2425,36 @@ function laneIndex(t) {
2419
2425
  });
2420
2426
  };
2421
2427
  walk(t, 0);
2428
+
2429
+ // A subagent that no call refers to. ATIF links delegation from the result of
2430
+ // the call that spawned it, but not every format has a call to link from:
2431
+ // MALT's records carry an advisor, an agent and its raters as trajectories in
2432
+ // their own right, spawned by nothing. Walking only the references missed all
2433
+ // of them, and a record with ten subagents drew no timeline at all.
2434
+ const linked = new Set(lanes.map((l) => l.id));
2435
+ const loose = (parent, depth) => {
2436
+ for (const sub of parent.subagent_trajectories || []) {
2437
+ if (!linked.has(sub.trajectory_id)) {
2438
+ const x = sub.extra || {};
2439
+ lanes.push({
2440
+ id: sub.trajectory_id,
2441
+ kind: x.agentType || sub.agent?.name || "subagent",
2442
+ said: x.description || "",
2443
+ steps: (sub.steps || []).length,
2444
+ tools: [...new Set((sub.steps || []).flatMap(
2445
+ (s) => (s.tool_calls || []).map((c) => c.function_name)))],
2446
+ span: spanMs(sub.steps),
2447
+ fromStep: null,
2448
+ fromIndex: 0,
2449
+ depth,
2450
+ });
2451
+ linked.add(sub.trajectory_id);
2452
+ }
2453
+ loose(sub, depth + 1);
2454
+ }
2455
+ };
2456
+ loose(t, 0);
2457
+
2422
2458
  return lanes;
2423
2459
  }
2424
2460
 
@@ -2529,10 +2565,17 @@ function drawLanes(t) {
2529
2565
  width:${Math.max(100 / Math.max(1, steps), 0.6)}%" title="${esc(lane.kind)}"
2530
2566
  onmouseenter="laneCard(event,'${esc(lane.id)}')" onmouseleave="laneCard(null)"
2531
2567
  onclick="openLane('${esc(lane.id)}')"></button>`)).join("");
2532
- return laneShell(LANES.length, "", rows,
2533
- laneRow({ kind: "main thread", steps, main: true }, tight,
2568
+ return laneShell({
2569
+ count: LANES.length,
2570
+ rows,
2571
+ mainRow: laneRow({ kind: "main thread", steps, main: true }, tight,
2534
2572
  `<span class="bar main" style="left:0;width:100%"></span>`),
2535
- "", "no timestamps — laid out by step");
2573
+ marks: "",
2574
+ // No clock to lay anything against, so no duration is claimed and no
2575
+ // overlap either: without times, two bars sitting side by side would
2576
+ // say something the log never recorded.
2577
+ note: "laid out by step · these steps carry no times",
2578
+ });
2536
2579
  }
2537
2580
 
2538
2581
  // Everything the run touched, parent and children alike.
@@ -2579,13 +2622,24 @@ function drawLanes(t) {
2579
2622
  return { pos: (m.pos / 100) * scale.room, label: m.label };
2580
2623
  });
2581
2624
 
2582
- const note = scale.gaps.length
2583
- ? `${num(scale.gaps.length)} quiet stretch${scale.gaps.length === 1 ? "" : "es"} skipped · ${duration(scale.skipped)} idle`
2584
- : "on the clock · click a bar to open it";
2585
-
2586
- return laneShell(LANES.length, duration(scale.kept), rows, mainRow,
2587
- marks.map((m) => `<span style="left:${m.pos}%">${esc(m.label)}</span>`).join(""),
2588
- note, bandLayer + breaks, bands.length);
2625
+ // What the axis leaves out, in the reader's terms. How many separate gaps
2626
+ // were collapsed is a fact about the drawing, not about the run, so it is not
2627
+ // said; how much time is missing from the picture very much is.
2628
+ const hidden = scale.gaps.length ? `${duration(scale.skipped)} of waiting hidden` : "";
2629
+ const note = [hidden, "click a bar to open it"].filter(Boolean).join(" · ");
2630
+
2631
+ const [shownAs, wholeAs] = durationPair(scale.kept, hi - lo);
2632
+ return laneShell({
2633
+ count: LANES.length,
2634
+ shown: shownAs,
2635
+ whole: wholeAs,
2636
+ rows,
2637
+ mainRow,
2638
+ marks: marks.map((m) => `<span style="left:${m.pos}%">${esc(m.label)}</span>`).join(""),
2639
+ note,
2640
+ layer: bandLayer + breaks,
2641
+ overlaps: bands.length,
2642
+ });
2589
2643
  }
2590
2644
 
2591
2645
  /* One row: who, how many steps, and the track it sits on. */
@@ -2602,14 +2656,35 @@ function laneRow(lane, tight, inner) {
2602
2656
  }
2603
2657
 
2604
2658
  /* The frame around the lanes: what it is, the axis, and what was left out. */
2605
- function laneShell(count, span, rows, mainRow, marks, note, layer, overlaps) {
2659
+ /* What the header says about a run. Every clause is computed, and each is true
2660
+ of any transcript rather than of the one it was written against:
2661
+
2662
+ - the count of subagents;
2663
+ - how much time the axis is showing, and — only when they differ — the whole
2664
+ stretch it was drawn from. Saying "31h" of a run that ran ten days would be
2665
+ wrong, and it is the axis that was shortened, not the run;
2666
+ - how many times two or more were going at once, which is what the view is
2667
+ for. Called "overlapped" rather than "stretches", because the skipped
2668
+ stretches below are the opposite thing and the word was doing both jobs.
2669
+ */
2670
+ function laneHeader({ count, shown, whole, overlaps }) {
2671
+ const parts = [many(count, "subagent")];
2672
+ if (shown) parts.push(whole && whole !== shown ? `${shown} shown of ${whole}` : shown);
2673
+ if (overlaps) {
2674
+ parts.push(
2675
+ `<em>overlapped ${overlaps === 1 ? "once" : many(overlaps, "time")}</em>`,
2676
+ );
2677
+ }
2678
+ return parts;
2679
+ }
2680
+
2681
+ function laneShell({ count, shown, whole, rows, mainRow, marks, note, layer, overlaps }) {
2682
+ const parts = laneHeader({ count, shown, whole, overlaps });
2606
2683
  return `
2607
2684
  <details id="lanes" open>
2608
2685
  <summary>
2609
2686
  <b>Delegation</b>
2610
- <span class="fine">${num(count)} subagent${count === 1 ? "" : "s"}${span ? " · " + span : ""}${
2611
- overlaps ? ` · <em>${num(overlaps)} stretch${overlaps === 1 ? "" : "es"} with two or more at once</em>` : ""
2612
- }</span>
2687
+ <span class="fine">${parts.join(" · ")}</span>
2613
2688
  </summary>
2614
2689
  <div class="lanes-body">
2615
2690
  <div class="lane-axis${rows.includes("lane tight") ? " tight" : ""}">
@@ -2623,7 +2698,7 @@ function laneShell(count, span, rows, mainRow, marks, note, layer, overlaps) {
2623
2698
  <div class="lanes-legend">
2624
2699
  <span><i style="background:var(--ink);opacity:.85"></i>main thread</span>
2625
2700
  <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>` : ""}
2701
+ ${overlaps ? `<span><i style="background:var(--hl)"></i>running at the same time</span>` : ""}
2627
2702
  <span class="lanes-note">${esc(note)}</span>
2628
2703
  </div>
2629
2704
  </div>
@@ -2681,6 +2756,25 @@ function openLane(id) {
2681
2756
  setTimeout(() => box.classList.remove("found"), 1400);
2682
2757
  }
2683
2758
 
2759
+ /* Subagents that no call refers to. They are drawn after the run rather than
2760
+ inside it, because there is no step they belong to: MALT records an advisor,
2761
+ an agent and its raters as trajectories spawned by nothing, and until now the
2762
+ viewer held them without ever showing them. The timeline made that visible by
2763
+ pointing at boxes that were not there. */
2764
+ function looseSubagents(t){
2765
+ const linked=new Set();
2766
+ const mark=p=>{
2767
+ for(const s of p.steps||[]){
2768
+ for(const r of s.observation?.results||[]){
2769
+ for(const ref of r.subagent_trajectory_ref||[])linked.add(ref.trajectory_id);
2770
+ }
2771
+ }
2772
+ for(const sub of p.subagent_trajectories||[])mark(sub);
2773
+ };
2774
+ mark(t);
2775
+ return (t.subagent_trajectories||[]).filter(s=>!linked.has(s.trajectory_id));
2776
+ }
2777
+
2684
2778
  function branchIndex(t){
2685
2779
  const out=[];
2686
2780
  t.steps.forEach((s,i)=>{
@@ -2787,7 +2881,16 @@ function trajectoryTab(t,e,branches,branchSteps,counts,q){
2787
2881
  ${shown.map(({s,i})=>step(s,t,0,i,"")).join("")
2788
2882
  ||`<div class="empty">Nothing matches.</div>`}
2789
2883
  ${total>shown.length?`<div class="more"><button onclick="more()">Show more —
2790
- ${num(shown.length)} of ${num(total)} steps</button></div>`:""}`;
2884
+ ${num(shown.length)} of ${num(total)} steps</button></div>`:""}
2885
+ ${(()=>{
2886
+ // Only in the plain view: a lens or a search is a question about the
2887
+ // steps of this run, and answering it with whole other conversations
2888
+ // would be answering something else.
2889
+ if(lens!=="all"||q)return "";
2890
+ const loose=looseSubagents(t);
2891
+ return loose.length?`<div class="loose"><div class="loose-head">
2892
+ ${many(loose.length,"other conversation")} in this record — no step spawned them</div>
2893
+ ${loose.map(sub=>branch({trajectory_id:sub.trajectory_id},t,0)).join("")}</div>`:""})()}`;
2791
2894
  }
2792
2895
 
2793
2896
  /* The switch stays visible when AI is off for a transcript — otherwise there
@@ -2936,6 +3039,16 @@ function spanOf(steps){
2936
3039
  return ms>0?ms:null;
2937
3040
  }
2938
3041
 
3042
+ /* Two durations that will be read against each other, in one unit, so "75m
3043
+ shown of 1.6h" cannot happen — the pair is scaled by the larger of them. */
3044
+ function durationPair(small, large){
3045
+ if(small==null||large==null)return [duration(small),duration(large)];
3046
+ const unit=large/1000<90?1000:large/60000<90?60000:large/3600000<48?3600000:86400000;
3047
+ const suffix=unit===1000?"s":unit===60000?"m":unit===3600000?"h":"d";
3048
+ const show=v=>{const n=v/unit;return (n<10?n.toFixed(1).replace(/\.0$/,""):String(Math.round(n)))+suffix};
3049
+ return [show(small),show(large)];
3050
+ }
3051
+
2939
3052
  function duration(ms){
2940
3053
  if(ms==null)return null;
2941
3054
  // A whole number reads better without the decimal: 2h, not 2.0h.
@@ -1840,10 +1840,30 @@ test("the timeline says how many ran at once", () => {
1840
1840
  const html = run(`return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`);
1841
1841
  assert.match(html, /id="lanes"/);
1842
1842
  assert.match(html, /2 subagents/);
1843
- assert.match(html, /two or more at once/);
1843
+ assert.match(html, /overlapped once/, "one overlap reads as once, not '1 times'");
1844
1844
  assert.match(html, /class="band"/, "the overlap is drawn, not only counted");
1845
1845
  });
1846
1846
 
1847
+ test("the header counts, and counts in English", () => {
1848
+ const one = run(`return laneHeader({count: 1, shown: "5m", overlaps: 0}).join(" · ")`);
1849
+ assert.strictEqual(one, "1 subagent · 5m");
1850
+
1851
+ const some = run(`return laneHeader({count: 12, shown: "2h", overlaps: 4}).join(" · ")`);
1852
+ assert.match(some, /^12 subagents · 2h · /);
1853
+ assert.match(some, /overlapped 4 times/);
1854
+ });
1855
+
1856
+ test("the header never passes off the drawn time as the whole run", () => {
1857
+ // The axis skips the waiting, so a run of ten days can show thirty-one hours.
1858
+ // Saying "31h" alone would be false about the run.
1859
+ const elided = run(`return laneHeader({count: 3, shown: "31h", whole: "10d"}).join(" · ")`);
1860
+ assert.match(elided, /31h shown of 10d/);
1861
+
1862
+ // Where nothing was skipped the two are the same and it says it once.
1863
+ const straight = run(`return laneHeader({count: 3, shown: "20m", whole: "20m"}).join(" · ")`);
1864
+ assert.strictEqual(straight, "3 subagents · 20m");
1865
+ });
1866
+
1847
1867
  test("a transcript that delegated nothing gets no timeline at all", () => {
1848
1868
  const html = run(`return drawLanes({steps:[{step_id:1,source:"user",message:"hi"}]})`);
1849
1869
  assert.strictEqual(html, "", "an empty chart says less than no chart");
@@ -1856,8 +1876,10 @@ test("without timestamps the lanes fall back to step position and say so", () =>
1856
1876
  for (const s of sub.steps) delete s.timestamp;
1857
1877
  }
1858
1878
  const html = run(`return drawLanes(${JSON.stringify(untimed)})`);
1859
- assert.match(html, /no timestamps/, "it must not imply a duration it does not have");
1879
+ assert.match(html, /carry no times/, "it must not imply a duration it does not have");
1860
1880
  assert.ok(!/class="band"/.test(html), "and must not claim overlap it cannot know");
1881
+ assert.ok(!/overlapped/.test(html), "nor say two things ran together");
1882
+ assert.ok(!/hidden/.test(html), "nor that anything was left out");
1861
1883
  });
1862
1884
 
1863
1885
  test("the axis is read in whole units, not seven of them", () => {
@@ -1946,7 +1968,77 @@ test("what was skipped is stated, not swallowed", () => {
1946
1968
  ] }],
1947
1969
  };
1948
1970
  const html = run(`return drawLanes(${JSON.stringify(t)})`);
1949
- assert.match(html, /quiet stretch/, "a reader must be told time was left out");
1950
- assert.match(html, /idle/);
1971
+ assert.match(html, /of waiting hidden/, "a reader must be told time was left out");
1972
+ assert.match(html, /shown of/, "and that the axis is shorter than the run");
1951
1973
  assert.match(html, /class="gapmark"/, "and see where");
1974
+ // How many gaps were collapsed is a fact about the drawing, not the run.
1975
+ assert.ok(!/\d+ quiet stretch/.test(html), "that is not the reader's business");
1976
+ });
1977
+
1978
+ test("every phrase in the timeline is computed, none of it written per run", () => {
1979
+ // The header and note are built from counts and durations alone: the same run
1980
+ // renders the same words, and a different one renders different ones for the
1981
+ // same reason. Nothing here is specific to any transcript.
1982
+ const twice = [1, 2].map(() =>
1983
+ run(`return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`),
1984
+ );
1985
+ assert.strictEqual(twice[0], twice[1], "the same run must read the same way");
1986
+
1987
+ const bigger = JSON.parse(JSON.stringify(TWO_AT_ONCE));
1988
+ bigger.subagent_trajectories[1].steps = [
1989
+ { step_id: 1, source: "agent", timestamp: "2026-01-01T10:30:00Z" },
1990
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:40:00Z" },
1991
+ ];
1992
+ const changed = run(`return drawLanes(${JSON.stringify(bigger)})`);
1993
+ assert.notStrictEqual(changed, twice[0], "a different run must read differently");
1994
+ assert.ok(!/overlapped/.test(changed), "and this one no longer overlaps");
1995
+ });
1996
+
1997
+ test("a subagent no call spawned still gets a lane and a box", () => {
1998
+ // MALT records an advisor, an agent and its raters as trajectories in their
1999
+ // own right. Walking only the call references found none of them, so a record
2000
+ // with ten subagents drew no timeline and showed none of them anywhere.
2001
+ const t = {
2002
+ steps: [{ step_id: 1, source: "user", message: "do the thing",
2003
+ timestamp: "2026-01-01T10:00:00Z" }],
2004
+ subagent_trajectories: [
2005
+ { trajectory_id: "advisor", agent: { name: "metr-malt" }, extra: {},
2006
+ steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:00:05Z" },
2007
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:02:00Z" }] },
2008
+ { trajectory_id: "rater", agent: { name: "metr-malt" }, extra: {},
2009
+ steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:01:00Z" },
2010
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:03:00Z" }] },
2011
+ ],
2012
+ };
2013
+
2014
+ const lanes = run(`return laneIndex(${JSON.stringify(t)})`);
2015
+ assert.strictEqual(lanes.length, 2, "both belong on the timeline");
2016
+ assert.deepStrictEqual(lanes.map((l) => l.fromStep), [null, null],
2017
+ "and neither claims a step spawned it");
2018
+
2019
+ const loose = run(`return looseSubagents(${JSON.stringify(t)}).map(s=>s.trajectory_id)`);
2020
+ assert.deepStrictEqual(loose, ["advisor", "rater"],
2021
+ "the transcript must show them somewhere, or the lanes point at nothing");
2022
+ });
2023
+
2024
+ test("a subagent a call did spawn is not also shown loose", () => {
2025
+ const t = {
2026
+ steps: [{ step_id: 1, source: "agent",
2027
+ tool_calls: [{ tool_call_id: "c1", function_name: "Task" }],
2028
+ observation: { results: [{ source_call_id: "c1",
2029
+ subagent_trajectory_ref: [{ trajectory_id: "a" }] }] } }],
2030
+ subagent_trajectories: [{ trajectory_id: "a", agent: { name: "claude-code" },
2031
+ extra: {}, steps: [] }],
2032
+ };
2033
+ const loose = run(`return looseSubagents(${JSON.stringify(t)})`);
2034
+ assert.deepStrictEqual(loose, [], "it already has a home in the run");
2035
+ });
2036
+
2037
+ test("two durations read against each other share a unit", () => {
2038
+ // "75m shown of 1.6h" is two scales in one sentence.
2039
+ const pair = run(`return durationPair(75*60000, 96*60000)`);
2040
+ assert.deepStrictEqual(pair, ["1.3h", "1.6h"]);
2041
+
2042
+ const wide = run(`return durationPair(31*3600000, 10*86400000)`);
2043
+ assert.deepStrictEqual(wide, ["1.3d", "10d"]);
1952
2044
  });
@@ -388,7 +388,7 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "transcript-viewer"
391
- version = "0.11.0"
391
+ version = "0.11.1"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "atif-make" },