@pi-archimedes/core 2.10.4 → 2.11.1

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.
@@ -17,7 +17,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
17
17
  }));
18
18
 
19
19
  // Import after mocks are set up
20
- const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled, updateConfig } = await import("./settings-io.js");
20
+ const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled, updateConfig, readEffectiveSettings } = await import("./settings-io.js");
21
21
 
22
22
  describe("removeConfig", () => {
23
23
  beforeEach(() => {
@@ -468,3 +468,176 @@ describe("symlink preservation", () => {
468
468
  expect(data["archimedes.test"]).toEqual({ d: 4 });
469
469
  });
470
470
  });
471
+
472
+ // ── readEffectiveSettings ───────────────────────────────────────────────────
473
+ //
474
+ // `loadConfig` reads `~/.pi/agent/settings.json` by hand, which cannot see
475
+ // project-scoped settings or overrides. `pi.getSettings()` returns the merged
476
+ // effective object, so it is the right source when the running pi has it — and
477
+ // the file read is the fallback that keeps core's peer floor where ADR 0013 put
478
+ // it (getSettings is an ExtensionAPI method that older pi does not expose).
479
+ // Note the direction of the whole thing: this is a READER. The writer stays on
480
+ // the hand-rolled symlink-preserving path because pi has no public write API.
481
+
482
+ describe("readEffectiveSettings", () => {
483
+ const settingsPath = () => join(tempDir, "settings.json");
484
+
485
+ beforeEach(() => {
486
+ const p = settingsPath();
487
+ if (fs.existsSync(p)) fs.unlinkSync(p);
488
+ });
489
+
490
+ afterEach(() => {
491
+ const p = settingsPath();
492
+ try { fs.unlinkSync(p); } catch { /* ignore */ }
493
+ try { fs.unlinkSync(p + ".tmp"); } catch { /* ignore */ }
494
+ });
495
+
496
+ it("returns pi.getSettings() when the host exposes it", () => {
497
+ const pi = { getSettings: () => ({ "archimedes.web": { braveApiKey: "from-host" } }) };
498
+ expect(readEffectiveSettings(pi)).toEqual({ "archimedes.web": { braveApiKey: "from-host" } });
499
+ });
500
+
501
+ it("its value wins over settings.json (project scope and overrides are invisible to the file read)", () => {
502
+ fs.writeFileSync(
503
+ settingsPath(),
504
+ JSON.stringify({ "archimedes.web": { braveApiKey: "from-file" } }),
505
+ "utf-8",
506
+ );
507
+ const pi = { getSettings: () => ({ "archimedes.web": { braveApiKey: "from-effective" } }) };
508
+ const result = readEffectiveSettings(pi) as { "archimedes.web": { braveApiKey: string } };
509
+ expect(result["archimedes.web"].braveApiKey).toBe("from-effective");
510
+ });
511
+
512
+ it("falls back to the file read when the handle has no getSettings (a pi older than the API)", () => {
513
+ fs.writeFileSync(
514
+ settingsPath(),
515
+ JSON.stringify({ "archimedes.web": { braveApiKey: "from-file" } }),
516
+ "utf-8",
517
+ );
518
+ const result = readEffectiveSettings({}) as { "archimedes.web": { braveApiKey: string } };
519
+ expect(result["archimedes.web"].braveApiKey).toBe("from-file");
520
+ });
521
+
522
+ it("falls back to the file read when no handle is passed at all", () => {
523
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
524
+ expect(readEffectiveSettings()).toEqual({ "test.ns": { a: 1 } });
525
+ expect(readEffectiveSettings(undefined)).toEqual({ "test.ns": { a: 1 } });
526
+ expect(readEffectiveSettings(null)).toEqual({ "test.ns": { a: 1 } });
527
+ });
528
+
529
+ it("treats a non-function getSettings property as absent", () => {
530
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
531
+ expect(readEffectiveSettings({ getSettings: "not a function" })).toEqual({ "test.ns": { a: 1 } });
532
+ });
533
+
534
+ it("falls back when getSettings() throws — pi asserts an active extension context and can refuse", () => {
535
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
536
+ const pi = {
537
+ getSettings: () => {
538
+ throw new Error("Extension is not active");
539
+ },
540
+ };
541
+ expect(() => readEffectiveSettings(pi)).not.toThrow();
542
+ expect(readEffectiveSettings(pi)).toEqual({ "test.ns": { a: 1 } });
543
+ });
544
+
545
+ it("falls back when getSettings() returns something unusable (null / undefined / a scalar)", () => {
546
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
547
+ expect(readEffectiveSettings({ getSettings: () => undefined })).toEqual({ "test.ns": { a: 1 } });
548
+ expect(readEffectiveSettings({ getSettings: () => null })).toEqual({ "test.ns": { a: 1 } });
549
+ expect(readEffectiveSettings({ getSettings: () => 42 })).toEqual({ "test.ns": { a: 1 } });
550
+ });
551
+
552
+ it("returns an empty object for malformed JSON with no handle (existing loadConfig behaviour preserved)", () => {
553
+ fs.writeFileSync(settingsPath(), "{ invalid json }", "utf-8");
554
+ expect(readEffectiveSettings()).toEqual({});
555
+ // …and the malformed file still does not break the namespaced reader.
556
+ expect(loadConfig("test.ns", { foo: "bar" })).toEqual({ foo: "bar" });
557
+ });
558
+
559
+ it("returns {} when there is no file and no handle", () => {
560
+ expect(fs.existsSync(settingsPath())).toBe(false);
561
+ expect(readEffectiveSettings()).toEqual({});
562
+ });
563
+
564
+ it("never writes: it is a reader, so the file and the writer's tmp path are untouched", () => {
565
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
566
+ const before = fs.readFileSync(settingsPath(), "utf-8");
567
+ const beforeMtimeMs = fs.statSync(settingsPath()).mtimeMs;
568
+ readEffectiveSettings({});
569
+ readEffectiveSettings({ getSettings: () => ({}) });
570
+ expect(fs.readFileSync(settingsPath(), "utf-8")).toBe(before);
571
+ expect(fs.statSync(settingsPath()).mtimeMs).toBe(beforeMtimeMs);
572
+ expect(fs.existsSync(settingsPath() + ".tmp")).toBe(false);
573
+ });
574
+
575
+ it("never creates settings.json when it is absent", () => {
576
+ readEffectiveSettings({});
577
+ expect(fs.existsSync(settingsPath())).toBe(false);
578
+ });
579
+
580
+ it("does not disturb the writer: saveConfig still round-trips after an effective read", () => {
581
+ readEffectiveSettings({ getSettings: () => ({ "test.ns": { from: "host" } }) });
582
+ saveConfig("test.ns", { from: "file" });
583
+ expect(loadConfig("test.ns", { from: "" })).toEqual({ from: "file" });
584
+ });
585
+ });
586
+
587
+ // ── The catch must not be invisible ────────────────────────────────────────
588
+ //
589
+ // `pi.getSettings()` refuses for the expected reason (the handle's wrapper
590
+ // asserts an active extension context) AND for reasons that are bugs: the
591
+ // runtime stub throws "Extension runtime not initialized" for a call made
592
+ // during extension loading, and the real implementation can throw for anything
593
+ // it chokes on. Falling back to the file is right in every case; falling back
594
+ // without a trace is not, because the symptom of each of those is a silently
595
+ // project-blind settings read.
596
+
597
+ describe("readEffectiveSettings reports the reason it fell back", () => {
598
+ const ENV = "PI_ARCHIMEDES_LOG";
599
+ const settingsPath = () => join(tempDir, "settings.json");
600
+ const previous = process.env[ENV];
601
+
602
+ afterEach(() => {
603
+ if (previous === undefined) delete process.env[ENV];
604
+ else process.env[ENV] = previous;
605
+ const p = settingsPath();
606
+ try { fs.unlinkSync(p); } catch { /* ignore */ }
607
+ });
608
+
609
+ it("logs the error when getSettings() throws, and still returns the file read", () => {
610
+ const target = join(tempDir, "effective-fallback.log");
611
+ process.env[ENV] = target;
612
+ fs.writeFileSync(
613
+ settingsPath(),
614
+ JSON.stringify({ "test.ns": { from: "file" } }),
615
+ "utf-8",
616
+ );
617
+
618
+ const pi = {
619
+ getSettings: () => {
620
+ throw new Error("Extension runtime not initialized");
621
+ },
622
+ };
623
+ expect(readEffectiveSettings(pi)).toEqual({ "test.ns": { from: "file" } });
624
+
625
+ const logged = fs.readFileSync(target, "utf-8");
626
+ expect(logged).toContain("[core]");
627
+ expect(logged).toContain("Extension runtime not initialized");
628
+ // With the stack, which is the part that says which call site did it.
629
+ expect(logged).toMatch(/\n\s+at\s+\S/);
630
+ });
631
+
632
+ it("says nothing on the normal paths (supported host, older pi, no handle)", () => {
633
+ const target = join(tempDir, "effective-quiet.log");
634
+ process.env[ENV] = target;
635
+ fs.writeFileSync(settingsPath(), JSON.stringify({ "test.ns": { a: 1 } }), "utf-8");
636
+
637
+ readEffectiveSettings({ getSettings: () => ({ "test.ns": { a: 2 } }) });
638
+ readEffectiveSettings({});
639
+ readEffectiveSettings();
640
+
641
+ expect(fs.existsSync(target)).toBe(false);
642
+ });
643
+ });
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, lstatSync, realpathSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { diag, describeError } from "./log.js";
4
5
 
5
6
  const SETTINGS_PATH = join(getAgentDir(), "settings.json");
6
7
 
@@ -33,6 +34,59 @@ function readSettings(): Record<string, unknown> {
33
34
  return parseSettings(readRawSettings());
34
35
  }
35
36
 
37
+ /**
38
+ * Read the settings the running pi actually applies: `pi.getSettings()` when
39
+ * the host exposes it, otherwise the file read.
40
+ *
41
+ * `getSettings()` returns the *effective* object — global settings merged with
42
+ * project settings (`<cwd>/.pi/settings.json`) and any in-process overrides —
43
+ * which a hand-rolled read of `~/.pi/agent/settings.json` cannot see. It lives
44
+ * on **`ExtensionAPI`**, not on `ExtensionContext` (`extensions/types.d.ts:1286`
45
+ * in pi 1.2.0), so a call site must pass the `pi` handle, never `ctx`.
46
+ *
47
+ * The fallback is not decoration: it is what keeps `core`'s peer floor at
48
+ * `>=0.85.0` (ADR 0013 / ADR 0026), because `getSettings` is younger than that
49
+ * floor. It also covers pi refusing the call — the handle's wrapper asserts an
50
+ * active extension context and throws otherwise — and a host that hands back
51
+ * something that is not an object. Anything unusable falls through to the file,
52
+ * and a *thrown* refusal is recorded in the log on the way: the same catch hides
53
+ * a call made before the runtime bound itself, whose only symptom would be a
54
+ * settings read that quietly cannot see project scope.
55
+ *
56
+ * Reads only. There is no matching write path: pi exposes no public settings
57
+ * writer, so `saveConfig` keeps its symlink-preserving tmp+rename behaviour
58
+ * (docs/features/symlink-preserving-settings-writes.md).
59
+ *
60
+ * @param pi the `ExtensionAPI` handle, or nothing at all (headless call sites,
61
+ * tests, and every pi without the API).
62
+ */
63
+ export function readEffectiveSettings(pi?: unknown): Record<string, unknown> {
64
+ const getSettings = (pi as { getSettings?: unknown } | null | undefined)?.getSettings;
65
+ if (typeof getSettings === "function") {
66
+ try {
67
+ const effective = (getSettings as () => unknown).call(pi);
68
+ // An object is the only usable answer; `undefined`/`null`/a scalar mean
69
+ // the host had nothing to say, which is the file read's job.
70
+ if (typeof effective === "object" && effective !== null && !Array.isArray(effective)) {
71
+ return effective as Record<string, unknown>;
72
+ }
73
+ } catch (err) {
74
+ // pi asserts the extension is active before answering, so the call can
75
+ // refuse. A settings read is never allowed to be the reason something
76
+ // failed, so fall through to the file — but never fall through silently:
77
+ // the same catch also swallows the handle's "runtime not initialized"
78
+ // stub (a call made too early, i.e. a bug) and anything the real
79
+ // implementation chokes on, and every one of those looks like nothing
80
+ // more than a settings read that cannot see project scope.
81
+ diag(
82
+ "core",
83
+ `[archimedes:settings-io] getSettings() refused; falling back to settings.json: ${describeError(err)}`,
84
+ );
85
+ }
86
+ }
87
+ return readSettings();
88
+ }
89
+
36
90
  /**
37
91
  * Load a config section from settings.json, merged with defaults.
38
92
  */
package/src/text.test.ts CHANGED
@@ -61,6 +61,64 @@ describe("stripAnsi", () => {
61
61
  });
62
62
  });
63
63
 
64
+ // ── stripAnsi regression fence ───────────────────────────────────────────────
65
+ //
66
+ // stripAnsi is a wrapper, not a delegation: the startup section parser and the
67
+ // footer both consume its output, and they depend on the *whole* pattern set
68
+ // (pi-tui's stripTerminalSequences leaves DCS / SOS / PM and the charset
69
+ // designators in place, and it never trims). These assertions are the fence
70
+ // that keeps the wrapper honest — they must read the same before and after the
71
+ // pi-tui migration, and the DCS/SOS/APC/PM/charset/trim cases below are the
72
+ // ones a pure delegation would break.
73
+
74
+ describe("stripAnsi (wrapper fence)", () => {
75
+ it("strips a DCS string terminated by ST as well as by BEL", () => {
76
+ expect(stripAnsi("before\x1bPq;status\x1b\\after")).toBe("beforeafter");
77
+ });
78
+
79
+ it("strips an APC string terminated by ST", () => {
80
+ expect(stripAnsi("before\x1b_privacy\x1b\\after")).toBe("beforeafter");
81
+ });
82
+
83
+ it("strips a SOS string terminated by ST", () => {
84
+ expect(stripAnsi("before\x1b^message\x1b\\after")).toBe("beforeafter");
85
+ });
86
+
87
+ it("strips both charset designators and keeps the designator byte it names", () => {
88
+ // Same shape as the case above: the pattern drops `ESC (` / `ESC )` and
89
+ // leaves the designator character, which is what the section parser saw
90
+ // before the wrapper existed.
91
+ expect(stripAnsi("\x1b(B\x1b)0plain")).toBe("B0plain");
92
+ });
93
+
94
+ it("strips pi's own control sequences, so callers see only what is drawn", () => {
95
+ // A captured frame line: SGR + the cursor marker (APC) + an OSC 8 link.
96
+ const line = `\x1b[38;2;9;9;9m ${"日本語"}\x1b[0m\x1b_pi:c\x07`;
97
+ expect(stripAnsi(line)).toBe("日本語");
98
+ });
99
+
100
+ it("trims the padding a surface left behind the styling", () => {
101
+ expect(stripAnsi("\x1b[90m│\x1b[0m \n")).toBe("│");
102
+ });
103
+ });
104
+
105
+ // ── stripAnsi: what the pi-tui pass adds ────────────────────────────────────
106
+ //
107
+ // The only *new* coverage the wrapper buys: a CSI sequence whose parameters use
108
+ // the `:` sub-parameter form, which our `[0-9;?]*` run stops at. Before the
109
+ // wrapper such an escape survived into the section parser's input; it is not
110
+ // part of the fence above.
111
+
112
+ describe("stripAnsi (pi-tui pass)", () => {
113
+ it("strips an SGR sequence whose parameters use the colon form", () => {
114
+ expect(stripAnsi("\x1b[38:2:255:0:0mred\x1b[0m")).toBe("red");
115
+ });
116
+
117
+ it("strips a private-mode CSI our pattern does not describe", () => {
118
+ expect(stripAnsi("\x1b[>4;2mhello\x1b[0m")).toBe("hello");
119
+ });
120
+ });
121
+
64
122
  // ── isParentBorder ──────────────────────────────────────────────────────────
65
123
 
66
124
  describe("isParentBorder", () => {
package/src/text.ts CHANGED
@@ -1,36 +1,37 @@
1
- import { truncateToWidth } from "@earendil-works/pi-tui";
1
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
2
 
3
3
  // Inline stripSgr — narrow SGR-only strip (no trim) for char-level checks
4
4
  const stripSgr = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
5
5
 
6
6
  /**
7
- * Strip ALL ANSI escape sequences. Covers:
7
+ * Strip ALL ANSI escape sequences and trim. Covers:
8
8
  * - CSI: ESC [ ... letter (SGR, cursor movement, etc.)
9
- * - OSC: ESC ] ... ST (operating system commands)
9
+ * - OSC: ESC ] ... ST (operating system commands, OSC 8 hyperlinks)
10
10
  * - DCS: ESC P ... ST (device control strings)
11
- * - APC: ESC _ ... ST (application program commands)
11
+ * - APC: ESC _ ... ST (application program commands — pi's CURSOR_MARKER)
12
12
  * - SOS: ESC ^ ... ST (start of string)
13
13
  * - PM: ESC \x5c ... ST (privacy message)
14
14
  * - Character set: ESC ( or ESC ) (DECSET/DECRST)
15
+ *
16
+ * This is a wrapper, not a delegation, and the **local pattern set is the
17
+ * load-bearing half**. pi-tui's `stripTerminalSequences` leaves DCS, SOS, PM and
18
+ * the charset designators in place and never trims, so delegating to it alone
19
+ * would feed control bytes — and stray padding — to the startup section parser
20
+ * (`ui/src/startup/sections.ts`) and the footer, both of which parse this
21
+ * output. The `stripTerminalSequences` pass that follows is the backstop for the
22
+ * sequences our patterns do *not* describe, chiefly the colon sub-parameter
23
+ * form of SGR (`\x1b[38:2:R:G:Bm`) and other private CSI shapes, which would
24
+ * otherwise survive into the parsed text.
25
+ *
15
26
  * Trims whitespace. Use for text extraction.
16
27
  */
17
28
  export function stripAnsi(s: string): string {
18
- return s
19
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI
20
- .replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "") // OSC
21
- .replace(/\x1b[P^_\x5c].*?(?:\x07|\x1b\\)/g, "") // DCS, SOS, APC, PM
22
- .replace(/\x1b[()]/g, "") // character set
23
- .trim();
24
- }
25
-
26
- /** Clamp a line to maxW visible characters, preserving ANSI escapes. */
27
- export function clampLine(line: string, maxW: number): string {
28
- return truncateToWidth(line, maxW);
29
- }
30
-
31
- /** Clamp an array of lines to maxW visible characters each. */
32
- function clampLines(lines: string[], maxW: number): string[] {
33
- return lines.map((l) => clampLine(l, maxW));
29
+ const ours = s
30
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI
31
+ .replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "") // OSC
32
+ .replace(/\x1b[P^_\x5c].*?(?:\x07|\x1b\\)/g, "") // DCS, SOS, APC, PM
33
+ .replace(/\x1b[()]/g, ""); // character set
34
+ return stripTerminalSequences(ours).trim();
34
35
  }
35
36
 
36
37
  // isParentBorder uses the narrow SGR-only strip (no trim) for char-level checks
@@ -4,6 +4,7 @@ import {
4
4
  renderStatusLabel,
5
5
  renderToolCallLine,
6
6
  STATUS_GLYPH,
7
+ toolRowDuration,
7
8
  type ToolRenderTheme,
8
9
  } from "./tool-render.js";
9
10
 
@@ -77,3 +78,50 @@ describe("renderToolCallLine", () => {
77
78
  );
78
79
  });
79
80
  });
81
+
82
+ describe("toolRowDuration", () => {
83
+ it("takes a settled row's duration from the result, so it survives a session reload", () => {
84
+ // A row re-rendered from a stored session has no renderer state at all —
85
+ // only the duration the result recorded.
86
+ expect(toolRowDuration({ durationMs: 4200 }, { isPartial: false })).toEqual({
87
+ label: "Took",
88
+ ms: 4200,
89
+ });
90
+ });
91
+
92
+ it("prefers the recorded duration over the row's own clock, so wall-clock steps are excluded", () => {
93
+ // The row started 90s of real time ago; the tool itself reported 1.5s.
94
+ const startedAt = Date.now() - 90_000;
95
+ expect(toolRowDuration({ durationMs: 1500, startedAt }, { isPartial: false })).toEqual({
96
+ label: "Took",
97
+ ms: 1500,
98
+ });
99
+ });
100
+
101
+ it("counts from the row's own clock while the result is still partial", () => {
102
+ const startedAt = Date.now() - 2500;
103
+ const out = toolRowDuration({ startedAt }, { isPartial: true });
104
+ expect(out?.label).toBe("Elapsed");
105
+ expect(out?.ms).toBeGreaterThanOrEqual(2400);
106
+ expect(out?.ms).toBeLessThan(5000);
107
+ });
108
+
109
+ it("falls back to the row's clock for a settled result that recorded no duration", () => {
110
+ expect(
111
+ toolRowDuration({ startedAt: Date.now() - 1000, endedAt: Date.now() }, { isPartial: false }),
112
+ ).toMatchObject({ label: "Took" });
113
+ });
114
+
115
+ it("says nothing about a row that never started and recorded nothing", () => {
116
+ expect(toolRowDuration({}, { isPartial: false })).toBeUndefined();
117
+ expect(toolRowDuration({}, { isPartial: true })).toBeUndefined();
118
+ });
119
+
120
+ it("finalises an errored row from its clock even while the result is partial", () => {
121
+ const out = toolRowDuration(
122
+ { startedAt: Date.now() - 800, endedAt: Date.now() },
123
+ { isPartial: true, isError: true },
124
+ );
125
+ expect(out?.label).toBe("Took");
126
+ });
127
+ });
@@ -99,3 +99,47 @@ export function renderToolCallLine(
99
99
  theme.fg(token, STATUS_GLYPH[status] + " ") + theme.fg(token, name);
100
100
  return suffix ? head + theme.fg("dim", suffix) : head;
101
101
  }
102
+
103
+ /** What a row knows about how long its tool took. */
104
+ export interface ToolRowTiming {
105
+ /** Milliseconds the tool recorded on its final result, if it recorded any. */
106
+ durationMs?: number | undefined;
107
+ /** When this row first saw the call running, from the row's own clock. */
108
+ startedAt?: number | undefined;
109
+ /** When the row saw the call settle, from the row's own clock. */
110
+ endedAt?: number | undefined;
111
+ }
112
+
113
+ /** The timing line of a tool row: which word to use, and how long to show. */
114
+ export interface ToolRowDuration {
115
+ label: "Elapsed" | "Took";
116
+ ms: number;
117
+ }
118
+
119
+ /**
120
+ * The duration line a tool row shows, following pi's own rows:
121
+ *
122
+ * - A settled result carries `durationMs`, measured by the tool. That number
123
+ * wins, because it is monotonic, it excludes whatever the row was doing
124
+ * between the call and the paint, and it is still there when the session is
125
+ * reloaded and the row's own clock is gone.
126
+ * - While the result is still partial the row counts from its own clock
127
+ * (`Elapsed`).
128
+ * - A settled result that recorded no duration falls back to the row's clock.
129
+ * - A row that never saw the call start and recorded nothing says nothing.
130
+ *
131
+ * `isError` finalises the row: a failed call is not still running, whatever the
132
+ * partial flag says.
133
+ */
134
+ export function toolRowDuration(
135
+ timing: ToolRowTiming,
136
+ options: { isPartial: boolean; isError?: boolean },
137
+ ): ToolRowDuration | undefined {
138
+ const settled = !options.isPartial || options.isError === true;
139
+ if (settled && typeof timing.durationMs === "number") {
140
+ return { label: "Took", ms: Math.max(0, timing.durationMs) };
141
+ }
142
+ if (timing.startedAt === undefined) return undefined;
143
+ const end = timing.endedAt ?? Date.now();
144
+ return { label: settled ? "Took" : "Elapsed", ms: Math.max(0, end - timing.startedAt) };
145
+ }
package/src/bus.test.ts DELETED
@@ -1,154 +0,0 @@
1
- import { describe, it, expect, afterEach } from "vitest";
2
- import { getBus, initBus, Events } from "./bus.js";
3
-
4
- // ── globalThis cleanup ──────────────────────────────────────────────────────
5
-
6
- const BUS_KEY = Symbol.for("archimedes:bus");
7
- const QUEUE_KEY = Symbol.for("archimedes:busQueue");
8
-
9
- afterEach(() => {
10
- // Reset globalThis bus and queue to avoid test pollution
11
- delete (globalThis as Record<symbol, unknown>)[BUS_KEY];
12
- delete (globalThis as Record<symbol, unknown>)[QUEUE_KEY];
13
- });
14
-
15
- // ── emit → on delivery ─────────────────────────────────────────────────────
16
-
17
- describe("emit → on delivery", () => {
18
- it("subscriber receives payload", () => {
19
- const bus = getBus();
20
- const received: unknown[] = [];
21
- bus.on("test:event", (payload) => received.push(payload));
22
- bus.emit("test:event", { hello: "world" });
23
- expect(received).toEqual([{ hello: "world" }]);
24
- });
25
- });
26
-
27
- // ── multiple subscribers ────────────────────────────────────────────────────
28
-
29
- describe("multiple subscribers", () => {
30
- it("all receive the event", () => {
31
- const bus = getBus();
32
- const a: unknown[] = [];
33
- const b: unknown[] = [];
34
- bus.on("multi", (p) => a.push(p));
35
- bus.on("multi", (p) => b.push(p));
36
- bus.emit("multi", "shared");
37
- expect(a).toEqual(["shared"]);
38
- expect(b).toEqual(["shared"]);
39
- });
40
- });
41
-
42
- // ── unsubscribe ─────────────────────────────────────────────────────────────
43
-
44
- describe("unsubscribe", () => {
45
- it("removed subscriber does not receive subsequent events", () => {
46
- const bus = getBus();
47
- const received: unknown[] = [];
48
- const unsub = bus.on("unsub:test", (p) => received.push(p));
49
- bus.emit("unsub:test", "first");
50
- unsub();
51
- bus.emit("unsub:test", "second");
52
- expect(received).toEqual(["first"]);
53
- });
54
- });
55
-
56
- // ── error isolation ─────────────────────────────────────────────────────────
57
-
58
- describe("error isolation", () => {
59
- it("one listener throwing does not prevent others from receiving", () => {
60
- const bus = getBus();
61
- const received: unknown[] = [];
62
- bus.on("err:test", () => {
63
- throw new Error("boom");
64
- });
65
- bus.on("err:test", (p) => received.push(p));
66
- bus.emit("err:test", "payload");
67
- expect(received).toEqual(["payload"]);
68
- });
69
- });
70
-
71
- // ── late subscriber queue ───────────────────────────────────────────────────
72
-
73
- describe("late subscriber queue", () => {
74
- it("events emitted before on() are delivered when subscriber registers", () => {
75
- // Create a fresh bus
76
- const bus = getBus();
77
-
78
- // Emit before anyone subscribes — this queues the event
79
- bus.emit("queued:event", "queued-payload");
80
-
81
- // Now subscribe — queued events should be delivered
82
- const received: unknown[] = [];
83
- bus.on("queued:event", (p) => received.push(p));
84
-
85
- expect(received).toEqual(["queued-payload"]);
86
- });
87
- });
88
-
89
- // ── initBus ─────────────────────────────────────────────────────────────────
90
-
91
- describe("initBus", () => {
92
- it("flushes queued events to subscribers that registered before init", () => {
93
- // Emit before any subscriber exists — queues the event
94
- const bus = getBus();
95
- bus.emit("init:test", "queued-value");
96
-
97
- // Subscribe — on() drains queue and delivers
98
- const received: unknown[] = [];
99
- bus.on("init:test", (p) => received.push(p));
100
- expect(received).toEqual(["queued-value"]);
101
- });
102
-
103
- it("initBus re-emits queued events through the bus", () => {
104
- // Emit with no subscriber — queues
105
- const bus = getBus();
106
- bus.emit("init:test2", "queued");
107
-
108
- // initBus re-emits through the bus
109
- initBus();
110
-
111
- // Since no subscriber for test2, re-emit goes back to queue.
112
- // Now subscribe — on() drains the re-queued event
113
- const received: unknown[] = [];
114
- bus.on("init:test2", (p) => received.push(p));
115
- expect(received).toEqual(["queued"]);
116
- });
117
-
118
- it("initBus snapshots and clears queue before iterating", () => {
119
- // Emit with no subscriber — queues
120
- const bus = getBus();
121
- bus.emit("snapshot:test", "a");
122
- bus.emit("snapshot:test", "b");
123
-
124
- // Subscribe — on() drains queue and delivers (but queue array persists)
125
- const received: unknown[] = [];
126
- bus.on("snapshot:test", (p) => received.push(p));
127
- expect(received).toEqual(["a", "b"]);
128
-
129
- // Queue array still has items (on() delivers but doesn't remove)
130
- const queueBefore = (globalThis as Record<symbol, unknown>)[QUEUE_KEY] as Array<unknown>;
131
- expect(queueBefore.length).toBe(2);
132
-
133
- // initBus snapshots, clears queue, and re-emits
134
- initBus();
135
- // Re-emits go to existing subscriber (not re-queued)
136
- expect(received).toEqual(["a", "b", "a", "b"]);
137
-
138
- // Queue is now empty (initBus cleared it)
139
- const queueAfter = (globalThis as Record<symbol, unknown>)[QUEUE_KEY] as Array<unknown>;
140
- expect(queueAfter.length).toBe(0);
141
- });
142
- });
143
-
144
- // ── Events constant ─────────────────────────────────────────────────────────
145
-
146
- describe("Events constant", () => {
147
- it("all event names are defined", () => {
148
- expect(Events.COST_UPDATE).toBe("archimedes:cost_update");
149
- expect(Events.TODOS_UPDATE).toBe("archimedes:todos_update");
150
- expect(Events.TODOS_CLEAR).toBe("archimedes:todos_clear");
151
- expect(Events.ASK_REQUEST).toBe("archimedes:ask_request");
152
- expect(Events.ASK_RESPONSE).toBe("archimedes:ask_response");
153
- });
154
- });