switchroom 0.14.58 → 0.14.60

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.
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ ObligationLedger,
4
+ buildObligationRepresentInbound,
5
+ obligationEscalationText,
6
+ type Obligation,
7
+ } from "../gateway/obligation-ledger.js";
8
+
9
+ function input(id: string, openedAt: number, text = "do the thing") {
10
+ return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text, openedAt };
11
+ }
12
+
13
+ describe("ObligationLedger", () => {
14
+ it("opens, reports open, and closes by origin id", () => {
15
+ const L = new ObligationLedger();
16
+ expect(L.hasOpen()).toBe(false);
17
+ expect(L.openIfAbsent(input("c:3#715", 1000))).toBe(true);
18
+ expect(L.hasOpen()).toBe(true);
19
+ expect(L.isOpen("c:3#715")).toBe(true);
20
+ expect(L.close("c:3#715")).toBe(true);
21
+ expect(L.hasOpen()).toBe(false);
22
+ expect(L.close("c:3#715")).toBe(false); // already closed
23
+ });
24
+
25
+ it("openIfAbsent is idempotent — buffer-then-enqueue opens once, keeps the first", () => {
26
+ const L = new ObligationLedger();
27
+ expect(L.openIfAbsent(input("c:3#715", 1000, "first"))).toBe(true);
28
+ expect(L.openIfAbsent(input("c:3#715", 2000, "second"))).toBe(false);
29
+ expect(L.size()).toBe(1);
30
+ expect(L.list()[0].text).toBe("first");
31
+ expect(L.list()[0].openedAt).toBe(1000);
32
+ });
33
+
34
+ it("close(null/undefined) is a safe no-op", () => {
35
+ const L = new ObligationLedger();
36
+ L.openIfAbsent(input("c:3#715", 1000));
37
+ expect(L.close(null)).toBe(false);
38
+ expect(L.close(undefined)).toBe(false);
39
+ expect(L.hasOpen()).toBe(true);
40
+ });
41
+
42
+ it("decideAtIdle returns 'none' when nothing is open", () => {
43
+ expect(new ObligationLedger().decideAtIdle()).toEqual({ action: "none" });
44
+ });
45
+
46
+ it("decideAtIdle picks the OLDEST open obligation to re-present", () => {
47
+ const L = new ObligationLedger();
48
+ L.openIfAbsent(input("c:3#715", 2000));
49
+ L.openIfAbsent(input("c:4#690", 1000)); // older
50
+ const d = L.decideAtIdle();
51
+ expect(d.action).toBe("represent");
52
+ expect(d.obligation?.originTurnId).toBe("c:4#690");
53
+ });
54
+
55
+ it("re-presents up to maxRepresents, then escalates (no infinite loop)", () => {
56
+ const L = new ObligationLedger(2); // max 2 represents
57
+ L.openIfAbsent(input("c:3#715", 1000));
58
+ // represent #1
59
+ expect(L.decideAtIdle().action).toBe("represent");
60
+ expect(L.markRepresented("c:3#715")).toBe(1);
61
+ // represent #2
62
+ expect(L.decideAtIdle().action).toBe("represent");
63
+ expect(L.markRepresented("c:3#715")).toBe(2);
64
+ // now exhausted → escalate
65
+ const d = L.decideAtIdle();
66
+ expect(d.action).toBe("escalate");
67
+ expect(d.obligation?.originTurnId).toBe("c:3#715");
68
+ // caller closes on escalate → ledger empties (no loop)
69
+ expect(L.close("c:3#715")).toBe(true);
70
+ expect(L.decideAtIdle().action).toBe("none");
71
+ });
72
+
73
+ it("the 715 scenario: open at receipt, NOT closed by an unrelated reply, re-presented", () => {
74
+ const L = new ObligationLedger();
75
+ // 713 (video, topic 635) and 715 (Meta report, topic 3) both open
76
+ L.openIfAbsent(input("c:635#713", 1000, "video swap task"));
77
+ L.openIfAbsent(input("c:3#715", 1100, "do the Meta report"));
78
+ // 713 gets a substantive reply resolving to its origin → close 713 only
79
+ expect(L.close("c:635#713")).toBe(true);
80
+ // 715 was only verbally deferred (no reply resolved to it) → still OPEN
81
+ expect(L.isOpen("c:3#715")).toBe(true);
82
+ // at idle, 715 is re-presented (this is the fix — the drop becomes a re-ask)
83
+ const d = L.decideAtIdle();
84
+ expect(d.action).toBe("represent");
85
+ expect(d.obligation?.originTurnId).toBe("c:3#715");
86
+ });
87
+
88
+ it("markRepresented on an unknown/closed id is a harmless 0", () => {
89
+ const L = new ObligationLedger();
90
+ expect(L.markRepresented("nope")).toBe(0);
91
+ });
92
+
93
+ describe("resolveCloseTarget — deterministic, holds for any model behavior", () => {
94
+ it("an echoed origin is authoritative (closes exactly that)", () => {
95
+ const L = new ObligationLedger();
96
+ L.openIfAbsent(input("c:635#713", 1000));
97
+ L.openIfAbsent(input("c:3#715", 1100));
98
+ // model echoed 713 while live turn is 715 → close 713, NOT the live turn
99
+ expect(L.resolveCloseTarget("c:635#713", "c:3#715")).toBe("c:635#713");
100
+ });
101
+
102
+ it("no echo + exactly ONE open → close the live turn (unambiguous)", () => {
103
+ const L = new ObligationLedger();
104
+ L.openIfAbsent(input("c:3#715", 1100));
105
+ expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
106
+ });
107
+
108
+ it("no echo + MULTIPLE open → close NOTHING (never wrong-close/drop)", () => {
109
+ const L = new ObligationLedger();
110
+ L.openIfAbsent(input("c:635#713", 1000));
111
+ L.openIfAbsent(input("c:3#715", 1100));
112
+ // the marko race: 713's un-echoed reply lands while currentTurn=715.
113
+ // Closing 715 would silently drop it → resolveCloseTarget refuses.
114
+ expect(L.resolveCloseTarget(undefined, "c:3#715")).toBeNull();
115
+ expect(L.isOpen("c:3#715")).toBe(true); // 715 stays open → re-presented
116
+ });
117
+
118
+ it("no echo + live turn not an open obligation → null", () => {
119
+ const L = new ObligationLedger();
120
+ L.openIfAbsent(input("c:3#715", 1100));
121
+ expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
122
+ });
123
+ });
124
+ });
125
+
126
+ describe("buildObligationRepresentInbound", () => {
127
+ const ob: Obligation = {
128
+ originTurnId: "-100123:3#715",
129
+ chatId: "-100123",
130
+ threadId: 3,
131
+ messageId: 715,
132
+ text: "do the Meta report",
133
+ openedAt: 1000,
134
+ representCount: 0,
135
+ };
136
+
137
+ it("carries the original message_id + origin_turn_id so the reply resolves back", () => {
138
+ const m = buildObligationRepresentInbound(ob, 5000);
139
+ expect(m.type).toBe("inbound");
140
+ expect(m.chatId).toBe("-100123");
141
+ expect(m.threadId).toBe(3);
142
+ expect(m.messageId).toBe(715); // ORIGINAL id → reply-quote + origin routing
143
+ expect(m.meta.origin_turn_id).toBe("-100123:3#715");
144
+ expect(m.meta.source).toBe("obligation_represent"); // synthetic → not tracked, no new obligation
145
+ expect(m.meta.represent_count).toBe("1");
146
+ expect(m.text).toContain("do the Meta report");
147
+ expect(m.text).toMatch(/answer it now|reply tool/i);
148
+ });
149
+
150
+ it("omits threadId for a DM obligation", () => {
151
+ const m = buildObligationRepresentInbound({ ...ob, threadId: undefined }, 5000);
152
+ expect(m.threadId).toBeUndefined();
153
+ });
154
+
155
+ it("truncates a long original to ~200 chars", () => {
156
+ const long = "x".repeat(500);
157
+ const m = buildObligationRepresentInbound({ ...ob, text: long }, 5000);
158
+ expect(m.text).toContain("…");
159
+ expect(m.text).not.toContain("x".repeat(201));
160
+ });
161
+
162
+ it("escalation text names the message and asks to re-send", () => {
163
+ expect(obligationEscalationText(ob)).toMatch(/missed|not sure/i);
164
+ expect(obligationEscalationText(ob)).toContain("do the Meta report");
165
+ expect(obligationEscalationText(ob)).toMatch(/re-?send/i);
166
+ });
167
+ });
@@ -146,6 +146,33 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
146
146
  it("final defaults false (live render keeps the → in-progress newest line)", () => {
147
147
  expect(renderActivityFeed(["Reading a.ts"])).toBe("<b>→ Reading a.ts</b>");
148
148
  });
149
+
150
+ // liveSuffix (PR1 heartbeat): appended INSIDE the newest in-progress line so a
151
+ // long single step visibly advances ("→ Pulling Meta data · 18s") even though
152
+ // the feed is pull-only and no new tool label arrived.
153
+ describe("liveSuffix (heartbeat)", () => {
154
+ it("appends the suffix to the newest in-progress line only", () => {
155
+ expect(renderActivityFeed(["Reading a.ts", "Running a command"], false, " · 18s")).toBe(
156
+ "<i>✓ Reading a.ts</i>\n<b>→ Running a command · 18s</b>",
157
+ );
158
+ });
159
+ it("single live line gets the suffix", () => {
160
+ expect(renderActivityFeed(["Pulling Meta data"], false, " · 1m05s")).toBe(
161
+ "<b>→ Pulling Meta data · 1m05s</b>",
162
+ );
163
+ });
164
+ it("final=true ignores the suffix (a finalized record never ticks)", () => {
165
+ const out = renderActivityFeed(["Reading a.ts", "Running a command"], true, " · 18s")!;
166
+ expect(out).not.toContain("·");
167
+ expect(out).not.toContain("→");
168
+ expect(out).toBe("<i>✓ Reading a.ts</i>\n<i>✓ Running a command</i>");
169
+ });
170
+ it("default empty suffix is byte-identical to no suffix", () => {
171
+ expect(renderActivityFeed(["Reading a.ts"], false, "")).toBe(
172
+ renderActivityFeed(["Reading a.ts"]),
173
+ );
174
+ });
175
+ });
149
176
  });
150
177
 
151
178
  describe("appendActivityLabel — precomputed label feed (tool_label path)", () => {
@@ -223,6 +250,23 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
223
250
  );
224
251
  });
225
252
 
253
+ it("liveSuffix (heartbeat) lands on the nested newest in-progress step", () => {
254
+ const out = renderActivityFeedWithNested(
255
+ ["Delegating: x"],
256
+ ["Reading schema.ts", "Looking for foreign keys"],
257
+ false,
258
+ " · 22s",
259
+ )!;
260
+ expect(out).toContain(" ↳ <b>→ Looking for foreign keys · 22s</b>");
261
+ expect(out).not.toContain("Reading schema.ts · "); // only the newest line ticks
262
+ });
263
+
264
+ it("liveSuffix passes through to the flat render when there are no children", () => {
265
+ expect(renderActivityFeedWithNested(["Reading a.ts"], [], false, " · 9s")).toBe(
266
+ "<b>→ Reading a.ts · 9s</b>",
267
+ );
268
+ });
269
+
226
270
  // Pins the invariant the gateway's foreground handoff-clear path relies on:
227
271
  // on an ack-first turn the parent feed is empty (mirrorLines=[]) and the only
228
272
  // content is the foreground sub-agent's nested narrative. The finalized
@@ -200,7 +200,11 @@ function escapeFeedHtml(s: string): string {
200
200
  * `✓ +N earlier…` header when the turn ran longer. Returns null when empty.
201
201
  * Callers send the result verbatim — do NOT re-escape or re-wrap it.
202
202
  */
203
- export function renderActivityFeed(lines: string[], final = false): string | null {
203
+ export function renderActivityFeed(
204
+ lines: string[],
205
+ final = false,
206
+ liveSuffix = "",
207
+ ): string | null {
204
208
  if (lines.length === 0) return null;
205
209
  const shown = lines.slice(-MIRROR_MAX_LINES);
206
210
  const hidden = lines.length - shown.length;
@@ -210,10 +214,14 @@ export function renderActivityFeed(lines: string[], final = false): string | nul
210
214
  // Newest line = in-progress step (bold, →); earlier = done (italic, ✓).
211
215
  // `final` (turn complete, feed left as a record): ALL lines render done (✓)
212
216
  // so the persisted message doesn't freeze on a misleading "→ in-progress".
217
+ // `liveSuffix` (heartbeat): appended INSIDE the newest in-progress line only
218
+ // (e.g. " · 18s") so the feed visibly advances during a long single step that
219
+ // emits no new tool label — the feed is otherwise pull-only and freezes.
220
+ // Caller passes framework-generated, HTML-safe text; never final + suffix.
213
221
  // Returns ready Telegram HTML — callers must NOT re-escape or re-wrap it.
214
222
  shown.forEach((l, i) => {
215
223
  const esc = escapeFeedHtml(l);
216
- out.push(i === lastIdx && !final ? `<b>→ ${esc}</b>` : `<i>✓ ${esc}</i>`);
224
+ out.push(i === lastIdx && !final ? `<b>→ ${esc}${liveSuffix}</b>` : `<i>✓ ${esc}</i>`);
217
225
  });
218
226
  return out.join("\n");
219
227
  }
@@ -248,9 +256,10 @@ export function renderActivityFeedWithNested(
248
256
  lines: string[],
249
257
  childLines: string[],
250
258
  final = false,
259
+ liveSuffix = "",
251
260
  ): string | null {
252
261
  const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
253
- if (children.length === 0) return renderActivityFeed(lines, final);
262
+ if (children.length === 0) return renderActivityFeed(lines, final, liveSuffix);
254
263
 
255
264
  const out: string[] = [];
256
265
  const shownParent = lines.slice(-MIRROR_MAX_LINES);
@@ -264,12 +273,13 @@ export function renderActivityFeedWithNested(
264
273
  const lastChildIdx = shownChild.length - 1;
265
274
  // `final`: the nested newest step also renders done (✓) so the left-behind
266
275
  // feed reads as completed, not stuck on a "→ in-progress" child step.
276
+ // `liveSuffix` (heartbeat): appended to the nested newest in-progress step.
267
277
  shownChild.forEach((l, i) => {
268
278
  const t = l.length > NESTED_LINE_MAX ? l.slice(0, NESTED_LINE_MAX - 1) + "…" : l;
269
279
  const esc = escapeFeedHtml(t);
270
280
  out.push(
271
281
  i === lastChildIdx && !final
272
- ? `${NESTED_PREFIX}<b>→ ${esc}</b>`
282
+ ? `${NESTED_PREFIX}<b>→ ${esc}${liveSuffix}</b>`
273
283
  : `${NESTED_PREFIX}<i>${esc}</i>`,
274
284
  );
275
285
  });