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.
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +19 -9
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +29 -70
- package/dist/host-control/main.js +1 -1
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +216 -12
- package/telegram-plugin/gateway/gateway.ts +216 -2
- package/telegram-plugin/gateway/obligation-ledger.ts +216 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +167 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tool-activity-summary.ts +14 -4
|
@@ -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(
|
|
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
|
});
|