@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,244 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type {
3
- ExtensionAPI,
4
- ExtensionContext,
5
- Theme,
6
- } from "@earendil-works/pi-coding-agent";
7
- import type { TodoItem } from "./schema";
8
- import registerTodo from "./index";
9
- import { getCurrentItems } from "./todo";
10
-
11
- type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
12
- type RegisteredTool = {
13
- readonly executionMode?: string;
14
- readonly execute: (...args: readonly unknown[]) => unknown;
15
- };
16
- type AppendedEntry = {
17
- readonly customType: string;
18
- readonly data: unknown;
19
- };
20
- type SentMessage = {
21
- readonly message: unknown;
22
- readonly options: unknown;
23
- };
24
- type MockPi = {
25
- readonly api: ExtensionAPI;
26
- readonly handlers: Map<string, Handler[]>;
27
- readonly tools: RegisteredTool[];
28
- readonly appendedEntries: AppendedEntry[];
29
- readonly sentMessages: SentMessage[];
30
- };
31
- type WidgetUpdate = {
32
- readonly id: string;
33
- readonly lines: readonly string[] | undefined;
34
- };
35
-
36
- type MockContext = ExtensionContext & {
37
- readonly widgetUpdates: WidgetUpdate[];
38
- };
39
-
40
- const stubTheme = {
41
- fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
42
- bold: (text: string) => `**${text}**`,
43
- strikethrough: (text: string) => `~~${text}~~`,
44
- } as unknown as Theme;
45
-
46
- describe("todo extension", () => {
47
- test("clears an all-done widget on the next user input", async () => {
48
- const pi = createPi();
49
- const ctx = createContext();
50
- registerTodo(pi.api);
51
-
52
- await setTodos(pi, ctx, [
53
- { content: "Ship it", status: "completed" },
54
- { content: "Skip obsolete", status: "cancelled" },
55
- ]);
56
- await emit(pi, "turn_end", { type: "turn_end" }, ctx);
57
- await flush();
58
-
59
- expect(ctx.widgetUpdates.at(-1)?.lines).toEqual([
60
- "**2 todos** (1 done, 1 cancelled)",
61
- "<success>✔</success> <muted>Ship it</muted>",
62
- "<muted>✘</muted> <muted>~~Skip obsolete~~</muted>",
63
- ]);
64
-
65
- const results = await emit(pi, "input", { type: "input" }, ctx);
66
-
67
- expect(results).toEqual([{ action: "continue" }]);
68
- expect(getCurrentItems(ctx.sessionManager)).toEqual([]);
69
- expect(ctx.widgetUpdates.at(-1)).toEqual({
70
- id: "pim-todo",
71
- lines: undefined,
72
- });
73
- });
74
-
75
- test("keeps the widget when any todo is still active", async () => {
76
- const pi = createPi();
77
- const ctx = createContext();
78
- registerTodo(pi.api);
79
-
80
- const todos: readonly TodoItem[] = [
81
- { content: "Done", status: "completed" },
82
- { content: "Next", status: "pending" },
83
- ];
84
- await setTodos(pi, ctx, todos);
85
- await emit(pi, "turn_end", { type: "turn_end" }, ctx);
86
- await flush();
87
- const updatesBeforeInput = ctx.widgetUpdates.length;
88
-
89
- const results = await emit(pi, "input", { type: "input" }, ctx);
90
-
91
- expect(results).toEqual([{ action: "continue" }]);
92
- expect(getCurrentItems(ctx.sessionManager)).toEqual(todos);
93
- expect(ctx.widgetUpdates).toHaveLength(updatesBeforeInput);
94
- });
95
-
96
- test("checkpoints todo state on session_compact so the widget survives a reload", async () => {
97
- const pi = createPi();
98
- const ctx = createContext();
99
- registerTodo(pi.api);
100
-
101
- const todos: readonly TodoItem[] = [
102
- { content: "Ship", status: "in_progress" },
103
- { content: "Verify", status: "pending" },
104
- ];
105
- await setTodos(pi, ctx, todos);
106
- await emit(pi, "session_compact", { type: "session_compact" }, ctx);
107
-
108
- expect(pi.appendedEntries).toEqual([
109
- { customType: "pim-todo-state", data: { todos } },
110
- ]);
111
- expect(pi.sentMessages).toEqual([
112
- {
113
- message: {
114
- customType: "pim-todo-snapshot",
115
- content: "Current todo list:\n[>] Ship\n[ ] Verify",
116
- display: false,
117
- },
118
- options: { triggerTurn: false },
119
- },
120
- ]);
121
- });
122
-
123
- test("session_compact is a no-op when there are no items", async () => {
124
- const pi = createPi();
125
- const ctx = createContext();
126
- registerTodo(pi.api);
127
-
128
- await emit(pi, "session_compact", { type: "session_compact" }, ctx);
129
-
130
- expect(pi.appendedEntries).toEqual([]);
131
- expect(pi.sentMessages).toEqual([]);
132
- });
133
-
134
- test("subagent ctx mutating todos does not leak into the parent ctx", async () => {
135
- const pi = createPi();
136
- const parent = createContext();
137
- const child = createContext();
138
- registerTodo(pi.api);
139
-
140
- await setTodos(pi, parent, [{ content: "parent", status: "pending" }]);
141
- await setTodos(pi, child, [{ content: "child", status: "in_progress" }]);
142
-
143
- expect(getCurrentItems(parent.sessionManager)).toEqual([
144
- { content: "parent", status: "pending" },
145
- ]);
146
- expect(getCurrentItems(child.sessionManager)).toEqual([
147
- { content: "child", status: "in_progress" },
148
- ]);
149
- });
150
-
151
- test("todo tool executes sequentially and returns a compact update summary", async () => {
152
- const pi = createPi();
153
- const ctx = createContext();
154
- registerTodo(pi.api);
155
-
156
- expect(pi.tools[0]?.executionMode).toBe("sequential");
157
- expect(await setTodos(pi, ctx, [])).toEqual({
158
- content: [
159
- {
160
- type: "text",
161
- text: "Todos cleared.",
162
- },
163
- ],
164
- details: {
165
- todos: [],
166
- summary: {
167
- pending: 0,
168
- in_progress: 0,
169
- completed: 0,
170
- cancelled: 0,
171
- },
172
- },
173
- });
174
- });
175
- });
176
-
177
- function createPi(): MockPi {
178
- const handlers = new Map<string, Handler[]>();
179
- const tools: RegisteredTool[] = [];
180
- const appendedEntries: AppendedEntry[] = [];
181
- const sentMessages: SentMessage[] = [];
182
- const api = {
183
- on(event: string, handler: Handler): void {
184
- const existing = handlers.get(event) ?? [];
185
- existing.push(handler);
186
- handlers.set(event, existing);
187
- },
188
- registerTool(tool: RegisteredTool): void {
189
- tools.push(tool);
190
- },
191
- appendEntry(customType: string, data: unknown): void {
192
- appendedEntries.push({ customType, data });
193
- },
194
- sendMessage(message: unknown, options: unknown): void {
195
- sentMessages.push({ message, options });
196
- },
197
- } as unknown as ExtensionAPI;
198
-
199
- return { api, handlers, tools, appendedEntries, sentMessages };
200
- }
201
-
202
- function createContext(): MockContext {
203
- const widgetUpdates: WidgetUpdate[] = [];
204
- return {
205
- hasUI: true,
206
- sessionManager: {},
207
- ui: {
208
- theme: stubTheme,
209
- setWidget(id: string, lines: readonly string[] | undefined): void {
210
- widgetUpdates.push({ id, lines });
211
- },
212
- },
213
- widgetUpdates,
214
- } as unknown as MockContext;
215
- }
216
-
217
- async function emit(
218
- pi: MockPi,
219
- event: string,
220
- payload: unknown,
221
- ctx: ExtensionContext
222
- ): Promise<unknown[]> {
223
- const results: unknown[] = [];
224
- for (const handler of pi.handlers.get(event) ?? []) {
225
- results.push(await handler(payload, ctx));
226
- }
227
- return results;
228
- }
229
-
230
- function flush(): Promise<void> {
231
- return new Promise((resolve) => setImmediate(resolve));
232
- }
233
-
234
- async function setTodos(
235
- pi: MockPi,
236
- ctx: ExtensionContext,
237
- todos: readonly TodoItem[]
238
- ): Promise<unknown> {
239
- const tool = pi.tools[0];
240
- if (!tool) {
241
- throw new Error("todo tool was not registered");
242
- }
243
- return await tool.execute("todo-call", { todos }, undefined, undefined, ctx);
244
- }
@@ -1,180 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type {
3
- AgentToolResult,
4
- Theme,
5
- ToolRenderResultOptions,
6
- } from "@earendil-works/pi-coding-agent";
7
- import type { TodoItem } from "./schema";
8
- import {
9
- formatCallTitle,
10
- formatWidgetTitle,
11
- renderCall,
12
- renderResult,
13
- renderWidgetLines,
14
- } from "./render";
15
- import { makeDetails } from "./todo";
16
-
17
- const items: readonly TodoItem[] = [
18
- { content: "Plan", status: "pending" },
19
- { content: "Build", status: "in_progress" },
20
- { content: "Verify", status: "completed" },
21
- { content: "Skip", status: "cancelled" },
22
- ];
23
-
24
- const stubTheme = {
25
- fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
26
- bold: (text: string) => `**${text}**`,
27
- strikethrough: (text: string) => `~~${text}~~`,
28
- } as unknown as Theme;
29
-
30
- const expandedOptions = {
31
- expanded: true,
32
- isPartial: false,
33
- } as ToolRenderResultOptions;
34
- const context = {
35
- lastComponent: undefined,
36
- isPartial: false,
37
- isError: false,
38
- };
39
-
40
- describe("todo render", () => {
41
- test("renderCall shows only the compact status summary", () => {
42
- expect(formatCallTitle(items)).toBe("1 done, 2 pending, 1 cancelled");
43
- const rendered = renderCall({ todos: items }, stubTheme, context).render(
44
- 120
45
- )[0];
46
- expect(rendered).toContain("**Todo**");
47
- expect(rendered).toContain("1 done, 2 pending, 1 cancelled");
48
- });
49
-
50
- test("renderCall shows cleared when the todo list is empty", () => {
51
- expect(formatCallTitle([])).toBe("cleared");
52
- const rendered = renderCall({ todos: [] }, stubTheme, context).render(
53
- 120
54
- )[0];
55
-
56
- expect(rendered).toContain("**Todo**");
57
- expect(rendered).toContain(": cleared");
58
- });
59
-
60
- test("renderResult is hidden so the widget is the only TUI checklist", () => {
61
- expect(
62
- renderResult(
63
- toolResult(items),
64
- expandedOptions,
65
- stubTheme,
66
- context
67
- ).render(120)
68
- ).toEqual([]);
69
- });
70
-
71
- test("widget title bolds total and wraps status summary", () => {
72
- const pendingItems: readonly TodoItem[] = [
73
- { content: "One", status: "pending" },
74
- { content: "Two", status: "pending" },
75
- { content: "Three", status: "pending" },
76
- { content: "Four", status: "pending" },
77
- ];
78
-
79
- expect(formatWidgetTitle(pendingItems, stubTheme)).toBe(
80
- "**4 todos** (4 pending)"
81
- );
82
- });
83
-
84
- test("widget colours only status markers", () => {
85
- const lines = renderWidgetLines(items, stubTheme);
86
-
87
- expect(formatWidgetTitle(items, stubTheme)).toBe(
88
- "**4 todos** (1 done, 2 pending, 1 cancelled)"
89
- );
90
- expect(lines).toEqual([
91
- "**4 todos** (1 done, 2 pending, 1 cancelled)",
92
- "□ Plan",
93
- "<warning>➤</warning> **Build**",
94
- "<success>✔</success> <muted>Verify</muted>",
95
- "<muted>✘</muted> <muted>~~Skip~~</muted>",
96
- ]);
97
- });
98
-
99
- test("widget shows all rows instead of trading one todo for a +1 hint", () => {
100
- const many = makePendingItems(6);
101
-
102
- const lines = renderWidgetLines(many, stubTheme);
103
-
104
- expect(lines).toHaveLength(7);
105
- expect(lines.slice(1)).toEqual([
106
- "□ Task 1",
107
- "□ Task 2",
108
- "□ Task 3",
109
- "□ Task 4",
110
- "□ Task 5",
111
- "□ Task 6",
112
- ]);
113
- });
114
-
115
- test("widget caps rows with a muted hidden-count hint", () => {
116
- const many = makePendingItems(10);
117
-
118
- const lines = renderWidgetLines(many, stubTheme);
119
-
120
- expect(lines).toHaveLength(7);
121
- expect(lines.slice(1, -1)).toEqual([
122
- "□ Task 1",
123
- "□ Task 2",
124
- "□ Task 3",
125
- "□ Task 4",
126
- "□ Task 5",
127
- ]);
128
- expect(lines.at(-1)).toBe("<muted>… +5 more</muted>");
129
- });
130
-
131
- test("widget centers the visible rows around the in-progress item", () => {
132
- const many = makePendingItems(50, { index: 24, status: "in_progress" });
133
-
134
- const lines = renderWidgetLines(many, stubTheme);
135
-
136
- expect(lines).toHaveLength(7);
137
- expect(lines.slice(1, -1)).toEqual([
138
- "□ Task 23",
139
- "□ Task 24",
140
- "<warning>➤</warning> **Task 25**",
141
- "□ Task 26",
142
- "□ Task 27",
143
- ]);
144
- expect(lines.at(-1)).toBe("<muted>… +45 more</muted>");
145
- });
146
-
147
- test("widget falls back to the last non-pending item when none are in progress", () => {
148
- const many = makePendingItems(12, { index: 6, status: "completed" });
149
-
150
- const lines = renderWidgetLines(many, stubTheme);
151
-
152
- expect(lines.slice(1, -1)).toEqual([
153
- "□ Task 5",
154
- "□ Task 6",
155
- "<success>✔</success> <muted>Task 7</muted>",
156
- "□ Task 8",
157
- "□ Task 9",
158
- ]);
159
- expect(lines.at(-1)).toBe("<muted>… +7 more</muted>");
160
- });
161
- });
162
-
163
- function toolResult(
164
- items: readonly TodoItem[]
165
- ): AgentToolResult<ReturnType<typeof makeDetails>> {
166
- return {
167
- content: [{ type: "text", text: "" }],
168
- details: makeDetails(items),
169
- };
170
- }
171
-
172
- function makePendingItems(
173
- count: number,
174
- override?: { readonly index: number; readonly status: TodoItem["status"] }
175
- ): readonly TodoItem[] {
176
- return Array.from({ length: count }, (_, index) => ({
177
- content: `Task ${index + 1}`,
178
- status: override?.index === index ? override.status : "pending",
179
- }));
180
- }
@@ -1,222 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { TodoItem } from "./schema";
3
- import {
4
- formatChecklist,
5
- formatUpdateSummary,
6
- getCurrentItems,
7
- hasActiveItems,
8
- makeDetails,
9
- normalizeItems,
10
- reconstructFromBranch,
11
- replaceItems,
12
- summarizeItems,
13
- type TodoSessionKey,
14
- } from "./todo";
15
-
16
- const allStatuses: readonly TodoItem[] = [
17
- { content: "Plan", status: "pending" },
18
- { content: "Build", status: "in_progress" },
19
- { content: "Verify", status: "completed" },
20
- { content: "Skip obsolete step", status: "cancelled" },
21
- ];
22
-
23
- function fakeSession(): TodoSessionKey {
24
- return {} as TodoSessionKey;
25
- }
26
-
27
- describe("todo state", () => {
28
- test("replace semantics keep the latest write only", () => {
29
- const sm = fakeSession();
30
- replaceItems(sm, [
31
- { content: "a", status: "pending" },
32
- { content: "b", status: "pending" },
33
- { content: "c", status: "pending" },
34
- ]);
35
- const latest = replaceItems(sm, [{ content: "d", status: "in_progress" }]);
36
-
37
- expect(latest).toEqual([{ content: "d", status: "in_progress" }]);
38
- expect(formatChecklist(getCurrentItems(sm))).toBe("[>] d");
39
- });
40
-
41
- test("state is isolated between sessions (parent vs subagent)", () => {
42
- const parent = fakeSession();
43
- const child = fakeSession();
44
- replaceItems(parent, [{ content: "parent task", status: "pending" }]);
45
- replaceItems(child, [{ content: "child task", status: "in_progress" }]);
46
-
47
- expect(getCurrentItems(parent)).toEqual([
48
- { content: "parent task", status: "pending" },
49
- ]);
50
- expect(getCurrentItems(child)).toEqual([
51
- { content: "child task", status: "in_progress" },
52
- ]);
53
- });
54
-
55
- test("content is normalized to a single trimmed line and blank content is dropped", () => {
56
- expect(
57
- normalizeItems([
58
- { content: "", status: "pending" },
59
- { content: " ", status: "in_progress" },
60
- { content: " keep\nthis\titem ", status: "completed" },
61
- ])
62
- ).toEqual([{ content: "keep this item", status: "completed" }]);
63
- });
64
-
65
- test("multiple in_progress items are accepted as-is", () => {
66
- const items = normalizeItems([
67
- { content: "one", status: "in_progress" },
68
- { content: "two", status: "in_progress" },
69
- ]);
70
-
71
- expect(formatChecklist(items)).toBe("[>] one\n[>] two");
72
- });
73
-
74
- test("duplicate content strings are accepted", () => {
75
- const items = normalizeItems([
76
- { content: "repeat", status: "pending" },
77
- { content: "repeat", status: "completed" },
78
- ]);
79
-
80
- expect(items).toEqual([
81
- { content: "repeat", status: "pending" },
82
- { content: "repeat", status: "completed" },
83
- ]);
84
- });
85
-
86
- test("active-only checklist drops completed and cancelled", () => {
87
- expect(formatChecklist(allStatuses, { activeOnly: true })).toBe(
88
- "[ ] Plan\n[>] Build"
89
- );
90
- expect(
91
- formatChecklist(
92
- [
93
- { content: "done", status: "completed" },
94
- { content: "skipped", status: "cancelled" },
95
- ],
96
- { activeOnly: true }
97
- )
98
- ).toBe("");
99
- });
100
-
101
- test("active item detection treats pending and in-progress as active", () => {
102
- expect(hasActiveItems(allStatuses)).toBe(true);
103
- expect(
104
- hasActiveItems([
105
- { content: "done", status: "completed" },
106
- { content: "skipped", status: "cancelled" },
107
- ])
108
- ).toBe(false);
109
- });
110
-
111
- test("full checklist includes all marker styles", () => {
112
- expect(formatChecklist(allStatuses)).toBe(
113
- ["[ ] Plan", "[>] Build", "[x] Verify", "[~] Skip obsolete step"].join(
114
- "\n"
115
- )
116
- );
117
- });
118
-
119
- test("reconstruction finds the most recent todo tool result", () => {
120
- const branch = [
121
- toolResult("todo", [{ content: "old", status: "pending" }]),
122
- toolResult("grep", [{ content: "ignored", status: "completed" }]),
123
- toolResult("todo", [{ content: "new", status: "in_progress" }]),
124
- ];
125
-
126
- expect(reconstructFromBranch(fakeSession(), branch)).toEqual([
127
- { content: "new", status: "in_progress" },
128
- ]);
129
- });
130
-
131
- test("reconstruction restores from a pim-todo-state checkpoint after compaction", () => {
132
- const branch = [
133
- { type: "compaction", summary: "old todos summarized away" },
134
- todoStateEntry([{ content: "kept", status: "in_progress" }]),
135
- ];
136
-
137
- expect(reconstructFromBranch(fakeSession(), branch)).toEqual([
138
- { content: "kept", status: "in_progress" },
139
- ]);
140
- });
141
-
142
- test("reconstruction prefers a later tool result over an older checkpoint", () => {
143
- const branch = [
144
- todoStateEntry([{ content: "old", status: "pending" }]),
145
- toolResult("todo", [{ content: "new", status: "completed" }]),
146
- ];
147
-
148
- expect(reconstructFromBranch(fakeSession(), branch)).toEqual([
149
- { content: "new", status: "completed" },
150
- ]);
151
- });
152
-
153
- test("reconstruction prefers a later checkpoint over an older tool result", () => {
154
- const branch = [
155
- toolResult("todo", [{ content: "old", status: "pending" }]),
156
- todoStateEntry([{ content: "checkpointed", status: "in_progress" }]),
157
- ];
158
-
159
- expect(reconstructFromBranch(fakeSession(), branch)).toEqual([
160
- { content: "checkpointed", status: "in_progress" },
161
- ]);
162
- });
163
-
164
- test("update summary formats model-visible acknowledgement", () => {
165
- expect(
166
- formatUpdateSummary([
167
- { content: "one", status: "completed" },
168
- { content: "two", status: "completed" },
169
- { content: "three", status: "in_progress" },
170
- { content: "four", status: "pending" },
171
- { content: "five", status: "pending" },
172
- ])
173
- ).toBe("Todos updated: 2 completed, 1 in progress, 2 pending.");
174
- });
175
-
176
- test("update summary omits zero counts and includes cancelled only when nonzero", () => {
177
- expect(formatUpdateSummary(allStatuses)).toBe(
178
- "Todos updated: 1 completed, 1 in progress, 1 pending, 1 cancelled."
179
- );
180
- expect(formatUpdateSummary([{ content: "next", status: "pending" }])).toBe(
181
- "Todos updated: 1 pending."
182
- );
183
- });
184
-
185
- test("update summary handles a cleared list", () => {
186
- expect(formatUpdateSummary([])).toBe("Todos cleared.");
187
- });
188
-
189
- test("summary counts statuses", () => {
190
- expect(summarizeItems(allStatuses)).toEqual({
191
- pending: 1,
192
- in_progress: 1,
193
- completed: 1,
194
- cancelled: 1,
195
- });
196
- expect(makeDetails(allStatuses).summary).toEqual({
197
- pending: 1,
198
- in_progress: 1,
199
- completed: 1,
200
- cancelled: 1,
201
- });
202
- });
203
- });
204
-
205
- function toolResult(toolName: string, todos: readonly TodoItem[]): unknown {
206
- return {
207
- type: "message",
208
- message: {
209
- role: "toolResult",
210
- toolName,
211
- details: { todos },
212
- },
213
- };
214
- }
215
-
216
- function todoStateEntry(todos: readonly TodoItem[]): unknown {
217
- return {
218
- type: "custom",
219
- customType: "pim-todo-state",
220
- data: { todos },
221
- };
222
- }