switchroom 0.14.25 → 0.14.27
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/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/card-format.ts +62 -0
- package/telegram-plugin/dist/gateway/gateway.js +81 -30
- package/telegram-plugin/gateway/gateway.ts +1 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +1 -1
- package/telegram-plugin/tests/card-format.test.ts +80 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +96 -52
- package/telegram-plugin/uat/assertions.ts +8 -6
- package/telegram-plugin/uat/feed-matcher.test.ts +14 -8
- package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-dm.test.ts +17 -6
- package/telegram-plugin/worker-activity-feed.ts +81 -46
package/dist/cli/switchroom.js
CHANGED
|
@@ -49420,8 +49420,8 @@ var {
|
|
|
49420
49420
|
} = import__.default;
|
|
49421
49421
|
|
|
49422
49422
|
// src/build-info.ts
|
|
49423
|
-
var VERSION = "0.14.
|
|
49424
|
-
var COMMIT_SHA = "
|
|
49423
|
+
var VERSION = "0.14.27";
|
|
49424
|
+
var COMMIT_SHA = "5ae9596e";
|
|
49425
49425
|
|
|
49426
49426
|
// src/cli/agent.ts
|
|
49427
49427
|
init_source();
|
package/package.json
CHANGED
|
@@ -60,3 +60,65 @@ export function escapeHtml(s: string): string {
|
|
|
60
60
|
export function truncate(s: string, n: number): string {
|
|
61
61
|
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Strip Markdown markup from a single line, leaving plain prose.
|
|
66
|
+
*
|
|
67
|
+
* Worker narration is authored as Markdown — the model writes `**bold**`,
|
|
68
|
+
* `` `code` ``, `- bullets`, `# headings`. The status cards render Telegram
|
|
69
|
+
* HTML, which does NOT interpret Markdown, so an unstripped `**` shows up as
|
|
70
|
+
* two literal asterisks (the #94-class "half-done" look). Strip the markup
|
|
71
|
+
* here so the card reads as clean prose.
|
|
72
|
+
*
|
|
73
|
+
* Run this BEFORE `truncate` + `escapeHtml`: clean → measure → escape. (The
|
|
74
|
+
* stripper never touches `<`/`>`/`&`, so escaping stays the last step.)
|
|
75
|
+
*/
|
|
76
|
+
export function stripMarkdown(s: string): string {
|
|
77
|
+
let out = s;
|
|
78
|
+
// Inline + leftover code spans → bare text.
|
|
79
|
+
out = out.replace(/`+/g, '');
|
|
80
|
+
// Links / images: [text](url) and  → the label.
|
|
81
|
+
out = out.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1');
|
|
82
|
+
// Paired bold / emphasis runs (longest marker first).
|
|
83
|
+
out = out.replace(/\*\*(.+?)\*\*/g, '$1');
|
|
84
|
+
out = out.replace(/__(.+?)__/g, '$1');
|
|
85
|
+
out = out.replace(/\*(.+?)\*/g, '$1');
|
|
86
|
+
out = out.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, '$1');
|
|
87
|
+
// Leading block markup: heading, blockquote, bullet, ordered item.
|
|
88
|
+
out = out.replace(/^\s{0,3}#{1,6}\s+/, '');
|
|
89
|
+
out = out.replace(/^\s{0,3}>\s?/, '');
|
|
90
|
+
out = out.replace(/^\s{0,3}[-*+]\s+/, '');
|
|
91
|
+
out = out.replace(/^\s{0,3}\d+[.)]\s+/, '');
|
|
92
|
+
// Residual unpaired bold markers (a lone `*` is left alone so `3 * 4`
|
|
93
|
+
// survives; only the doubled form is markup-by-construction).
|
|
94
|
+
out = out.replace(/\*\*/g, '');
|
|
95
|
+
return out.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True for a whole-line horizontal rule: `---`, `___`, `***` (3+ of one). */
|
|
99
|
+
function isRuleLine(s: string): boolean {
|
|
100
|
+
return /^\s*([-_*])\1{2,}\s*$/.test(s);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clean a worker's multi-line result/narration into a single plain-text
|
|
105
|
+
* paragraph for a card's finished body. Drops fenced code blocks and
|
|
106
|
+
* horizontal rules, strips per-line Markdown, then space-joins what's left.
|
|
107
|
+
* Output is plain text — the caller still truncates + escapes before
|
|
108
|
+
* interpolating into HTML.
|
|
109
|
+
*/
|
|
110
|
+
export function cleanWorkerResultParagraph(s: string): string {
|
|
111
|
+
const kept: string[] = [];
|
|
112
|
+
let inFence = false;
|
|
113
|
+
for (const raw of s.split('\n')) {
|
|
114
|
+
if (/^\s*```/.test(raw)) {
|
|
115
|
+
inFence = !inFence;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (inFence) continue;
|
|
119
|
+
if (isRuleLine(raw)) continue;
|
|
120
|
+
const cleaned = stripMarkdown(raw);
|
|
121
|
+
if (cleaned.length > 0) kept.push(cleaned);
|
|
122
|
+
}
|
|
123
|
+
return kept.join(' ').replace(/\s+/g, ' ').trim();
|
|
124
|
+
}
|
|
@@ -6549,6 +6549,43 @@ function escapeHtml(s) {
|
|
|
6549
6549
|
function truncate(s, n) {
|
|
6550
6550
|
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
6551
6551
|
}
|
|
6552
|
+
function stripMarkdown(s) {
|
|
6553
|
+
let out = s;
|
|
6554
|
+
out = out.replace(/`+/g, "");
|
|
6555
|
+
out = out.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
6556
|
+
out = out.replace(/\*\*(.+?)\*\*/g, "$1");
|
|
6557
|
+
out = out.replace(/__(.+?)__/g, "$1");
|
|
6558
|
+
out = out.replace(/\*(.+?)\*/g, "$1");
|
|
6559
|
+
out = out.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, "$1");
|
|
6560
|
+
out = out.replace(/^\s{0,3}#{1,6}\s+/, "");
|
|
6561
|
+
out = out.replace(/^\s{0,3}>\s?/, "");
|
|
6562
|
+
out = out.replace(/^\s{0,3}[-*+]\s+/, "");
|
|
6563
|
+
out = out.replace(/^\s{0,3}\d+[.)]\s+/, "");
|
|
6564
|
+
out = out.replace(/\*\*/g, "");
|
|
6565
|
+
return out.trim();
|
|
6566
|
+
}
|
|
6567
|
+
function isRuleLine(s) {
|
|
6568
|
+
return /^\s*([-_*])\1{2,}\s*$/.test(s);
|
|
6569
|
+
}
|
|
6570
|
+
function cleanWorkerResultParagraph(s) {
|
|
6571
|
+
const kept = [];
|
|
6572
|
+
let inFence = false;
|
|
6573
|
+
for (const raw of s.split(`
|
|
6574
|
+
`)) {
|
|
6575
|
+
if (/^\s*```/.test(raw)) {
|
|
6576
|
+
inFence = !inFence;
|
|
6577
|
+
continue;
|
|
6578
|
+
}
|
|
6579
|
+
if (inFence)
|
|
6580
|
+
continue;
|
|
6581
|
+
if (isRuleLine(raw))
|
|
6582
|
+
continue;
|
|
6583
|
+
const cleaned = stripMarkdown(raw);
|
|
6584
|
+
if (cleaned.length > 0)
|
|
6585
|
+
kept.push(cleaned);
|
|
6586
|
+
}
|
|
6587
|
+
return kept.join(" ").replace(/\s+/g, " ").trim();
|
|
6588
|
+
}
|
|
6552
6589
|
|
|
6553
6590
|
// ../node_modules/.bun/@grammyjs+runner@2.0.3+c6be0243b1bbec89/node_modules/@grammyjs/runner/out/mod.js
|
|
6554
6591
|
var require_mod4 = __commonJS((exports) => {
|
|
@@ -31966,37 +32003,51 @@ function isWorkerActivityFeedEnabled(envVal) {
|
|
|
31966
32003
|
return envVal !== "0";
|
|
31967
32004
|
}
|
|
31968
32005
|
var DESC_MAX = 80;
|
|
31969
|
-
var
|
|
31970
|
-
var
|
|
32006
|
+
var STEP_MAX = 100;
|
|
32007
|
+
var RESULT_MAX = 320;
|
|
32008
|
+
var RULE = "\u2500\u2500\u2500\u2500\u2500";
|
|
31971
32009
|
var NARRATIVE_MAX_LINES = 6;
|
|
32010
|
+
function appendStepFeed(lines, steps, allDone) {
|
|
32011
|
+
if (steps.length === 0)
|
|
32012
|
+
return;
|
|
32013
|
+
const shown = steps.slice(-NARRATIVE_MAX_LINES);
|
|
32014
|
+
const hidden = steps.length - shown.length;
|
|
32015
|
+
if (hidden > 0)
|
|
32016
|
+
lines.push(`<i>\u2713 +${hidden} earlier\u2026</i>`);
|
|
32017
|
+
const lastIdx = shown.length - 1;
|
|
32018
|
+
shown.forEach((s, i) => {
|
|
32019
|
+
lines.push(!allDone && i === lastIdx ? `<b>\u2192 ${s}</b>` : `<i>\u2713 ${s}</i>`);
|
|
32020
|
+
});
|
|
32021
|
+
}
|
|
31972
32022
|
function renderWorkerActivity(v) {
|
|
31973
|
-
const desc = truncate(v.description.trim() || "background task", DESC_MAX);
|
|
32023
|
+
const desc = truncate(stripMarkdown(v.description).trim() || "background task", DESC_MAX);
|
|
31974
32024
|
const elapsed = formatDuration(v.elapsedMs);
|
|
31975
32025
|
const toolWord = v.toolCount === 1 ? "tool" : "tools";
|
|
31976
|
-
|
|
31977
|
-
|
|
31978
|
-
|
|
31979
|
-
|
|
31980
|
-
|
|
31981
|
-
|
|
31982
|
-
|
|
31983
|
-
|
|
31984
|
-
|
|
31985
|
-
|
|
31986
|
-
|
|
32026
|
+
const header = `\uD83D\uDEE0 <b>Worker</b> \u00b7 <i>${escapeHtml(desc)}</i>`;
|
|
32027
|
+
const finished = v.state === "done" || v.state === "failed";
|
|
32028
|
+
const steps = (v.narrativeLines ?? []).map((s) => stripMarkdown(s)).filter((s) => s.length > 0).map((s) => escapeHtml(truncate(s, STEP_MAX)));
|
|
32029
|
+
if (finished) {
|
|
32030
|
+
const verb = v.state === "done" ? "completed" : "failed";
|
|
32031
|
+
const lines2 = [header, `<i>finished \u00b7 ${verb} \u00b7 ${v.toolCount} ${toolWord} \u00b7 ${elapsed}</i>`];
|
|
32032
|
+
appendStepFeed(lines2, steps, true);
|
|
32033
|
+
const result = cleanWorkerResultParagraph(v.latestSummary);
|
|
32034
|
+
if (result.length > 0) {
|
|
32035
|
+
const emoji = v.state === "done" ? "\u2705" : "\u26a0\ufe0f";
|
|
32036
|
+
lines2.push(RULE);
|
|
32037
|
+
lines2.push(`${emoji} <i>${escapeHtml(truncate(result, RESULT_MAX))}</i>`);
|
|
32038
|
+
}
|
|
32039
|
+
return lines2.join(`
|
|
32040
|
+
`);
|
|
32041
|
+
}
|
|
32042
|
+
const lines = [header, `<i>running \u00b7 ${elapsed} \u00b7 ${v.toolCount} ${toolWord}</i>`];
|
|
32043
|
+
if (steps.length > 0) {
|
|
32044
|
+
appendStepFeed(lines, steps, false);
|
|
31987
32045
|
} else {
|
|
31988
|
-
|
|
31989
|
-
}
|
|
31990
|
-
const lines = [header, activity];
|
|
31991
|
-
const narrative = (v.narrativeLines ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
31992
|
-
if (narrative.length > 0) {
|
|
31993
|
-
for (const line of narrative) {
|
|
31994
|
-
lines.push(` \u21b3 <i>${escapeHtml(truncate(line, SUMMARY_MAX))}</i>`);
|
|
31995
|
-
}
|
|
31996
|
-
} else {
|
|
31997
|
-
const summary = v.latestSummary.trim();
|
|
32046
|
+
const summary = stripMarkdown(v.latestSummary);
|
|
31998
32047
|
if (summary.length > 0) {
|
|
31999
|
-
lines.push(
|
|
32048
|
+
lines.push(`<b>\u2192 ${escapeHtml(truncate(summary, STEP_MAX))}</b>`);
|
|
32049
|
+
} else {
|
|
32050
|
+
lines.push("<i>starting\u2026</i>");
|
|
32000
32051
|
}
|
|
32001
32052
|
}
|
|
32002
32053
|
return lines.join(`
|
|
@@ -32085,7 +32136,7 @@ function createWorkerActivityFeed(opts) {
|
|
|
32085
32136
|
if (nowFn() < h.cooldownUntil) {
|
|
32086
32137
|
return;
|
|
32087
32138
|
}
|
|
32088
|
-
const body = renderWorkerActivity(view);
|
|
32139
|
+
const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative });
|
|
32089
32140
|
if (body === h.lastBody)
|
|
32090
32141
|
return;
|
|
32091
32142
|
try {
|
|
@@ -51469,10 +51520,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51469
51520
|
}
|
|
51470
51521
|
|
|
51471
51522
|
// ../src/build-info.ts
|
|
51472
|
-
var VERSION = "0.14.
|
|
51473
|
-
var COMMIT_SHA = "
|
|
51474
|
-
var COMMIT_DATE = "2026-06-
|
|
51475
|
-
var LATEST_PR =
|
|
51523
|
+
var VERSION = "0.14.27";
|
|
51524
|
+
var COMMIT_SHA = "5ae9596e";
|
|
51525
|
+
var COMMIT_DATE = "2026-06-01T01:58:04Z";
|
|
51526
|
+
var LATEST_PR = 2042;
|
|
51476
51527
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51477
51528
|
|
|
51478
51529
|
// gateway/boot-version.ts
|
|
@@ -17971,7 +17971,7 @@ void (async () => {
|
|
|
17971
17971
|
// The watcher's `description` is its 'sub-agent' default (it
|
|
17972
17972
|
// never reassigns it from the worker jsonl). The dispatch-time
|
|
17973
17973
|
// task description lives in the registry row — resolveWorkerFeedDispatch
|
|
17974
|
-
// prefers it so the header reads "
|
|
17974
|
+
// prefers it so the header reads "🛠 Worker · <real task>" not
|
|
17975
17975
|
// "· sub-agent" (worker-feed-dispatch.ts, pinned by its test).
|
|
17976
17976
|
let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
|
|
17977
17977
|
if (turnsDb != null) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
cleanWorkerResultParagraph,
|
|
4
|
+
escapeHtml,
|
|
5
|
+
formatDuration,
|
|
6
|
+
stripMarkdown,
|
|
7
|
+
truncate,
|
|
8
|
+
} from '../card-format.js'
|
|
9
|
+
|
|
10
|
+
describe('stripMarkdown', () => {
|
|
11
|
+
it('strips paired bold and emphasis', () => {
|
|
12
|
+
expect(stripMarkdown('a **bold** and *em* and __b__ and _e_')).toBe(
|
|
13
|
+
'a bold and em and b and e',
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('strips inline code spans', () => {
|
|
18
|
+
expect(stripMarkdown('run `git push` now')).toBe('run git push now')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('strips leading headings, blockquotes, bullets, and ordered items', () => {
|
|
22
|
+
expect(stripMarkdown('### Heading')).toBe('Heading')
|
|
23
|
+
expect(stripMarkdown('> quoted')).toBe('quoted')
|
|
24
|
+
expect(stripMarkdown('- a bullet')).toBe('a bullet')
|
|
25
|
+
expect(stripMarkdown('* star bullet')).toBe('star bullet')
|
|
26
|
+
expect(stripMarkdown('1. first')).toBe('first')
|
|
27
|
+
expect(stripMarkdown('2) second')).toBe('second')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('reduces a link to its label', () => {
|
|
31
|
+
expect(stripMarkdown('see [the PR](https://x/y) here')).toBe('see the PR here')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('removes residual doubled markers but keeps a lone asterisk (math)', () => {
|
|
35
|
+
expect(stripMarkdown('**dangling')).toBe('dangling')
|
|
36
|
+
expect(stripMarkdown('3 * 4 = 12')).toBe('3 * 4 = 12')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not touch HTML-significant characters (escaping stays separate)', () => {
|
|
40
|
+
expect(stripMarkdown('a < b & c > d')).toBe('a < b & c > d')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('cleanWorkerResultParagraph', () => {
|
|
45
|
+
it('collapses multi-line Markdown into one plain paragraph', () => {
|
|
46
|
+
const input = '## Done\n\n**PR #21** opened\n\n- merged\n- pushed'
|
|
47
|
+
expect(cleanWorkerResultParagraph(input)).toBe('Done PR #21 opened merged pushed')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('drops fenced code blocks entirely', () => {
|
|
51
|
+
const input = 'before\n```ts\nconst x = 1\n```\nafter'
|
|
52
|
+
expect(cleanWorkerResultParagraph(input)).toBe('before after')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('drops horizontal rules', () => {
|
|
56
|
+
expect(cleanWorkerResultParagraph('a\n---\nb\n***\nc')).toBe('a b c')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns empty for whitespace/markup-only input', () => {
|
|
60
|
+
expect(cleanWorkerResultParagraph(' \n---\n')).toBe('')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('formatDuration', () => {
|
|
65
|
+
it('renders sub-second as ms and seconds/minutes as MM:SS', () => {
|
|
66
|
+
expect(formatDuration(500)).toBe('500ms')
|
|
67
|
+
expect(formatDuration(1000)).toBe('00:01')
|
|
68
|
+
expect(formatDuration(60_000)).toBe('01:00')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('escapeHtml / truncate', () => {
|
|
73
|
+
it('escapes the three HTML-significant characters', () => {
|
|
74
|
+
expect(escapeHtml('a <b> & c')).toBe('a <b> & c')
|
|
75
|
+
})
|
|
76
|
+
it('truncates with an ellipsis', () => {
|
|
77
|
+
expect(truncate('abcdef', 4)).toBe('abc…')
|
|
78
|
+
expect(truncate('abc', 4)).toBe('abc')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -69,72 +69,122 @@ function makeFakeBot(): FakeBot {
|
|
|
69
69
|
// ─── renderWorkerActivity (pure) ─────────────────────────────────────────────
|
|
70
70
|
|
|
71
71
|
describe('renderWorkerActivity', () => {
|
|
72
|
-
|
|
72
|
+
/** Count of step-feed bullets (`✓` done + `→` in-progress) in a body. */
|
|
73
|
+
const stepCount = (s: string) => (s.match(/[✓→]/g) ?? []).length
|
|
74
|
+
|
|
75
|
+
it('renders the native header + running status + step feed', () => {
|
|
73
76
|
const out = renderWorkerActivity(view())
|
|
74
|
-
expect(out).toContain('
|
|
75
|
-
expect(out).toContain('
|
|
76
|
-
expect(out).toContain('
|
|
77
|
-
|
|
77
|
+
expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
|
|
78
|
+
expect(out).toContain('running · ')
|
|
79
|
+
expect(out).toContain('3 tools')
|
|
80
|
+
// No narrativeLines → the latestSummary surfaces as the newest `→` step.
|
|
81
|
+
expect(out).toContain('<b>→ scanning vendor pages</b>')
|
|
82
|
+
// The old tool/arg chrome is gone.
|
|
83
|
+
expect(out).not.toContain('⚡')
|
|
84
|
+
expect(out).not.toContain('<code>')
|
|
78
85
|
})
|
|
79
86
|
|
|
80
|
-
it('shows a "starting…" line when no
|
|
87
|
+
it('shows a "starting…" line when no step has run yet', () => {
|
|
81
88
|
const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
|
|
82
|
-
expect(out).toContain('
|
|
89
|
+
expect(out).toContain('🛠 <b>Worker</b>')
|
|
83
90
|
expect(out).toContain('starting…')
|
|
84
|
-
expect(out).not.toContain('
|
|
91
|
+
expect(out).not.toContain('→')
|
|
85
92
|
})
|
|
86
93
|
|
|
87
|
-
it('
|
|
94
|
+
it('falls back to starting… when the summary is blank', () => {
|
|
88
95
|
const out = renderWorkerActivity(view({ latestSummary: ' ' }))
|
|
89
|
-
expect(out).
|
|
96
|
+
expect(out).toContain('starting…')
|
|
97
|
+
expect(stepCount(out)).toBe(0)
|
|
90
98
|
})
|
|
91
99
|
|
|
92
100
|
it('uses singular "tool" for a single tool call', () => {
|
|
93
101
|
const out = renderWorkerActivity(view({ toolCount: 1 }))
|
|
94
|
-
expect(out).toContain('
|
|
102
|
+
expect(out).toContain('1 tool')
|
|
103
|
+
expect(out).not.toContain('1 tools')
|
|
95
104
|
})
|
|
96
105
|
|
|
97
|
-
it('renders a done terminal recap', () => {
|
|
98
|
-
const out = renderWorkerActivity(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(out).
|
|
106
|
+
it('renders a done terminal recap with a rule + cleaned result', () => {
|
|
107
|
+
const out = renderWorkerActivity(
|
|
108
|
+
view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
|
|
109
|
+
)
|
|
110
|
+
expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
|
|
111
|
+
expect(out).toContain('finished · completed · 5 tools · ')
|
|
112
|
+
expect(out).toContain('─────')
|
|
113
|
+
expect(out).toContain('✅ <i>PR #21 opened</i>')
|
|
102
114
|
})
|
|
103
115
|
|
|
104
116
|
it('renders a failed terminal recap', () => {
|
|
105
|
-
const out = renderWorkerActivity(view({ state: 'failed' }))
|
|
106
|
-
expect(out).toContain('
|
|
117
|
+
const out = renderWorkerActivity(view({ state: 'failed', latestSummary: 'blew up' }))
|
|
118
|
+
expect(out).toContain('finished · failed · ')
|
|
119
|
+
expect(out).toContain('⚠️ <i>blew up</i>')
|
|
107
120
|
})
|
|
108
121
|
|
|
109
|
-
it('
|
|
122
|
+
it('omits the rule + result line when the terminal result is empty', () => {
|
|
123
|
+
const out = renderWorkerActivity(view({ state: 'done', latestSummary: ' ' }))
|
|
124
|
+
expect(out).toContain('finished · completed · ')
|
|
125
|
+
expect(out).not.toContain('─────')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('grows a step feed when narrativeLines is present (prior ✓, newest →)', () => {
|
|
110
129
|
const out = renderWorkerActivity(
|
|
111
130
|
view({
|
|
112
131
|
latestSummary: 'newest only — should be ignored',
|
|
113
132
|
narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
|
|
114
133
|
}),
|
|
115
134
|
)
|
|
116
|
-
expect(out).toContain('
|
|
117
|
-
expect(out).toContain('
|
|
118
|
-
expect(out).toContain('
|
|
135
|
+
expect(out).toContain('<i>✓ read the brief</i>')
|
|
136
|
+
expect(out).toContain('<i>✓ scanned vendor A</i>')
|
|
137
|
+
expect(out).toContain('<b>→ scanned vendor B</b>')
|
|
119
138
|
// The single-line latestSummary fallback is NOT used when a block is present.
|
|
120
139
|
expect(out).not.toContain('newest only')
|
|
121
|
-
|
|
122
|
-
expect(out.match(/↳/g) ?? []).toHaveLength(3)
|
|
140
|
+
expect(stepCount(out)).toBe(3)
|
|
123
141
|
})
|
|
124
142
|
|
|
125
143
|
it('falls back to latestSummary when narrativeLines is empty', () => {
|
|
126
144
|
const out = renderWorkerActivity(view({ narrativeLines: [], latestSummary: 'one line' }))
|
|
127
|
-
expect(out).toContain('
|
|
128
|
-
expect(out
|
|
145
|
+
expect(out).toContain('<b>→ one line</b>')
|
|
146
|
+
expect(stepCount(out)).toBe(1)
|
|
129
147
|
})
|
|
130
148
|
|
|
131
|
-
it('drops blank narrative lines from the
|
|
132
|
-
const out = renderWorkerActivity(
|
|
133
|
-
|
|
149
|
+
it('drops blank narrative lines from the feed', () => {
|
|
150
|
+
const out = renderWorkerActivity(view({ narrativeLines: ['kept', ' ', 'also kept'] }))
|
|
151
|
+
expect(out).toContain('<i>✓ kept</i>')
|
|
152
|
+
expect(out).toContain('<b>→ also kept</b>')
|
|
153
|
+
expect(stepCount(out)).toBe(2)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('shows an overflow header when the feed exceeds the cap', () => {
|
|
157
|
+
const lines = Array.from({ length: 9 }, (_, i) => `step ${i + 1}`)
|
|
158
|
+
const out = renderWorkerActivity(view({ narrativeLines: lines }))
|
|
159
|
+
expect(out).toContain('<i>✓ +3 earlier…</i>')
|
|
160
|
+
expect(out).not.toContain('step 1')
|
|
161
|
+
expect(out).toContain('<i>✓ step 4</i>')
|
|
162
|
+
expect(out).toContain('<b>→ step 9</b>')
|
|
163
|
+
// 6 visible step lines (the overflow header is not itself a step).
|
|
164
|
+
expect(out.match(/step \d/g) ?? []).toHaveLength(6)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('strips Markdown markup from narrative + description + result', () => {
|
|
168
|
+
const running = renderWorkerActivity(
|
|
169
|
+
view({
|
|
170
|
+
description: '**Build** the `sync`',
|
|
171
|
+
narrativeLines: ['- ran the **full** suite', '`git push`'],
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
expect(running).toContain('🛠 <b>Worker</b> · <i>Build the sync</i>')
|
|
175
|
+
expect(running).toContain('ran the full suite')
|
|
176
|
+
expect(running).toContain('git push')
|
|
177
|
+
expect(running).not.toContain('**')
|
|
178
|
+
expect(running).not.toContain('`')
|
|
179
|
+
|
|
180
|
+
const done = renderWorkerActivity(
|
|
181
|
+
view({ state: 'done', latestSummary: '## Done\n\n**PR #21** opened\n\n---\n`merged`' }),
|
|
134
182
|
)
|
|
135
|
-
expect(
|
|
136
|
-
expect(
|
|
137
|
-
expect(
|
|
183
|
+
expect(done).toContain('Done PR #21 opened merged')
|
|
184
|
+
expect(done).not.toContain('**')
|
|
185
|
+
expect(done).not.toContain('`')
|
|
186
|
+
// The card's own divider is the box-drawing rule, never a raw `---`.
|
|
187
|
+
expect(done).not.toMatch(/(^|\n)\s*-{3,}\s*(\n|$)/)
|
|
138
188
|
})
|
|
139
189
|
|
|
140
190
|
it('escapes HTML inside narrative lines', () => {
|
|
@@ -142,17 +192,11 @@ describe('renderWorkerActivity', () => {
|
|
|
142
192
|
expect(out).toContain('a <b>x</b> & y')
|
|
143
193
|
})
|
|
144
194
|
|
|
145
|
-
it('escapes HTML in description
|
|
195
|
+
it('escapes HTML in description and summary', () => {
|
|
146
196
|
const out = renderWorkerActivity(
|
|
147
|
-
view({
|
|
148
|
-
description: 'a <b>bold</b> task',
|
|
149
|
-
lastTool: { name: 'Ba<sh', sanitisedArg: 'x & y' },
|
|
150
|
-
latestSummary: 'a > b',
|
|
151
|
-
}),
|
|
197
|
+
view({ description: 'a <b>bold</b> task', latestSummary: 'a > b' }),
|
|
152
198
|
)
|
|
153
199
|
expect(out).toContain('a <b>bold</b> task')
|
|
154
|
-
expect(out).toContain('Ba<sh')
|
|
155
|
-
expect(out).toContain('x & y')
|
|
156
200
|
expect(out).toContain('a > b')
|
|
157
201
|
})
|
|
158
202
|
})
|
|
@@ -206,7 +250,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
206
250
|
clock = 13_000 // +3000 since last edit > 2500
|
|
207
251
|
await feed.update('w1', 'chat', view({ toolCount: 3 }))
|
|
208
252
|
expect(bot.edits).toHaveLength(1)
|
|
209
|
-
expect(bot.edits[0].text).toContain('
|
|
253
|
+
expect(bot.edits[0].text).toContain('3 tools')
|
|
210
254
|
})
|
|
211
255
|
|
|
212
256
|
it('forces a terminal edit on finish, skipping the throttle', async () => {
|
|
@@ -219,7 +263,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
219
263
|
clock = 10_500 // well within the throttle window
|
|
220
264
|
await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
|
|
221
265
|
expect(bot.edits).toHaveLength(1)
|
|
222
|
-
expect(bot.edits[0].text).toContain('
|
|
266
|
+
expect(bot.edits[0].text).toContain('finished · completed · 5 tools')
|
|
223
267
|
// finish forgets the worker.
|
|
224
268
|
expect(feed.has('w1')).toBe(false)
|
|
225
269
|
expect(feed.size).toBe(0)
|
|
@@ -303,7 +347,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
303
347
|
|
|
304
348
|
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'read the brief' }))
|
|
305
349
|
expect(bot.sent).toHaveLength(1)
|
|
306
|
-
expect(bot.sent[0].text).toContain('
|
|
350
|
+
expect(bot.sent[0].text).toContain('<b>→ read the brief</b>')
|
|
307
351
|
|
|
308
352
|
clock = 11_000
|
|
309
353
|
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'scanned vendor A' }))
|
|
@@ -311,10 +355,10 @@ describe('createWorkerActivityFeed', () => {
|
|
|
311
355
|
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
|
|
312
356
|
|
|
313
357
|
const last = bot.edits.at(-1)!
|
|
314
|
-
expect(last.text).toContain('
|
|
315
|
-
expect(last.text).toContain('
|
|
316
|
-
expect(last.text).toContain('
|
|
317
|
-
expect(last.text.match(
|
|
358
|
+
expect(last.text).toContain('<i>✓ read the brief</i>')
|
|
359
|
+
expect(last.text).toContain('<i>✓ scanned vendor A</i>')
|
|
360
|
+
expect(last.text).toContain('<b>→ scanned vendor B</b>')
|
|
361
|
+
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(3)
|
|
318
362
|
})
|
|
319
363
|
|
|
320
364
|
it('dedups a repeated narrative line so the block does not duplicate', async () => {
|
|
@@ -329,7 +373,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
329
373
|
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'same line' }))
|
|
330
374
|
|
|
331
375
|
const last = bot.edits.at(-1)!
|
|
332
|
-
expect(last.text.match(
|
|
376
|
+
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(1)
|
|
333
377
|
})
|
|
334
378
|
|
|
335
379
|
it('caps the narrative block to the last 6 lines', async () => {
|
|
@@ -343,7 +387,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
343
387
|
}
|
|
344
388
|
|
|
345
389
|
const last = bot.edits.at(-1)!
|
|
346
|
-
expect(last.text.match(
|
|
390
|
+
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(6)
|
|
347
391
|
// Oldest lines evicted; newest retained.
|
|
348
392
|
expect(last.text).not.toContain('line 1')
|
|
349
393
|
expect(last.text).not.toContain('line 3')
|
|
@@ -368,9 +412,9 @@ describe('createWorkerActivityFeed', () => {
|
|
|
368
412
|
clock = 13_000
|
|
369
413
|
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
|
|
370
414
|
const last = bot.edits.at(-1)!
|
|
371
|
-
expect(last.text).toContain('
|
|
372
|
-
expect(last.text).toContain('
|
|
373
|
-
expect(last.text).toContain('
|
|
415
|
+
expect(last.text).toContain('<i>✓ line A</i>')
|
|
416
|
+
expect(last.text).toContain('<i>✓ line B</i>')
|
|
417
|
+
expect(last.text).toContain('<b>→ line C</b>')
|
|
374
418
|
})
|
|
375
419
|
|
|
376
420
|
it('forwards threadId as message_thread_id on send', async () => {
|
|
@@ -13,16 +13,18 @@ import type { Driver, ObservedMessage, ObservedReaction } from "./driver.js";
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Canonical shape of a worker-activity-feed message (#2000) as rendered
|
|
16
|
-
* in Telegram: a
|
|
17
|
-
* finalizes to
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* in Telegram: a native card with a `🛠 Worker · …` header that edits in
|
|
17
|
+
* place (running) and finalizes to a `finished · completed/failed · …`
|
|
18
|
+
* status line. The header is present in every state, so it's the primary
|
|
19
|
+
* key. The feed is default-on fleet-wide as of v0.14.19, so background
|
|
20
|
+
* sub-agent activity surfaces as its own bot message in any chat —
|
|
21
|
+
* including DMs whose scenario only cares about the agent's conversational
|
|
22
|
+
* reply.
|
|
21
23
|
*
|
|
22
24
|
* Single source of truth; the worker-feed scenario asserts against this,
|
|
23
25
|
* and recall/reply scenarios exclude it via {@link isWorkerFeedMessage}.
|
|
24
26
|
*/
|
|
25
|
-
export const WORKER_FEED_RE =
|
|
27
|
+
export const WORKER_FEED_RE = /🛠[️]?\s*Worker\b|finished\s*·\s*(?:completed|failed)/i;
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* True when `m` is a worker-activity-feed message rather than the agent's
|
|
@@ -13,17 +13,23 @@ const feed = (text: string) => ({ text }) as Parameters<typeof isWorkerFeedMessa
|
|
|
13
13
|
|
|
14
14
|
describe("isWorkerFeedMessage", () => {
|
|
15
15
|
it("matches the running feed header", () => {
|
|
16
|
-
expect(isWorkerFeedMessage(feed("
|
|
16
|
+
expect(isWorkerFeedMessage(feed("🛠 Worker · crawling changelog"))).toBe(true);
|
|
17
|
+
expect(
|
|
18
|
+
isWorkerFeedMessage(feed("🛠 Worker · crawling changelog\nrunning · 00:12 · 4 tools")),
|
|
19
|
+
).toBe(true);
|
|
17
20
|
});
|
|
18
21
|
|
|
19
|
-
it("matches the terminal
|
|
20
|
-
expect(
|
|
21
|
-
|
|
22
|
+
it("matches the terminal finished status (completed/failed)", () => {
|
|
23
|
+
expect(
|
|
24
|
+
isWorkerFeedMessage(feed("🛠 Worker · crawl\nfinished · completed · 10 tools · 01:03")),
|
|
25
|
+
).toBe(true);
|
|
26
|
+
expect(
|
|
27
|
+
isWorkerFeedMessage(feed("🛠 Worker · crawl\nfinished · failed · 3 tools · 00:08")),
|
|
28
|
+
).toBe(true);
|
|
22
29
|
});
|
|
23
30
|
|
|
24
|
-
it("matches
|
|
25
|
-
expect(isWorkerFeedMessage(feed("
|
|
26
|
-
expect(isWorkerFeedMessage(feed("Worker failed mid-step"))).toBe(true);
|
|
31
|
+
it("matches the finished status even on its own line (header-stripped edge)", () => {
|
|
32
|
+
expect(isWorkerFeedMessage(feed("finished · completed · 2 tools · 00:30"))).toBe(true);
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
it("does NOT match an ordinary agent reply", () => {
|
|
@@ -40,7 +46,7 @@ describe("isWorkerFeedMessage", () => {
|
|
|
40
46
|
});
|
|
41
47
|
|
|
42
48
|
it("exposes the regex for scenarios that assert on the feed directly", () => {
|
|
43
|
-
expect(WORKER_FEED_RE.test("
|
|
49
|
+
expect(WORKER_FEED_RE.test("🛠 Worker · x")).toBe(true);
|
|
44
50
|
});
|
|
45
51
|
});
|
|
46
52
|
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
* sleep/echo work, so it narrates between tools and the feed can paint
|
|
13
13
|
* + edit), then asserts:
|
|
14
14
|
*
|
|
15
|
-
* 1. a worker-feed message appears (
|
|
15
|
+
* 1. a worker-feed message appears (🛠 Worker · …), distinct from the
|
|
16
16
|
* parent's ack reply — proving background activity surfaces after
|
|
17
17
|
* the parent turn closed;
|
|
18
18
|
* 2. the message edits in place while work is in flight (body changes
|
|
19
19
|
* across a window) — proving it's live, not a one-shot post;
|
|
20
|
-
* 3. it finalizes to the terminal recap (
|
|
20
|
+
* 3. it finalizes to the terminal recap (finished · completed · N tools);
|
|
21
|
+
* 4. the native card never leaks raw Markdown (no `**`, backticks, or
|
|
22
|
+
* ASCII `---` rule) — the #94-class regression guard.
|
|
21
23
|
*
|
|
22
24
|
* It logs every observed body so a human can read the real rendered UX.
|
|
23
25
|
*
|
|
@@ -53,10 +55,11 @@ const BG_DISPATCH_PROMPT =
|
|
|
53
55
|
`brief reply saying you've kicked off the background worker so I can ` +
|
|
54
56
|
`watch its progress.`;
|
|
55
57
|
|
|
56
|
-
// The feed header rendered in Telegram: "
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
58
|
+
// The feed header rendered in Telegram: "🛠 Worker · <desc>" with a
|
|
59
|
+
// "running · …" status (running) or "finished · completed/failed · …"
|
|
60
|
+
// (terminal).
|
|
61
|
+
const WORKER_FEED_RE = /🛠\s*Worker|running\s*·|finished\s*·/i;
|
|
62
|
+
const WORKER_DONE_RE = /finished\s*·\s*(completed|failed)/i;
|
|
60
63
|
|
|
61
64
|
describe("uat: live worker-activity feed (#2000)", () => {
|
|
62
65
|
it(
|
|
@@ -116,6 +119,14 @@ describe("uat: live worker-activity feed (#2000)", () => {
|
|
|
116
119
|
expect(doneText!).toMatch(/tools?|tool ·/i);
|
|
117
120
|
// Did the body actually move between first paint and terminal?
|
|
118
121
|
expect(doneText).not.toBe(before);
|
|
122
|
+
// #94-class regression guard: the native card strips worker Markdown,
|
|
123
|
+
// so the rendered body must never carry raw `**`, backticks, or an
|
|
124
|
+
// ASCII `---` rule (the finished divider is the box-drawing `─────`).
|
|
125
|
+
expect(doneText!, "raw ** leaked into the card").not.toMatch(/\*\*/);
|
|
126
|
+
expect(doneText!, "raw backtick leaked into the card").not.toContain("`");
|
|
127
|
+
expect(doneText!, "raw --- rule leaked into the card").not.toMatch(
|
|
128
|
+
/(^|\n)\s*-{3,}\s*(\n|$)/,
|
|
129
|
+
);
|
|
119
130
|
} finally {
|
|
120
131
|
await sc.tearDown();
|
|
121
132
|
}
|
|
@@ -32,7 +32,13 @@
|
|
|
32
32
|
* the feed is fed from watcher callbacks rather than the bridge event stream.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
cleanWorkerResultParagraph,
|
|
37
|
+
escapeHtml,
|
|
38
|
+
formatDuration,
|
|
39
|
+
stripMarkdown,
|
|
40
|
+
truncate,
|
|
41
|
+
} from './card-format.js'
|
|
36
42
|
|
|
37
43
|
/** Worker-activity feed is ON by default; an operator opts out with
|
|
38
44
|
* SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
|
|
@@ -54,10 +60,11 @@ export interface WorkerActivityView {
|
|
|
54
60
|
latestSummary: string
|
|
55
61
|
/**
|
|
56
62
|
* Accumulated narrative lines, oldest→newest, already deduped + capped by
|
|
57
|
-
* the feed manager. When present and non-empty, the render grows a
|
|
58
|
-
*
|
|
59
|
-
* collapsing to the single `latestSummary`
|
|
60
|
-
* single-line fallback (back-compat for direct
|
|
63
|
+
* the feed manager. When present and non-empty, the render grows a `✓`/`→`
|
|
64
|
+
* step feed (prior steps done, newest in-progress — mirroring the main
|
|
65
|
+
* agent's activity card) instead of collapsing to the single `latestSummary`
|
|
66
|
+
* line. Absent/empty → the single-line fallback (back-compat for direct
|
|
67
|
+
* render callers).
|
|
61
68
|
*/
|
|
62
69
|
narrativeLines?: string[]
|
|
63
70
|
/** Wall-clock since dispatch, ms. */
|
|
@@ -80,8 +87,10 @@ export interface BotApiForWorkerFeed {
|
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
const DESC_MAX = 80
|
|
83
|
-
const
|
|
84
|
-
const
|
|
90
|
+
const STEP_MAX = 100
|
|
91
|
+
const RESULT_MAX = 320
|
|
92
|
+
/** Subtle horizontal rule between the running feed and the finished result. */
|
|
93
|
+
const RULE = '─────'
|
|
85
94
|
/**
|
|
86
95
|
* How many trailing narrative lines the live feed keeps visible. The feed
|
|
87
96
|
* grows like the main agent's answer but can't grow unbounded — Telegram
|
|
@@ -91,57 +100,83 @@ const SUMMARY_MAX = 100
|
|
|
91
100
|
const NARRATIVE_MAX_LINES = 6
|
|
92
101
|
|
|
93
102
|
/**
|
|
94
|
-
*
|
|
103
|
+
* Append the accumulated step feed to `lines`, mirroring the main agent's
|
|
104
|
+
* activity card (`renderActivityFeed`): prior steps render done (`✓`, italic),
|
|
105
|
+
* the newest renders in-progress (`→`, bold) unless `allDone`, and an overflow
|
|
106
|
+
* header (`✓ +N earlier…`) appears when the feed exceeds NARRATIVE_MAX_LINES.
|
|
107
|
+
* `steps` are already cleaned + escaped HTML.
|
|
108
|
+
*/
|
|
109
|
+
function appendStepFeed(lines: string[], steps: string[], allDone: boolean): void {
|
|
110
|
+
if (steps.length === 0) return
|
|
111
|
+
const shown = steps.slice(-NARRATIVE_MAX_LINES)
|
|
112
|
+
const hidden = steps.length - shown.length
|
|
113
|
+
if (hidden > 0) lines.push(`<i>✓ +${hidden} earlier…</i>`)
|
|
114
|
+
const lastIdx = shown.length - 1
|
|
115
|
+
shown.forEach((s, i) => {
|
|
116
|
+
lines.push(!allDone && i === lastIdx ? `<b>→ ${s}</b>` : `<i>✓ ${s}</i>`)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render the worker-activity message body as native Telegram HTML, matching
|
|
122
|
+
* the main agent's activity card (`renderActivityFeed` in
|
|
123
|
+
* tool-activity-summary.ts): a `🛠 Worker · <desc>` header, a one-line status,
|
|
124
|
+
* then a `✓`/`→` step feed. Worker narration is authored as Markdown, so every
|
|
125
|
+
* text fragment is run through `stripMarkdown` before escaping — without it the
|
|
126
|
+
* raw `**`/`` ` ``/`---` leak through as literal characters (the "half-done"
|
|
127
|
+
* look we're fixing).
|
|
95
128
|
*
|
|
96
129
|
* Layout (running):
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
130
|
+
* 🛠 <b>Worker</b> · <i>{description}</i>
|
|
131
|
+
* <i>running · {elapsed} · {n} tools</i>
|
|
132
|
+
* <i>✓ {earlier step}</i>
|
|
133
|
+
* <b>→ {newest step}</b>
|
|
100
134
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* <i>{n} tools · {elapsed}</i>
|
|
135
|
+
* Layout (finished): the feed renders all-done, then a rule + cleaned result:
|
|
136
|
+
* 🛠 <b>Worker</b> · <i>{description}</i>
|
|
137
|
+
* <i>finished · completed · {n} tools · {elapsed}</i>
|
|
138
|
+
* <i>✓ {step}</i>
|
|
139
|
+
* ─────
|
|
140
|
+
* ✅ <i>{cleaned result paragraph}</i>
|
|
104
141
|
*/
|
|
105
142
|
export function renderWorkerActivity(v: WorkerActivityView): string {
|
|
106
|
-
const desc = truncate(v.description.trim() || 'background task', DESC_MAX)
|
|
143
|
+
const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
|
|
107
144
|
const elapsed = formatDuration(v.elapsedMs)
|
|
108
145
|
const toolWord = v.toolCount === 1 ? 'tool' : 'tools'
|
|
146
|
+
const header = `🛠 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
|
|
147
|
+
const finished = v.state === 'done' || v.state === 'failed'
|
|
109
148
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
: `⚠️ <b>Worker failed</b> · <i>${escapeHtml(desc)}</i>`
|
|
115
|
-
return `${head}\n<i>${v.toolCount} ${toolWord} · ${elapsed}</i>`
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const header = `🔧 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
|
|
149
|
+
const steps = (v.narrativeLines ?? [])
|
|
150
|
+
.map((s) => stripMarkdown(s))
|
|
151
|
+
.filter((s) => s.length > 0)
|
|
152
|
+
.map((s) => escapeHtml(truncate(s, STEP_MAX)))
|
|
119
153
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
if (finished) {
|
|
155
|
+
const verb = v.state === 'done' ? 'completed' : 'failed'
|
|
156
|
+
const lines = [header, `<i>finished · ${verb} · ${v.toolCount} ${toolWord} · ${elapsed}</i>`]
|
|
157
|
+
appendStepFeed(lines, steps, true)
|
|
158
|
+
// On terminal, latestSummary carries the worker's final result text
|
|
159
|
+
// (gateway onFinish), distinct from the running narrative steps.
|
|
160
|
+
const result = cleanWorkerResultParagraph(v.latestSummary)
|
|
161
|
+
if (result.length > 0) {
|
|
162
|
+
const emoji = v.state === 'done' ? '✅' : '⚠️'
|
|
163
|
+
lines.push(RULE)
|
|
164
|
+
lines.push(`${emoji} <i>${escapeHtml(truncate(result, RESULT_MAX))}</i>`)
|
|
165
|
+
}
|
|
166
|
+
return lines.join('\n')
|
|
127
167
|
}
|
|
128
168
|
|
|
129
|
-
const lines = [header,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// reads like the main agent's live answer rather than a single replaced
|
|
133
|
-
// status line. Fall back to the single latestSummary line otherwise.
|
|
134
|
-
const narrative = (v.narrativeLines ?? [])
|
|
135
|
-
.map((s) => s.trim())
|
|
136
|
-
.filter((s) => s.length > 0)
|
|
137
|
-
if (narrative.length > 0) {
|
|
138
|
-
for (const line of narrative) {
|
|
139
|
-
lines.push(` ↳ <i>${escapeHtml(truncate(line, SUMMARY_MAX))}</i>`)
|
|
140
|
-
}
|
|
169
|
+
const lines = [header, `<i>running · ${elapsed} · ${v.toolCount} ${toolWord}</i>`]
|
|
170
|
+
if (steps.length > 0) {
|
|
171
|
+
appendStepFeed(lines, steps, false)
|
|
141
172
|
} else {
|
|
142
|
-
|
|
173
|
+
// Back-compat for direct render callers that pass only latestSummary;
|
|
174
|
+
// the manager always supplies narrativeLines.
|
|
175
|
+
const summary = stripMarkdown(v.latestSummary)
|
|
143
176
|
if (summary.length > 0) {
|
|
144
|
-
lines.push(
|
|
177
|
+
lines.push(`<b>→ ${escapeHtml(truncate(summary, STEP_MAX))}</b>`)
|
|
178
|
+
} else {
|
|
179
|
+
lines.push('<i>starting…</i>')
|
|
145
180
|
}
|
|
146
181
|
}
|
|
147
182
|
return lines.join('\n')
|
|
@@ -307,7 +342,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
307
342
|
// message is left at its last running render — stale but harmless.
|
|
308
343
|
return
|
|
309
344
|
}
|
|
310
|
-
const body = renderWorkerActivity(view)
|
|
345
|
+
const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative })
|
|
311
346
|
if (body === h.lastBody) return
|
|
312
347
|
try {
|
|
313
348
|
await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))
|