switchroom 0.15.44 → 0.16.4
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 +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3249 -1241
- package/dist/cli/ui/index.html +1 -1
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7482 -7439
- package/dist/vault/broker/server.js +11315 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +3 -22
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -3,11 +3,14 @@ import {
|
|
|
3
3
|
describeToolUse,
|
|
4
4
|
appendActivityLine,
|
|
5
5
|
appendActivityLabel,
|
|
6
|
+
clipNarrative,
|
|
6
7
|
renderActivityFeed,
|
|
7
8
|
renderActivityFeedWithNested,
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
renderActivityHeader,
|
|
10
|
+
formatFeedElapsed,
|
|
11
|
+
type SessionActivityHeader,
|
|
10
12
|
} from "../tool-activity-summary.js";
|
|
13
|
+
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
|
|
11
14
|
|
|
12
15
|
describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () => {
|
|
13
16
|
it("Bash uses the model-authored description verbatim, never the command", () => {
|
|
@@ -101,17 +104,21 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
101
104
|
expect(lines).toEqual([]);
|
|
102
105
|
});
|
|
103
106
|
|
|
104
|
-
it("
|
|
105
|
-
|
|
107
|
+
it("windows to the last STATUS_ROLLING_LINES with a '✓ +N earlier…' header on the AGENT surface", () => {
|
|
108
|
+
// The +N earlier header now appears on the AGENT surface too (flag retired).
|
|
109
|
+
const total = STATUS_ROLLING_LINES + 4;
|
|
110
|
+
const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
|
|
106
111
|
const out = renderActivityFeed(lines)!;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
const hidden = total - STATUS_ROLLING_LINES;
|
|
113
|
+
expect(out.startsWith(`<i>✓ +${hidden} earlier…</i>\n`)).toBe(true);
|
|
114
|
+
// Only the last STATUS_ROLLING_LINES actions are shown; older ones collapsed.
|
|
115
|
+
const firstVisible = total - STATUS_ROLLING_LINES + 1;
|
|
116
|
+
expect(out).toContain(`<i>✓ Action ${firstVisible}</i>`);
|
|
117
|
+
expect(out).not.toContain(`Action ${firstVisible - 1}<`);
|
|
111
118
|
// The newest action is the in-progress step (bold →); the rest are done (✓).
|
|
112
|
-
expect(out).toContain(
|
|
113
|
-
expect(out).toContain(
|
|
114
|
-
expect(out).not.toContain(
|
|
119
|
+
expect(out).toContain(`<b>→ Action ${total}</b>`);
|
|
120
|
+
expect(out).toContain(`<i>✓ Action ${total - 1}</i>`);
|
|
121
|
+
expect(out).not.toContain(`<b>→ Action ${total - 1}</b>`);
|
|
115
122
|
});
|
|
116
123
|
|
|
117
124
|
it("HTML-escapes &, <, > in action text (no double-escaping by callers)", () => {
|
|
@@ -193,6 +200,96 @@ describe("appendActivityLabel — precomputed label feed (tool_label path)", ()
|
|
|
193
200
|
});
|
|
194
201
|
});
|
|
195
202
|
|
|
203
|
+
describe("clipNarrative — narrative-step clip (JSONL-text-narrative primitive)", () => {
|
|
204
|
+
it("takes the first line only, trims, slices to 200 chars (Fix 1: raised from 120 to match STATUS_LINE_MAX)", () => {
|
|
205
|
+
expect(clipNarrative("On it. Let me find the repo…\nthen build")).toBe(
|
|
206
|
+
"On it. Let me find the repo…",
|
|
207
|
+
);
|
|
208
|
+
expect(clipNarrative(" Found both: ")).toBe("Found both:");
|
|
209
|
+
// Fix 1: cap is now 200 chars (STATUS_LINE_MAX), not 120.
|
|
210
|
+
const long = "x".repeat(250);
|
|
211
|
+
expect(clipNarrative(long).length).toBe(200);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("a shown narrative renders identically to a tool label via the same feed path", () => {
|
|
215
|
+
// A SHOWN narrative line is pushed through appendActivityLabel exactly
|
|
216
|
+
// like a tool label, so the rendered feed string is byte-identical.
|
|
217
|
+
const narrativeLines: string[] = [];
|
|
218
|
+
const labelLines: string[] = [];
|
|
219
|
+
const text = "Important find: no main branch yet — branching off origin.";
|
|
220
|
+
const narrativeRender = appendActivityLabel(narrativeLines, clipNarrative(text));
|
|
221
|
+
const labelRender = appendActivityLabel(labelLines, clipNarrative(text));
|
|
222
|
+
expect(narrativeRender).toBe(labelRender);
|
|
223
|
+
expect(narrativeRender).toBe(`<b>→ ${text}</b>`);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("empty string yields an empty clip (appendActivityLabel then drops it)", () => {
|
|
227
|
+
expect(clipNarrative("")).toBe("");
|
|
228
|
+
expect(appendActivityLabel([], clipNarrative(" \n "))).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)", () => {
|
|
233
|
+
it("appends '✓ N steps' footer when final=true and stepCount > 0", () => {
|
|
234
|
+
const lines = ["Reading CLAUDE.md", "Searching memory", "Running a command"];
|
|
235
|
+
const out = renderActivityFeed(lines, true, "", 5)!;
|
|
236
|
+
// All lines are done (✓) and the footer is appended.
|
|
237
|
+
expect(out).toContain("<i>✓ Running a command</i>");
|
|
238
|
+
expect(out).toContain("<i>✓ 5 steps</i>");
|
|
239
|
+
expect(out.endsWith("<i>✓ 5 steps</i>")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("stepCount=0 → no footer (no tools fired that were surfaced)", () => {
|
|
243
|
+
const lines = ["Reading CLAUDE.md"];
|
|
244
|
+
const out = renderActivityFeed(lines, true, "", 0)!;
|
|
245
|
+
expect(out).not.toContain("steps");
|
|
246
|
+
expect(out).toBe("<i>✓ Reading CLAUDE.md</i>");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("stepCount undefined → no footer (live/non-final callers omit it)", () => {
|
|
250
|
+
const lines = ["Reading CLAUDE.md"];
|
|
251
|
+
const out = renderActivityFeed(lines, true)!;
|
|
252
|
+
expect(out).not.toContain("steps");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("stepCount present but final=false → no footer (live in-progress feed stays clean)", () => {
|
|
256
|
+
const lines = ["Reading CLAUDE.md", "Running a command"];
|
|
257
|
+
const out = renderActivityFeed(lines, false, "", 7)!;
|
|
258
|
+
expect(out).not.toContain("steps");
|
|
259
|
+
// The newest line is still the live in-progress step.
|
|
260
|
+
expect(out).toContain("<b>→ Running a command</b>");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("stepCount=1 footer reads '✓ 1 steps' (no special-casing)", () => {
|
|
264
|
+
const lines = ["Reading CLAUDE.md"];
|
|
265
|
+
const out = renderActivityFeed(lines, true, "", 1)!;
|
|
266
|
+
expect(out).toContain("<i>✓ 1 steps</i>");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("footer appears even when the feed overflows the rolling window", () => {
|
|
270
|
+
const total = STATUS_ROLLING_LINES + 4;
|
|
271
|
+
const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
|
|
272
|
+
const out = renderActivityFeed(lines, true, "", total)!;
|
|
273
|
+
expect(out).toContain(`<i>✓ +${total - STATUS_ROLLING_LINES} earlier…</i>`);
|
|
274
|
+
expect(out).toContain(`<i>✓ ${total} steps</i>`);
|
|
275
|
+
expect(out.endsWith(`<i>✓ ${total} steps</i>`)).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("stepCount is surface-tool-excluded: reply/react count 0, Read+mcp count correctly", () => {
|
|
279
|
+
// This test documents the counting contract: stepCount is incremented
|
|
280
|
+
// after the isTelegramSurfaceTool guard in case 'tool_label', so surface
|
|
281
|
+
// tools (reply/stream_reply/edit_message/react) are never counted. The
|
|
282
|
+
// rendered footer reflects only the surfaced non-surface steps.
|
|
283
|
+
const lines = ["Reading CLAUDE.md", "Searching memory"]; // 2 non-surface labels
|
|
284
|
+
// stepCount=2 (Read + mcp__hindsight__recall) — reply is NOT counted.
|
|
285
|
+
const out = renderActivityFeed(lines, true, "", 2)!;
|
|
286
|
+
expect(out).toContain("<i>✓ 2 steps</i>");
|
|
287
|
+
// stepCount=0 would mean only surface tools fired — no footer.
|
|
288
|
+
const outSurfaceOnly = renderActivityFeed(["Reading CLAUDE.md"], true, "", 0)!;
|
|
289
|
+
expect(outSurfaceOnly).not.toContain("steps");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
196
293
|
describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A)", () => {
|
|
197
294
|
it("with no child lines, is identical to the flat feed", () => {
|
|
198
295
|
const lines = ["Searching memory", "Delegating: review the migration"];
|
|
@@ -214,12 +311,13 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
214
311
|
expect(out).toContain(" ↳ <b>→ Looking for foreign keys</b>");
|
|
215
312
|
});
|
|
216
313
|
|
|
217
|
-
it("
|
|
218
|
-
const
|
|
314
|
+
it("windows the nested block to STATUS_ROLLING_LINES with a '↳ +N earlier…' header", () => {
|
|
315
|
+
const total = STATUS_ROLLING_LINES + 3;
|
|
316
|
+
const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
|
|
219
317
|
const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
|
|
220
|
-
expect(out).toContain(
|
|
318
|
+
expect(out).toContain(` ↳ <i>+${total - STATUS_ROLLING_LINES} earlier…</i>`);
|
|
221
319
|
// newest nested line is the live → step
|
|
222
|
-
expect(out).toContain(` ↳ <b>→ step ${
|
|
320
|
+
expect(out).toContain(` ↳ <b>→ step ${total}</b>`);
|
|
223
321
|
// the oldest (collapsed) lines are not rendered verbatim
|
|
224
322
|
expect(out).not.toContain("step 1<");
|
|
225
323
|
});
|
|
@@ -267,6 +365,45 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
267
365
|
);
|
|
268
366
|
});
|
|
269
367
|
|
|
368
|
+
it("stepCount footer appears on final=true with children present", () => {
|
|
369
|
+
const parent = ["Delegating: review the migration"];
|
|
370
|
+
const child = ["Reading schema.ts", "Looking for foreign keys"];
|
|
371
|
+
const out = renderActivityFeedWithNested(parent, child, true, "", 5)!;
|
|
372
|
+
expect(out).toContain("<i>✓ 5 steps</i>");
|
|
373
|
+
expect(out.endsWith("<i>✓ 5 steps</i>")).toBe(true);
|
|
374
|
+
expect(out).not.toContain("→");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("stepCount footer appears on final=true with no children (delegates to flat render)", () => {
|
|
378
|
+
const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 3)!;
|
|
379
|
+
expect(out).toContain("<i>✓ 3 steps</i>");
|
|
380
|
+
expect(out).toBe("<i>✓ Reading a.ts</i>\n<i>✓ 3 steps</i>");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Liveness-driven feed open (dark-turn fix): a turn that emits no tool_label
|
|
384
|
+
// (pure thinking / suppressed tools) gets a minimal "Working…" placeholder so
|
|
385
|
+
// the feed still opens and stays visibly alive. These pin the exact render
|
|
386
|
+
// the gateway's feedHeartbeatTick (open + climb) and clearActivitySummary
|
|
387
|
+
// (terminal record) depend on.
|
|
388
|
+
describe("liveness placeholder — 'Working…' feed", () => {
|
|
389
|
+
it("renders the live in-progress placeholder with climbing elapsed", () => {
|
|
390
|
+
expect(
|
|
391
|
+
renderActivityFeedWithNested(["Working…"], [], false, " · 12s"),
|
|
392
|
+
).toBe("<b>→ Working… · 12s</b>");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("finalizes the placeholder to a done record (no frozen → line)", () => {
|
|
396
|
+
expect(renderActivityFeedWithNested(["Working…"], [], true)).toBe(
|
|
397
|
+
"<i>✓ Working…</i>",
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("stepCount=0 → no footer even on final=true", () => {
|
|
403
|
+
const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 0)!;
|
|
404
|
+
expect(out).not.toContain("steps");
|
|
405
|
+
});
|
|
406
|
+
|
|
270
407
|
// Pins the invariant the gateway's foreground handoff-clear path relies on:
|
|
271
408
|
// on an ack-first turn the parent feed is empty (mirrorLines=[]) and the only
|
|
272
409
|
// content is the foreground sub-agent's nested narrative. The finalized
|
|
@@ -293,3 +430,401 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
293
430
|
});
|
|
294
431
|
});
|
|
295
432
|
});
|
|
433
|
+
|
|
434
|
+
// ─── Rolling-window + STATUS_LINE_MAX (flag retired) ────────────────────────
|
|
435
|
+
// Contract (single mode, no flag):
|
|
436
|
+
// - last STATUS_ROLLING_LINES lines render; overflow → `+N earlier…` header
|
|
437
|
+
// on BOTH surfaces;
|
|
438
|
+
// - each line clipped to STATUS_LINE_MAX (200) chars then escaped;
|
|
439
|
+
// - char-budget backstop (fitCardToBudget) is the only wire-limit ceiling.
|
|
440
|
+
|
|
441
|
+
describe("rolling window + +N earlier — renderActivityFeed", () => {
|
|
442
|
+
it("with 12 lines, exactly the last STATUS_ROLLING_LINES render + a +N earlier header", () => {
|
|
443
|
+
const lines = Array.from({ length: 12 }, (_, i) => `XAct-${String(i + 1).padStart(3, '0')}`);
|
|
444
|
+
const out = renderActivityFeed(lines)!;
|
|
445
|
+
const firstVisible = 12 - STATUS_ROLLING_LINES + 1;
|
|
446
|
+
for (let i = firstVisible; i <= 12; i++) {
|
|
447
|
+
expect(out).toContain(`XAct-${String(i).padStart(3, '0')}`);
|
|
448
|
+
}
|
|
449
|
+
for (let i = 1; i < firstVisible; i++) {
|
|
450
|
+
expect(out).not.toContain(`XAct-${String(i).padStart(3, '0')}`);
|
|
451
|
+
}
|
|
452
|
+
// Overflow header present on the AGENT surface now.
|
|
453
|
+
expect(out).toContain(`<i>✓ +${12 - STATUS_ROLLING_LINES} earlier…</i>`);
|
|
454
|
+
expect(out).toContain(`<b>→ XAct-012</b>`);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("STATUS_LINE_MAX=200: a 250-char step is clipped to 200 with a trailing …", () => {
|
|
458
|
+
const longLine = "a".repeat(250);
|
|
459
|
+
const out = renderActivityFeed([longLine])!;
|
|
460
|
+
expect(out).toContain("…");
|
|
461
|
+
// The full 250-char line must NOT survive; the clip is 200 (199 + …).
|
|
462
|
+
expect(out).not.toContain(longLine);
|
|
463
|
+
expect(out).toContain("a".repeat(STATUS_LINE_MAX - 1) + "…");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("a line at exactly STATUS_LINE_MAX is NOT clipped", () => {
|
|
467
|
+
const exact = "b".repeat(STATUS_LINE_MAX);
|
|
468
|
+
const out = renderActivityFeed([exact])!;
|
|
469
|
+
expect(out).toContain(exact);
|
|
470
|
+
expect(out).not.toContain("…");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("no spurious overflow header when the feed fits the window", () => {
|
|
474
|
+
const lines = Array.from({ length: STATUS_ROLLING_LINES }, (_, i) => `Short ${i + 1}`);
|
|
475
|
+
const out = renderActivityFeed(lines)!;
|
|
476
|
+
expect(out).not.toContain("earlier");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("pathologically oversized lines: char-budget backstop fires, output ≤ 4096 chars", () => {
|
|
480
|
+
// STATUS_ROLLING_LINES lines of ~900 chars each → over budget pre-clip, but
|
|
481
|
+
// each line is clipped to 200 first, so the budget should comfortably hold.
|
|
482
|
+
const bigLine = "z".repeat(900);
|
|
483
|
+
const lines = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
|
|
484
|
+
const out = renderActivityFeed(lines)!;
|
|
485
|
+
expect(out.length).toBeLessThanOrEqual(4096);
|
|
486
|
+
const hasBullet = out.includes("→") || out.includes("✓");
|
|
487
|
+
expect(hasBullet).toBe(true);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
492
|
+
it("many parent lines → only the last STATUS_ROLLING_LINES render with a +N earlier header", () => {
|
|
493
|
+
const totalParent = STATUS_ROLLING_LINES + 3;
|
|
494
|
+
const parent = Array.from({ length: totalParent }, (_, i) => `Parent ${i + 1}`);
|
|
495
|
+
const child = ["Child step A", "Child step B"];
|
|
496
|
+
const out = renderActivityFeedWithNested(parent, child)!;
|
|
497
|
+
const firstVisible = totalParent - STATUS_ROLLING_LINES + 1;
|
|
498
|
+
for (let i = firstVisible; i <= totalParent; i++) {
|
|
499
|
+
expect(out).toContain(`Parent ${i}`);
|
|
500
|
+
}
|
|
501
|
+
for (let i = 1; i < firstVisible; i++) {
|
|
502
|
+
expect(out).not.toContain(`Parent ${i}`);
|
|
503
|
+
}
|
|
504
|
+
expect(out).toContain(`<i>✓ +${totalParent - STATUS_ROLLING_LINES} earlier…</i>`);
|
|
505
|
+
expect(out).toContain(" ↳ <b>→ Child step B</b>");
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("many child lines → only the last STATUS_ROLLING_LINES child lines render with a ↳ +N earlier header", () => {
|
|
509
|
+
const parent = ["Delegating: big task"];
|
|
510
|
+
const totalChild = STATUS_ROLLING_LINES + 4;
|
|
511
|
+
const child = Array.from({ length: totalChild }, (_, i) => `Child ${i + 1}`);
|
|
512
|
+
const out = renderActivityFeedWithNested(parent, child)!;
|
|
513
|
+
const firstVisible = totalChild - STATUS_ROLLING_LINES + 1;
|
|
514
|
+
for (let i = firstVisible; i <= totalChild; i++) {
|
|
515
|
+
expect(out).toContain(`Child ${i}`);
|
|
516
|
+
}
|
|
517
|
+
for (let i = 1; i < firstVisible; i++) {
|
|
518
|
+
expect(out).not.toContain(`Child ${i}`);
|
|
519
|
+
}
|
|
520
|
+
expect(out).toContain(` ↳ <i>+${totalChild - STATUS_ROLLING_LINES} earlier…</i>`);
|
|
521
|
+
expect(out).toContain(` ↳ <b>→ Child ${totalChild}</b>`);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
|
|
525
|
+
const longLine = "x".repeat(250);
|
|
526
|
+
const out = renderActivityFeedWithNested(["Delegating"], [longLine])!;
|
|
527
|
+
expect(out).toContain("…");
|
|
528
|
+
expect(out).not.toContain(longLine);
|
|
529
|
+
expect(out).toContain("x".repeat(STATUS_LINE_MAX - 1) + "…");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("huge nested feed: char-budget backstop fires, output ≤ 4096 chars", () => {
|
|
533
|
+
const bigLine = "w".repeat(900);
|
|
534
|
+
const parent = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
|
|
535
|
+
const child = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
|
|
536
|
+
const out = renderActivityFeedWithNested(parent, child)!;
|
|
537
|
+
expect(out.length).toBeLessThanOrEqual(4096);
|
|
538
|
+
expect(out).toContain(" ↳ ");
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// ─── Extreme-edge: single oversized line with HTML-special chars ─────────────
|
|
543
|
+
// These tests cover the _fitToCharBudget and _fitNestedToCharBudget fallback
|
|
544
|
+
// paths that are reachable in no-truncate mode when a Bash label contains
|
|
545
|
+
// special chars (e.g. && is extremely common) and is longer than the budget.
|
|
546
|
+
|
|
547
|
+
/** Cheap valid-HTML checker: balanced tags + no partial entity. */
|
|
548
|
+
function isValidHtml(s: string): boolean {
|
|
549
|
+
// No partial entity: must not end with &[a-z]* lacking semicolon.
|
|
550
|
+
if (/&[a-z]+$/.test(s)) return false;
|
|
551
|
+
if (/&[a-z]+[^;]$/.test(s)) return false;
|
|
552
|
+
// Every &...; must be complete.
|
|
553
|
+
const entityRe = /&([^;]*)/g;
|
|
554
|
+
let m: RegExpExecArray | null;
|
|
555
|
+
while ((m = entityRe.exec(s)) !== null) {
|
|
556
|
+
if (!s.slice(m.index).startsWith("&") || s[m.index + m[0].length] !== ";") {
|
|
557
|
+
// Check if this entity is actually terminated
|
|
558
|
+
const entityStart = m.index;
|
|
559
|
+
const semiIdx = s.indexOf(";", entityStart + 1);
|
|
560
|
+
if (semiIdx === -1) return false; // no closing semicolon
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// All opening tags have a corresponding close (simple check for <b>/<i>).
|
|
564
|
+
const bOpen = (s.match(/<b>/g) ?? []).length;
|
|
565
|
+
const bClose = (s.match(/<\/b>/g) ?? []).length;
|
|
566
|
+
const iOpen = (s.match(/<i>/g) ?? []).length;
|
|
567
|
+
const iClose = (s.match(/<\/i>/g) ?? []).length;
|
|
568
|
+
return bOpen === bClose && iOpen === iClose;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
describe("extreme-edge: single oversized line with &, <, >, &&", () => {
|
|
572
|
+
it("renderActivityFeed: single ~4100-char line with special chars → ≤ budget and valid HTML", () => {
|
|
573
|
+
// Build a raw line >4000 chars with &, <, >, and a trailing &&.
|
|
574
|
+
// The && will escape to && (5 chars each), exercising the expansion guard.
|
|
575
|
+
const base = "Run script with args: foo=bar && baz=<qux> & corge=1 ";
|
|
576
|
+
const bigLine = base.repeat(80) + "&&";
|
|
577
|
+
expect(bigLine.length).toBeGreaterThan(4000);
|
|
578
|
+
|
|
579
|
+
const out = renderActivityFeed([bigLine])!;
|
|
580
|
+
expect(out).not.toBeNull();
|
|
581
|
+
expect(out.length).toBeLessThanOrEqual(4000); // STATUS_CARD_CHAR_BUDGET
|
|
582
|
+
expect(isValidHtml(out)).toBe(true);
|
|
583
|
+
// Must be wrapped in <b>…</b> (live/non-final) — no dangling tag.
|
|
584
|
+
expect(out.startsWith("<b>→ ")).toBe(true);
|
|
585
|
+
expect(out.endsWith("</b>")).toBe(true);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("renderActivityFeed final=true: single ~4100-char line → ≤ budget and valid HTML", () => {
|
|
589
|
+
const base = "Deploy build && upload && notify && cleanup: ";
|
|
590
|
+
const bigLine = base.repeat(90) + "done";
|
|
591
|
+
expect(bigLine.length).toBeGreaterThan(4000);
|
|
592
|
+
|
|
593
|
+
const out = renderActivityFeed([bigLine], true)!;
|
|
594
|
+
expect(out).not.toBeNull();
|
|
595
|
+
expect(out.length).toBeLessThanOrEqual(4000);
|
|
596
|
+
expect(isValidHtml(out)).toBe(true);
|
|
597
|
+
expect(out.startsWith("<i>✓ ")).toBe(true);
|
|
598
|
+
expect(out.endsWith("</i>")).toBe(true);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("renderActivityFeedWithNested: single ~4100-char parent line → ≤ budget and valid HTML", () => {
|
|
602
|
+
const base = "Parent action with & < > special chars && bash: ";
|
|
603
|
+
const bigLine = base.repeat(85);
|
|
604
|
+
expect(bigLine.length).toBeGreaterThan(4000);
|
|
605
|
+
|
|
606
|
+
const out = renderActivityFeedWithNested([bigLine], [])!;
|
|
607
|
+
expect(out).not.toBeNull();
|
|
608
|
+
expect(out.length).toBeLessThanOrEqual(4000);
|
|
609
|
+
expect(isValidHtml(out)).toBe(true);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("renderActivityFeedWithNested: single ~4100-char child line → ≤ budget and valid HTML", () => {
|
|
613
|
+
const base = "Child step: run build && test && deploy with <args> & flags=1 ";
|
|
614
|
+
const bigChild = base.repeat(65) + "&&";
|
|
615
|
+
expect(bigChild.length).toBeGreaterThan(4000);
|
|
616
|
+
|
|
617
|
+
const out = renderActivityFeedWithNested(["Delegating: big job"], [bigChild])!;
|
|
618
|
+
expect(out).not.toBeNull();
|
|
619
|
+
expect(out.length).toBeLessThanOrEqual(4000);
|
|
620
|
+
expect(isValidHtml(out)).toBe(true);
|
|
621
|
+
// Must contain the nested prefix.
|
|
622
|
+
expect(out).toContain(" ↳ ");
|
|
623
|
+
// Regression guard: an operator-precedence bug made wrapperOverhead a string
|
|
624
|
+
// (NESTED_PREFIX + n) → NaN budget → slice(0, NaN) === "" → the child content
|
|
625
|
+
// was silently discarded, leaving only " ↳ <b>→ </b>". The empty-wrapper
|
|
626
|
+
// output still satisfies the toContain(" ↳ ") check above, so assert that
|
|
627
|
+
// real child content actually survives.
|
|
628
|
+
expect(out).toContain("Child step");
|
|
629
|
+
expect(out.length).toBeGreaterThan(100);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("renderActivityFeedWithNested final=true: single ~4100-char child line → ≤ budget and valid HTML", () => {
|
|
633
|
+
const base = "Final child: compile && link && package & ship: ";
|
|
634
|
+
const bigChild = base.repeat(85);
|
|
635
|
+
expect(bigChild.length).toBeGreaterThan(4000);
|
|
636
|
+
|
|
637
|
+
const out = renderActivityFeedWithNested(["Delegating: big job"], [bigChild], true)!;
|
|
638
|
+
expect(out).not.toBeNull();
|
|
639
|
+
expect(out.length).toBeLessThanOrEqual(4000);
|
|
640
|
+
expect(isValidHtml(out)).toBe(true);
|
|
641
|
+
// final=true → nested line renders done (italic, no →).
|
|
642
|
+
expect(out).not.toContain("→");
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ─── Session activity header (unified renderer — main-session card fix) ───────
|
|
647
|
+
//
|
|
648
|
+
// The main-session card was missing the two-line header that the worker card
|
|
649
|
+
// already renders: elapsed time + tool count. These tests verify that the header
|
|
650
|
+
// is now emitted when a `SessionActivityHeader` is supplied, matching the worker
|
|
651
|
+
// card's style and fixing the "missing header" regression.
|
|
652
|
+
|
|
653
|
+
describe("renderActivityHeader — two-line header builder", () => {
|
|
654
|
+
it("renders the running header with elapsed and tool count", () => {
|
|
655
|
+
const [h1, h2] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running");
|
|
656
|
+
expect(h1).toBe("🤖 <b>Agent</b>");
|
|
657
|
+
expect(h2).toBe("<i>15s · 7 tools</i>");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("renders the done header with tool count and elapsed", () => {
|
|
661
|
+
const [h1, h2] = renderActivityHeader("🤖", "Agent", "", 65_000, 3, "done");
|
|
662
|
+
expect(h1).toBe("🤖 <b>Agent</b>");
|
|
663
|
+
expect(h2).toBe("<i>done · 3 tools · 1m05s</i>");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("includes the description in line 1 when non-empty", () => {
|
|
667
|
+
const [h1] = renderActivityHeader("🛠", "Worker", "run tests", 10_000, 2, "running");
|
|
668
|
+
expect(h1).toBe("🛠 <b>Worker</b> · <i>run tests</i>");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("omits the description part when empty", () => {
|
|
672
|
+
const [h1] = renderActivityHeader("🤖", "Agent", "", 5_000, 1, "running");
|
|
673
|
+
expect(h1).toBe("🤖 <b>Agent</b>");
|
|
674
|
+
expect(h1).not.toContain(" · ");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("HTML-escapes special chars in description and label", () => {
|
|
678
|
+
const [h1] = renderActivityHeader("🤖", "Agent", "run <foo> & <bar>", 5_000, 1, "running");
|
|
679
|
+
expect(h1).toContain("run <foo> & <bar>");
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe("agent flat path routes through the shared step-feed primitive", () => {
|
|
684
|
+
it("flat renderActivityFeed === nested-with-empty-children (same window + +N output)", () => {
|
|
685
|
+
// The flat agent path and the nested path with NO children must produce the
|
|
686
|
+
// identical render — both flow through renderStatusCard's step feed.
|
|
687
|
+
const lines = Array.from({ length: STATUS_ROLLING_LINES + 3 }, (_, i) => `Step ${i + 1}`);
|
|
688
|
+
expect(renderActivityFeed(lines)).toBe(renderActivityFeedWithNested(lines, []));
|
|
689
|
+
expect(renderActivityFeed(lines, true, "", 9)).toBe(
|
|
690
|
+
renderActivityFeedWithNested(lines, [], true, "", 9),
|
|
691
|
+
);
|
|
692
|
+
// And the +N earlier marker is present on the flat agent surface.
|
|
693
|
+
expect(renderActivityFeed(lines)!).toContain(
|
|
694
|
+
`<i>✓ +${lines.length - STATUS_ROLLING_LINES} earlier…</i>`,
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
describe("escape entity is never split mid-clip (clip raw → escape last)", () => {
|
|
700
|
+
it("a step ending in '&' clipped at STATUS_LINE_MAX stays valid HTML", () => {
|
|
701
|
+
// Build a step exactly STATUS_LINE_MAX+1 chars ending in '&' so a naive
|
|
702
|
+
// escape-then-clip would split the & entity at the boundary.
|
|
703
|
+
const line = "x".repeat(STATUS_LINE_MAX) + "&";
|
|
704
|
+
const out = renderActivityFeed([line])!;
|
|
705
|
+
// The clip keeps STATUS_LINE_MAX-1 chars + '…', then escapes — no stray entity.
|
|
706
|
+
expect(out).not.toMatch(/&$/);
|
|
707
|
+
expect(out).not.toMatch(/&am[^p]/);
|
|
708
|
+
expect(out).toContain("…");
|
|
709
|
+
// Whatever '&' survives must be a complete &.
|
|
710
|
+
const ampCount = (out.match(/&/g) ?? []).length;
|
|
711
|
+
const bareAmp = (out.match(/&(?!amp;|lt;|gt;)/g) ?? []).length;
|
|
712
|
+
expect(bareAmp).toBe(0);
|
|
713
|
+
expect(ampCount).toBeGreaterThanOrEqual(0);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("a step ending in '<' clipped at STATUS_LINE_MAX stays valid HTML", () => {
|
|
717
|
+
const line = "y".repeat(STATUS_LINE_MAX) + "<";
|
|
718
|
+
const out = renderActivityFeed([line])!;
|
|
719
|
+
expect(out).not.toMatch(/<$/);
|
|
720
|
+
expect(out).not.toMatch(/&l[^t;]/);
|
|
721
|
+
const bareLt = (out.match(/&(?!amp;|lt;|gt;)/g) ?? []).length;
|
|
722
|
+
expect(bareLt).toBe(0);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
describe("formatFeedElapsed — elapsed formatter", () => {
|
|
727
|
+
it("formats sub-minute durations as Ns", () => {
|
|
728
|
+
expect(formatFeedElapsed(0)).toBe("0s");
|
|
729
|
+
expect(formatFeedElapsed(999)).toBe("0s");
|
|
730
|
+
expect(formatFeedElapsed(1_000)).toBe("1s");
|
|
731
|
+
expect(formatFeedElapsed(59_000)).toBe("59s");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("formats minute+ durations as MmSSs", () => {
|
|
735
|
+
expect(formatFeedElapsed(60_000)).toBe("1m00s");
|
|
736
|
+
expect(formatFeedElapsed(65_000)).toBe("1m05s");
|
|
737
|
+
expect(formatFeedElapsed(125_000)).toBe("2m05s");
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
742
|
+
it("prepends the two-line header when header is supplied (running)", () => {
|
|
743
|
+
const header: SessionActivityHeader = {
|
|
744
|
+
label: "Agent",
|
|
745
|
+
elapsedMs: 15_000,
|
|
746
|
+
toolCount: 7,
|
|
747
|
+
state: "running",
|
|
748
|
+
};
|
|
749
|
+
const out = renderActivityFeed(["Reading CLAUDE.md", "Searching memory"], false, "", undefined, header)!;
|
|
750
|
+
// Must start with the header lines.
|
|
751
|
+
expect(out).toContain("🤖 <b>Agent</b>");
|
|
752
|
+
expect(out).toContain("<i>15s · 7 tools</i>");
|
|
753
|
+
// Step feed follows the header.
|
|
754
|
+
expect(out).toContain("<i>✓ Reading CLAUDE.md</i>");
|
|
755
|
+
expect(out).toContain("<b>→ Searching memory</b>");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("prepends the done header when final=true", () => {
|
|
759
|
+
const header: SessionActivityHeader = {
|
|
760
|
+
label: "Agent",
|
|
761
|
+
elapsedMs: 65_000,
|
|
762
|
+
toolCount: 3,
|
|
763
|
+
state: "done",
|
|
764
|
+
};
|
|
765
|
+
const out = renderActivityFeed(["Reading CLAUDE.md"], true, "", undefined, header)!;
|
|
766
|
+
expect(out).toContain("🤖 <b>Agent</b>");
|
|
767
|
+
expect(out).toContain("<i>done · 3 tools · 1m05s</i>");
|
|
768
|
+
expect(out).toContain("<i>✓ Reading CLAUDE.md</i>");
|
|
769
|
+
// No in-progress arrow (final=true).
|
|
770
|
+
expect(out).not.toContain("→");
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("renders header-only (no steps) when lines is empty", () => {
|
|
774
|
+
const header: SessionActivityHeader = {
|
|
775
|
+
label: "Agent",
|
|
776
|
+
elapsedMs: 5_000,
|
|
777
|
+
toolCount: 0,
|
|
778
|
+
state: "running",
|
|
779
|
+
};
|
|
780
|
+
// renderActivityFeed normally returns null for empty lines; with header it returns content.
|
|
781
|
+
const out = renderActivityFeed([], false, "", undefined, header);
|
|
782
|
+
expect(out).not.toBeNull();
|
|
783
|
+
expect(out).toContain("🤖 <b>Agent</b>");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("without header, renderActivityFeed still returns null for empty lines (no regression)", () => {
|
|
787
|
+
expect(renderActivityFeed([], false, "", undefined, undefined)).toBeNull();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("header is present in renderActivityFeedWithNested output too", () => {
|
|
791
|
+
const header: SessionActivityHeader = {
|
|
792
|
+
label: "Agent",
|
|
793
|
+
elapsedMs: 30_000,
|
|
794
|
+
toolCount: 5,
|
|
795
|
+
state: "running",
|
|
796
|
+
};
|
|
797
|
+
const out = renderActivityFeedWithNested(
|
|
798
|
+
["Delegating: review"],
|
|
799
|
+
["Reading schema.ts"],
|
|
800
|
+
false,
|
|
801
|
+
"",
|
|
802
|
+
undefined,
|
|
803
|
+
header,
|
|
804
|
+
)!;
|
|
805
|
+
expect(out).toContain("🤖 <b>Agent</b>");
|
|
806
|
+
expect(out).toContain("<i>30s · 5 tools</i>");
|
|
807
|
+
// Parent step is done-styled (child is the live step).
|
|
808
|
+
expect(out).toContain("<i>✓ Delegating: review</i>");
|
|
809
|
+
// Child step is the in-progress step.
|
|
810
|
+
expect(out).toContain(" ↳ <b>→ Reading schema.ts</b>");
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
describe("describeToolUse — surface-tool suppression is key-agnostic", () => {
|
|
815
|
+
it("returns null for telegram reply/stream_reply under any registration key", () => {
|
|
816
|
+
// Standard switchroom-telegram key.
|
|
817
|
+
expect(describeToolUse("mcp__switchroom-telegram__reply", {})).toBeNull();
|
|
818
|
+
expect(describeToolUse("mcp__switchroom-telegram__stream_reply", {})).toBeNull();
|
|
819
|
+
// Legacy clerk-telegram key.
|
|
820
|
+
expect(describeToolUse("mcp__clerk-telegram__reply", {})).toBeNull();
|
|
821
|
+
expect(describeToolUse("mcp__clerk-telegram__stream_reply", {})).toBeNull();
|
|
822
|
+
// Hypothetical custom fork.
|
|
823
|
+
expect(describeToolUse("mcp__my-custom-telegram__reply", {})).toBeNull();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("returns null for telegram edit_message and react under any key", () => {
|
|
827
|
+
expect(describeToolUse("mcp__clerk-telegram__edit_message", {})).toBeNull();
|
|
828
|
+
expect(describeToolUse("mcp__clerk-telegram__react", {})).toBeNull();
|
|
829
|
+
});
|
|
830
|
+
});
|