@aaroncql/pim-agent 0.0.1 → 0.2.0

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.
Files changed (84) hide show
  1. package/README.md +94 -66
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/apply-patch/coordinator.ts +49 -0
  6. package/src/extensions/apply-patch/executor.ts +566 -0
  7. package/src/extensions/apply-patch/index.ts +74 -0
  8. package/src/extensions/apply-patch/matcher.ts +66 -0
  9. package/src/extensions/apply-patch/model.ts +34 -0
  10. package/src/extensions/apply-patch/parser.ts +381 -0
  11. package/src/extensions/apply-patch/render.ts +261 -0
  12. package/src/extensions/apply-patch/schema.ts +43 -0
  13. package/src/extensions/apply-patch/types.ts +30 -0
  14. package/src/extensions/bash/index.ts +3 -3
  15. package/src/extensions/edit/index.ts +2 -1
  16. package/src/extensions/glob/index.ts +3 -1
  17. package/src/extensions/glob/schema.ts +2 -1
  18. package/src/extensions/grep/index.ts +3 -1
  19. package/src/extensions/grep/render.ts +18 -4
  20. package/src/extensions/grep/schema.ts +1 -1
  21. package/src/extensions/read/index.ts +36 -9
  22. package/src/extensions/read/render.ts +31 -3
  23. package/src/extensions/subagent/index.ts +4 -1
  24. package/src/extensions/todo/index.ts +4 -3
  25. package/src/extensions/web-search/index.ts +2 -1
  26. package/src/extensions/write/index.ts +2 -1
  27. package/src/shared/PatchSummary.ts +82 -0
  28. package/src/telegram/Renderer.ts +190 -4
  29. package/src/extensions/bash/capture.test.ts +0 -126
  30. package/src/extensions/bash/format.test.ts +0 -240
  31. package/src/extensions/bash/run.test.ts +0 -262
  32. package/src/extensions/command-picker/ranker.test.ts +0 -46
  33. package/src/extensions/edit/edit.test.ts +0 -285
  34. package/src/extensions/file-picker/catalog.test.ts +0 -263
  35. package/src/extensions/file-picker/index.test.ts +0 -168
  36. package/src/extensions/file-picker/ranker.test.ts +0 -94
  37. package/src/extensions/footer/git.test.ts +0 -76
  38. package/src/extensions/footer/index.test.ts +0 -161
  39. package/src/extensions/footer/segments.test.ts +0 -164
  40. package/src/extensions/glob/glob.test.ts +0 -171
  41. package/src/extensions/glob/index.test.ts +0 -68
  42. package/src/extensions/glob/render.test.ts +0 -126
  43. package/src/extensions/grep/grep.test.ts +0 -387
  44. package/src/extensions/grep/index.test.ts +0 -68
  45. package/src/extensions/grep/render.test.ts +0 -269
  46. package/src/extensions/read/read.test.ts +0 -177
  47. package/src/extensions/read/render.test.ts +0 -61
  48. package/src/extensions/subagent/index.test.ts +0 -44
  49. package/src/extensions/subagent/render.test.ts +0 -292
  50. package/src/extensions/subagent/subagent.test.ts +0 -315
  51. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  52. package/src/extensions/todo/index.test.ts +0 -244
  53. package/src/extensions/todo/render.test.ts +0 -180
  54. package/src/extensions/todo/todo.test.ts +0 -222
  55. package/src/extensions/tps/index.test.ts +0 -254
  56. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  57. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  58. package/src/extensions/web-fetch/render.test.ts +0 -56
  59. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  60. package/src/extensions/web-search/render.test.ts +0 -21
  61. package/src/extensions/web-search/search.test.ts +0 -53
  62. package/src/extensions/working-indicator/index.test.ts +0 -21
  63. package/src/extensions/write/render.test.ts +0 -64
  64. package/src/extensions/write/write.test.ts +0 -108
  65. package/src/shared/DiffLines.test.ts +0 -193
  66. package/src/shared/DiffRenderer.test.ts +0 -206
  67. package/src/shared/EditMatcher.test.ts +0 -123
  68. package/src/shared/FileScanner.test.ts +0 -158
  69. package/src/shared/FuzzyMatcher.test.ts +0 -114
  70. package/src/shared/GitignoreFilter.test.ts +0 -64
  71. package/src/shared/Lines.test.ts +0 -25
  72. package/src/shared/McpClient.test.ts +0 -235
  73. package/src/shared/OutputBudget.test.ts +0 -99
  74. package/src/shared/Paths.test.ts +0 -51
  75. package/src/shared/PimSettings.test.ts +0 -90
  76. package/src/shared/Renderer.test.ts +0 -190
  77. package/src/shared/SpillCache.test.ts +0 -94
  78. package/src/shared/Tools.test.ts +0 -392
  79. package/src/telegram/Config.test.ts +0 -275
  80. package/src/telegram/Markdown.test.ts +0 -143
  81. package/src/telegram/Renderer.test.ts +0 -216
  82. package/src/telegram/SessionRegistry.test.ts +0 -89
  83. package/src/telegram/TaskScheduler.test.ts +0 -278
  84. package/src/telegram/TaskTool.test.ts +0 -179
@@ -1,275 +0,0 @@
1
- import { chmod, mkdtemp, rm, stat } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
-
6
- import { Fs } from "../shared/Fs";
7
- import { Config, type TelegramConfig } from "./Config";
8
-
9
- const ENV_KEYS = [
10
- "PIM_TELEGRAM_BOT_TOKEN",
11
- "PIM_TELEGRAM_ALLOW",
12
- "PIM_HOME_DIR",
13
- ] as const;
14
-
15
- let savedEnv: Record<string, string | undefined>;
16
- let tmp: string;
17
-
18
- beforeEach(async () => {
19
- savedEnv = {};
20
- for (const k of ENV_KEYS) {
21
- savedEnv[k] = process.env[k];
22
- delete process.env[k];
23
- }
24
- tmp = await mkdtemp(join(tmpdir(), "pim-telegram-test-"));
25
- });
26
-
27
- afterEach(async () => {
28
- for (const k of ENV_KEYS) {
29
- if (savedEnv[k] === undefined) {
30
- delete process.env[k];
31
- } else {
32
- process.env[k] = savedEnv[k];
33
- }
34
- }
35
- await rm(tmp, { recursive: true, force: true });
36
- });
37
-
38
- describe("Config.parseArgs", () => {
39
- test("parses space-separated flags", () => {
40
- const cli = Config.parseArgs([
41
- "--mode",
42
- "telegram",
43
- "--token",
44
- "abc:xyz",
45
- "--allow",
46
- "111,222",
47
- "--cwd",
48
- "/work",
49
- "--model",
50
- "sonnet",
51
- "--config-dir",
52
- "/c",
53
- ]);
54
- expect(cli).toEqual({
55
- token: "abc:xyz",
56
- allow: "111,222",
57
- cwd: "/work",
58
- model: "sonnet",
59
- configDir: "/c",
60
- printConfig: false,
61
- });
62
- });
63
-
64
- test("parses --key=value form", () => {
65
- const cli = Config.parseArgs([
66
- "--mode=telegram",
67
- "--token=abc",
68
- "--allow=1",
69
- ]);
70
- expect(cli.token).toBe("abc");
71
- expect(cli.allow).toBe("1");
72
- });
73
-
74
- test("--print-config is a boolean flag", () => {
75
- const cli = Config.parseArgs(["--print-config", "--token", "t"]);
76
- expect(cli.printConfig).toBe(true);
77
- expect(cli.token).toBe("t");
78
- });
79
-
80
- test("ignores unknown positional args and unknown flags", () => {
81
- const cli = Config.parseArgs([
82
- "positional",
83
- "--unknown",
84
- "x",
85
- "--token",
86
- "t",
87
- ]);
88
- expect(cli.token).toBe("t");
89
- });
90
-
91
- test("consumes --mode value so it does not leak into the next flag", () => {
92
- const cli = Config.parseArgs(["--mode", "telegram", "--token", "t"]);
93
- expect(cli.token).toBe("t");
94
- });
95
- });
96
-
97
- describe("Config.load precedence", () => {
98
- test("CLI wins over env and file", async () => {
99
- process.env.PIM_TELEGRAM_BOT_TOKEN = "env-tok";
100
- process.env.PIM_TELEGRAM_ALLOW = "9";
101
- await Bun.write(
102
- join(tmp, "config.json"),
103
- JSON.stringify({ token: "file-tok", allow: [1] })
104
- );
105
- const cfg = await Config.load({
106
- token: "cli-tok",
107
- allow: "2,3",
108
- configDir: tmp,
109
- printConfig: false,
110
- });
111
- expect(cfg.token).toBe("cli-tok");
112
- expect(cfg.allow).toEqual([2, 3]);
113
- });
114
-
115
- test("env wins over file when no CLI value", async () => {
116
- process.env.PIM_TELEGRAM_BOT_TOKEN = "env-tok";
117
- process.env.PIM_TELEGRAM_ALLOW = "9";
118
- await Bun.write(
119
- join(tmp, "config.json"),
120
- JSON.stringify({ token: "file-tok", allow: [1] })
121
- );
122
- const cfg = await Config.load({
123
- configDir: tmp,
124
- printConfig: false,
125
- });
126
- expect(cfg.token).toBe("env-tok");
127
- expect(cfg.allow).toEqual([9]);
128
- });
129
-
130
- test("file values used when neither CLI nor env set", async () => {
131
- await Bun.write(
132
- join(tmp, "config.json"),
133
- JSON.stringify({ token: "file-tok", allow: [1, 2], cwd: "/from-file" })
134
- );
135
- const cfg = await Config.load({
136
- configDir: tmp,
137
- printConfig: false,
138
- });
139
- expect(cfg.token).toBe("file-tok");
140
- expect(cfg.allow).toEqual([1, 2]);
141
- expect(cfg.cwd).toBe("/from-file");
142
- });
143
-
144
- test("PIM_HOME_DIR resolves the config directory", async () => {
145
- process.env.PIM_HOME_DIR = tmp;
146
- process.env.PIM_TELEGRAM_BOT_TOKEN = "t";
147
- const cfg = await Config.load({ printConfig: false });
148
- expect(cfg.configDir).toBe(join(tmp, "telegram"));
149
- });
150
-
151
- test("throws when no token from any source", async () => {
152
- await expect(
153
- Config.load({ configDir: tmp, printConfig: false })
154
- ).rejects.toThrow(/token required/i);
155
- });
156
-
157
- test("missing config.json is not an error", async () => {
158
- const cfg = await Config.load({
159
- token: "t",
160
- configDir: tmp,
161
- printConfig: false,
162
- });
163
- expect(cfg.allow).toEqual([]);
164
- });
165
-
166
- test("rejects non-numeric allow entries", async () => {
167
- await expect(
168
- Config.load({
169
- token: "t",
170
- allow: "1,abc",
171
- configDir: tmp,
172
- printConfig: false,
173
- })
174
- ).rejects.toThrow(/Invalid chat ID/);
175
- });
176
-
177
- test("trims whitespace and drops empty entries in allow", async () => {
178
- const cfg = await Config.load({
179
- token: "t",
180
- allow: " 1 , ,2 ",
181
- configDir: tmp,
182
- printConfig: false,
183
- });
184
- expect(cfg.allow).toEqual([1, 2]);
185
- });
186
-
187
- test("accepts allow as a single number in config.json", async () => {
188
- await Bun.write(
189
- join(tmp, "config.json"),
190
- JSON.stringify({ token: "t", allow: 12345 })
191
- );
192
- const cfg = await Config.load({ configDir: tmp, printConfig: false });
193
- expect(cfg.allow).toEqual([12345]);
194
- });
195
-
196
- test("accepts allow as a comma-separated string in config.json", async () => {
197
- await Bun.write(
198
- join(tmp, "config.json"),
199
- JSON.stringify({ token: "t", allow: "1, 2 ,3" })
200
- );
201
- const cfg = await Config.load({ configDir: tmp, printConfig: false });
202
- expect(cfg.allow).toEqual([1, 2, 3]);
203
- });
204
-
205
- test("accepts allow as a mixed-type array in config.json", async () => {
206
- await Bun.write(
207
- join(tmp, "config.json"),
208
- JSON.stringify({ token: "t", allow: [1, "2", 3] })
209
- );
210
- const cfg = await Config.load({ configDir: tmp, printConfig: false });
211
- expect(cfg.allow).toEqual([1, 2, 3]);
212
- });
213
-
214
- test("malformed config.json surfaces a clear error", async () => {
215
- await Bun.write(join(tmp, "config.json"), "{not json");
216
- await expect(
217
- Config.load({ token: "t", configDir: tmp, printConfig: false })
218
- ).rejects.toThrow(/Failed to parse/);
219
- });
220
- });
221
-
222
- describe("Config.save + Fs.writeAtomic", () => {
223
- test("save writes a file readable by load", async () => {
224
- const cfg: TelegramConfig = {
225
- token: "tok",
226
- allow: [1, 2],
227
- cwd: "/work",
228
- model: "sonnet",
229
- configDir: tmp,
230
- };
231
- await Config.save(cfg);
232
- const reloaded = await Config.load({
233
- configDir: tmp,
234
- printConfig: false,
235
- });
236
- expect(reloaded.token).toBe("tok");
237
- expect(reloaded.allow).toEqual([1, 2]);
238
- expect(reloaded.cwd).toBe("/work");
239
- expect(reloaded.model).toBe("sonnet");
240
- });
241
-
242
- test("writeAtomic applies explicit mode", async () => {
243
- const path = join(tmp, "secret.json");
244
- await Fs.writeAtomic(path, "{}", 0o600);
245
- const s = await stat(path);
246
- expect(s.mode & 0o777).toBe(0o600);
247
- });
248
-
249
- test("writeAtomic preserves an existing file's mode when no mode is passed", async () => {
250
- const path = join(tmp, "existing.json");
251
- await Bun.write(path, "{}");
252
- await chmod(path, 0o640);
253
- await Fs.writeAtomic(path, "{}");
254
- const s = await stat(path);
255
- expect(s.mode & 0o777).toBe(0o640);
256
- });
257
-
258
- test("Config.save writes config.json as 0600", async () => {
259
- await Config.save({
260
- token: "tok",
261
- allow: [],
262
- cwd: "/work",
263
- configDir: tmp,
264
- });
265
- const s = await stat(join(tmp, "config.json"));
266
- expect(s.mode & 0o777).toBe(0o600);
267
- });
268
-
269
- test("writeAtomic leaves no temp files behind", async () => {
270
- await Fs.writeAtomic(join(tmp, "x.json"), "{}");
271
- const glob = new Bun.Glob("x.json.tmp-*");
272
- const leftovers = await Array.fromAsync(glob.scan({ cwd: tmp }));
273
- expect(leftovers).toEqual([]);
274
- });
275
- });
@@ -1,143 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import { Markdown } from "./Markdown";
4
-
5
- describe("toHtml", () => {
6
- test("escapes html-special characters in plain text", () => {
7
- expect(Markdown.toHtml("a < b & c > d")).toBe("a &lt; b &amp; c &gt; d");
8
- });
9
-
10
- test("bold + italic + strike + inline code", () => {
11
- expect(Markdown.toHtml("**b** *i* ~~s~~ `c`")).toBe(
12
- "<b>b</b> <i>i</i> <s>s</s> <code>c</code>"
13
- );
14
- });
15
-
16
- test("headings: h1 is underline+bold, others bold", () => {
17
- expect(Markdown.toHtml("# H1")).toBe("<u><b>H1</b></u>");
18
- expect(Markdown.toHtml("## H2")).toBe("<b>H2</b>");
19
- expect(Markdown.toHtml("### H3")).toBe("<b>H3</b>");
20
- });
21
-
22
- test("fenced code block with language", () => {
23
- expect(Markdown.toHtml("```ts\nconst x = 1;\n```")).toBe(
24
- '<pre><code class="language-ts">const x = 1;</code></pre>'
25
- );
26
- });
27
-
28
- test("fenced code block without language", () => {
29
- expect(Markdown.toHtml("```\nplain\n```")).toBe("<pre>plain</pre>");
30
- });
31
-
32
- test("escapes html inside code block", () => {
33
- expect(Markdown.toHtml("```\na <b> & c\n```")).toBe(
34
- "<pre>a &lt;b&gt; &amp; c</pre>"
35
- );
36
- });
37
-
38
- test("safe links pass through, javascript: dropped", () => {
39
- expect(Markdown.toHtml("[ok](https://example.com)")).toBe(
40
- '<a href="https://example.com">ok</a>'
41
- );
42
- expect(Markdown.toHtml("[bad](javascript:alert(1))")).toBe("bad");
43
- });
44
-
45
- test("images render as link to src", () => {
46
- expect(Markdown.toHtml("![alt](https://e.com/a.png)")).toBe(
47
- '<a href="https://e.com/a.png">alt</a>'
48
- );
49
- });
50
-
51
- test("blockquote wraps multi-line content", () => {
52
- expect(Markdown.toHtml("> a\n> b")).toBe("<blockquote>a\nb</blockquote>");
53
- });
54
-
55
- test("unordered list bullets and nested ◦", () => {
56
- const out = Markdown.toHtml("- one\n- two\n - nested\n - also");
57
- expect(out).toBe("• one\n• two\n ◦ nested\n ◦ also");
58
- });
59
-
60
- test("ordered list renders 1. 2. markers", () => {
61
- expect(Markdown.toHtml("1. a\n2. b\n3. c")).toBe("1. a\n2. b\n3. c");
62
- });
63
-
64
- test("task list checkboxes", () => {
65
- const out = Markdown.toHtml("- [x] done\n- [ ] todo");
66
- expect(out).toBe("✅ done\n⬜ todo");
67
- });
68
-
69
- test("thematic break renders em-dashes", () => {
70
- expect(Markdown.toHtml("a\n\n───\n\nb")).toBe("a\n\n───\n\nb");
71
- });
72
-
73
- test("collapses 3+ newlines to two", () => {
74
- expect(Markdown.toHtml("a\n\n\n\nb")).toBe("a\n\nb");
75
- });
76
-
77
- test("trims leading and trailing whitespace", () => {
78
- expect(Markdown.toHtml("\n\nhello\n\n")).toBe("hello");
79
- });
80
-
81
- test("table renders as vertical labeled cards", () => {
82
- const md = "| Name | Score |\n| ---- | ----- |\n| Aaron | 99 |\n| Bo | 7 |";
83
- expect(Markdown.toHtml(md)).toBe(
84
- "───\n<b>Name</b>: Aaron\n<b>Score</b>: 99\n───\n<b>Name</b>: Bo\n<b>Score</b>: 7\n───"
85
- );
86
- });
87
-
88
- test("table renders markdown formatting in cells", () => {
89
- const md = "| a | b |\n| - | - |\n| **x** | `y` |";
90
- const out = Markdown.toHtml(md);
91
- expect(out).toContain("<b>a</b>: <b>x</b>");
92
- expect(out).toContain("<b>b</b>: <code>y</code>");
93
- });
94
-
95
- test("table escapes html inside cells", () => {
96
- const md = "| a | b |\n| - | - |\n| <x> | & |";
97
- const out = Markdown.toHtml(md);
98
- expect(out).toContain("&lt;x&gt;");
99
- expect(out).toContain("&amp;");
100
- expect(out).not.toContain("<pre>");
101
- });
102
-
103
- test("table surrounded by prose is rendered with both segments", () => {
104
- const md = "Hello\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nDone.";
105
- const out = Markdown.toHtml(md);
106
- expect(out.startsWith("Hello")).toBe(true);
107
- expect(out).toContain("───");
108
- expect(out).toContain("<b>a</b>: 1");
109
- expect(out).toContain("<b>b</b>: 2");
110
- expect(out.endsWith("Done.")).toBe(true);
111
- });
112
-
113
- test("link inside emphasis nests correctly", () => {
114
- expect(Markdown.toHtml("*[a](https://e.com)*")).toBe(
115
- '<i><a href="https://e.com">a</a></i>'
116
- );
117
- });
118
-
119
- test("paragraphs are separated by blank line", () => {
120
- expect(Markdown.toHtml("one\n\ntwo")).toBe("one\n\ntwo");
121
- });
122
-
123
- test("inline html in source is escaped, never passed through", () => {
124
- expect(Markdown.toHtml("hello <script>alert(1)</script>")).toContain(
125
- "&lt;script&gt;"
126
- );
127
- });
128
-
129
- test("empty input returns empty string", () => {
130
- expect(Markdown.toHtml("")).toBe("");
131
- expect(Markdown.toHtml(" \n\n ")).toBe("");
132
- });
133
-
134
- test("escape covers all four Telegram-supported named entities", () => {
135
- expect(Markdown.escape('& < > "')).toBe("&amp; &lt; &gt; &quot;");
136
- });
137
-
138
- test('link href containing " is escaped to &quot;', () => {
139
- expect(Markdown.toHtml('[x](https://e.com/?q="a")')).toBe(
140
- '<a href="https://e.com/?q=&quot;a&quot;">x</a>'
141
- );
142
- });
143
- });
@@ -1,216 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
3
- import type { Api } from "grammy";
4
-
5
- import { Renderer } from "./Renderer";
6
- import type { Session } from "./Session";
7
-
8
- type SentMessage = {
9
- readonly chatId: number;
10
- readonly text: string;
11
- readonly options: unknown;
12
- };
13
-
14
- type EditedMessage = {
15
- readonly chatId: number;
16
- readonly messageId: number;
17
- readonly text: string;
18
- readonly options: unknown;
19
- };
20
-
21
- class FakeApi {
22
- public readonly sent: SentMessage[] = [];
23
- public readonly edited: EditedMessage[] = [];
24
-
25
- public async sendMessage(
26
- chatId: number,
27
- text: string,
28
- options: unknown
29
- ): Promise<{ readonly message_id: number }> {
30
- this.sent.push({ chatId, text, options });
31
- return { message_id: this.sent.length };
32
- }
33
-
34
- public async editMessageText(
35
- chatId: number,
36
- messageId: number,
37
- text: string,
38
- options: unknown
39
- ): Promise<void> {
40
- this.edited.push({ chatId, messageId, text, options });
41
- }
42
-
43
- public async sendChatAction(): Promise<void> {}
44
- }
45
-
46
- const session = {
47
- id: { chatId: 123, threadId: undefined },
48
- settings: { logsMode: "text" },
49
- } as unknown as Session;
50
-
51
- function makeRenderer(): {
52
- readonly api: FakeApi;
53
- readonly renderer: Renderer;
54
- } {
55
- const api = new FakeApi();
56
- return { api, renderer: new Renderer(session, api as unknown as Api) };
57
- }
58
-
59
- function todoStart(
60
- todos: readonly unknown[],
61
- toolCallId = "todo-1"
62
- ): AgentSessionEvent {
63
- return todoStartWithArgs({ todos }, toolCallId);
64
- }
65
-
66
- function todoStartWithArgs(
67
- args: unknown,
68
- toolCallId = "todo-1"
69
- ): AgentSessionEvent {
70
- return {
71
- type: "tool_execution_start",
72
- toolCallId,
73
- toolName: "todo",
74
- args,
75
- } as AgentSessionEvent;
76
- }
77
-
78
- function todoEnd(
79
- todos: readonly unknown[],
80
- toolCallId = "todo-1"
81
- ): AgentSessionEvent {
82
- return {
83
- type: "tool_execution_end",
84
- toolCallId,
85
- toolName: "todo",
86
- result: { content: [], details: { todos } },
87
- isError: false,
88
- } as AgentSessionEvent;
89
- }
90
-
91
- function assistantText(text: string): readonly AgentSessionEvent[] {
92
- return [
93
- { type: "message_start" } as AgentSessionEvent,
94
- {
95
- type: "message_update",
96
- assistantMessageEvent: { type: "text_delta", delta: text },
97
- } as AgentSessionEvent,
98
- {
99
- type: "message_update",
100
- assistantMessageEvent: { type: "text_end", content: text },
101
- } as AgentSessionEvent,
102
- {
103
- type: "message_end",
104
- message: { role: "assistant", stopReason: "toolUse" },
105
- } as AgentSessionEvent,
106
- ];
107
- }
108
-
109
- async function flush(renderer: Renderer): Promise<void> {
110
- await (
111
- renderer as unknown as {
112
- readonly flushEdit: (state: "running") => Promise<void>;
113
- }
114
- ).flushEdit("running");
115
- }
116
-
117
- describe("Telegram Renderer todo status", () => {
118
- test("renders the latest in-progress todo in bold", async () => {
119
- const { api, renderer } = makeRenderer();
120
-
121
- renderer.handleEvent(
122
- todoStart([
123
- { content: "First task", status: "in_progress" },
124
- { content: "Second <task> & verify", status: "in_progress" },
125
- ])
126
- );
127
- await renderer.finish("", "ok");
128
-
129
- expect(api.sent.map((msg) => msg.text)).toEqual([
130
- "📋 <b>Second &lt;task&gt; &amp; verify</b>",
131
- ]);
132
- });
133
-
134
- test("keeps todo entries in event order instead of replacing the prior one", async () => {
135
- const { api, renderer } = makeRenderer();
136
-
137
- renderer.handleEvent(
138
- todoStart([{ content: "Remember to buy milk", status: "in_progress" }])
139
- );
140
- await flush(renderer);
141
-
142
- for (const event of assistantText(
143
- "First item is in progress. Now let me finish it and start the next one:"
144
- )) {
145
- renderer.handleEvent(event);
146
- }
147
- renderer.handleEvent(
148
- todoStart(
149
- [
150
- { content: "Remember to buy milk", status: "completed" },
151
- { content: "Remember to get water", status: "in_progress" },
152
- ],
153
- "todo-2"
154
- )
155
- );
156
- await renderer.finish("", "ok");
157
-
158
- expect(api.sent.map((msg) => msg.text)).toEqual([
159
- "📋 <b>Remember to buy milk</b>",
160
- ]);
161
- expect(api.edited.map((msg) => msg.text)).toEqual([
162
- [
163
- "📋 <b>Remember to buy milk</b>",
164
- "",
165
- "First item is in progress. Now let me finish it and start the next one:",
166
- "",
167
- "📋 <b>Remember to get water</b>",
168
- ].join("\n"),
169
- ]);
170
- });
171
-
172
- test("does not render todo calls with no in-progress item", async () => {
173
- const { api, renderer } = makeRenderer();
174
-
175
- renderer.handleEvent(
176
- todoStart([
177
- { content: "Plan", status: "pending" },
178
- { content: "Done", status: "completed" },
179
- ])
180
- );
181
- await renderer.finish("", "ok");
182
-
183
- expect(api.sent).toEqual([]);
184
- expect(api.edited).toEqual([]);
185
- });
186
-
187
- test("ignores malformed todo args", async () => {
188
- const { api, renderer } = makeRenderer();
189
-
190
- expect(() =>
191
- renderer.handleEvent(todoStartWithArgs({ text: "done" }))
192
- ).not.toThrow();
193
- await renderer.finish("", "ok");
194
-
195
- expect(api.sent).toEqual([]);
196
- expect(api.edited).toEqual([]);
197
- });
198
-
199
- test("does not emit a new todo entry when no item remains in progress", async () => {
200
- const { api, renderer } = makeRenderer();
201
-
202
- renderer.handleEvent(
203
- todoStart([{ content: "Build feature", status: "in_progress" }])
204
- );
205
- await flush(renderer);
206
- renderer.handleEvent(
207
- todoEnd([{ content: "Build feature", status: "completed" }])
208
- );
209
- await flush(renderer);
210
-
211
- expect(api.sent.map((msg) => msg.text)).toEqual([
212
- "📋 <b>Build feature</b>",
213
- ]);
214
- expect(api.edited).toEqual([]);
215
- });
216
- });