@aaroncql/pim-agent 0.0.1 → 0.1.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 (60) hide show
  1. package/README.md +19 -8
  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/bash/capture.test.ts +0 -126
  6. package/src/extensions/bash/format.test.ts +0 -240
  7. package/src/extensions/bash/run.test.ts +0 -262
  8. package/src/extensions/command-picker/ranker.test.ts +0 -46
  9. package/src/extensions/edit/edit.test.ts +0 -285
  10. package/src/extensions/file-picker/catalog.test.ts +0 -263
  11. package/src/extensions/file-picker/index.test.ts +0 -168
  12. package/src/extensions/file-picker/ranker.test.ts +0 -94
  13. package/src/extensions/footer/git.test.ts +0 -76
  14. package/src/extensions/footer/index.test.ts +0 -161
  15. package/src/extensions/footer/segments.test.ts +0 -164
  16. package/src/extensions/glob/glob.test.ts +0 -171
  17. package/src/extensions/glob/index.test.ts +0 -68
  18. package/src/extensions/glob/render.test.ts +0 -126
  19. package/src/extensions/grep/grep.test.ts +0 -387
  20. package/src/extensions/grep/index.test.ts +0 -68
  21. package/src/extensions/grep/render.test.ts +0 -269
  22. package/src/extensions/read/read.test.ts +0 -177
  23. package/src/extensions/read/render.test.ts +0 -61
  24. package/src/extensions/subagent/index.test.ts +0 -44
  25. package/src/extensions/subagent/render.test.ts +0 -292
  26. package/src/extensions/subagent/subagent.test.ts +0 -315
  27. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  28. package/src/extensions/todo/index.test.ts +0 -244
  29. package/src/extensions/todo/render.test.ts +0 -180
  30. package/src/extensions/todo/todo.test.ts +0 -222
  31. package/src/extensions/tps/index.test.ts +0 -254
  32. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  33. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  34. package/src/extensions/web-fetch/render.test.ts +0 -56
  35. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  36. package/src/extensions/web-search/render.test.ts +0 -21
  37. package/src/extensions/web-search/search.test.ts +0 -53
  38. package/src/extensions/working-indicator/index.test.ts +0 -21
  39. package/src/extensions/write/render.test.ts +0 -64
  40. package/src/extensions/write/write.test.ts +0 -108
  41. package/src/shared/DiffLines.test.ts +0 -193
  42. package/src/shared/DiffRenderer.test.ts +0 -206
  43. package/src/shared/EditMatcher.test.ts +0 -123
  44. package/src/shared/FileScanner.test.ts +0 -158
  45. package/src/shared/FuzzyMatcher.test.ts +0 -114
  46. package/src/shared/GitignoreFilter.test.ts +0 -64
  47. package/src/shared/Lines.test.ts +0 -25
  48. package/src/shared/McpClient.test.ts +0 -235
  49. package/src/shared/OutputBudget.test.ts +0 -99
  50. package/src/shared/Paths.test.ts +0 -51
  51. package/src/shared/PimSettings.test.ts +0 -90
  52. package/src/shared/Renderer.test.ts +0 -190
  53. package/src/shared/SpillCache.test.ts +0 -94
  54. package/src/shared/Tools.test.ts +0 -392
  55. package/src/telegram/Config.test.ts +0 -275
  56. package/src/telegram/Markdown.test.ts +0 -143
  57. package/src/telegram/Renderer.test.ts +0 -216
  58. package/src/telegram/SessionRegistry.test.ts +0 -89
  59. package/src/telegram/TaskScheduler.test.ts +0 -278
  60. 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
- });