@aaroncql/pim-agent 0.0.1

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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,180 @@
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
+ }
@@ -0,0 +1,172 @@
1
+ import type {
2
+ AgentToolResult,
3
+ Theme,
4
+ ToolRenderResultOptions,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { Component } from "@earendil-works/pi-tui";
7
+ import { Renderer } from "../../shared/Renderer";
8
+ import type { TodoItem } from "./schema";
9
+ import type { TodoDetails } from "./todo";
10
+
11
+ const MAX_WIDGET_LINES = 7;
12
+ const WIDGET_TITLE_LINES = 1;
13
+ const WIDGET_HINT_LINES = 1;
14
+ const MAX_UNTRUNCATED_WIDGET_TODOS = MAX_WIDGET_LINES - WIDGET_TITLE_LINES;
15
+ const MAX_TRUNCATED_WIDGET_TODOS =
16
+ MAX_UNTRUNCATED_WIDGET_TODOS - WIDGET_HINT_LINES;
17
+ const WIDGET_ANCHOR_SLOT = Math.floor(MAX_WIDGET_LINES / 2) - 1;
18
+
19
+ type RenderContext = {
20
+ readonly lastComponent: Component | undefined;
21
+ readonly isPartial: boolean;
22
+ readonly isError: boolean;
23
+ };
24
+
25
+ class HiddenTodoToolRender implements Component {
26
+ public render(): string[] {
27
+ return [];
28
+ }
29
+
30
+ public invalidate(): void {}
31
+ }
32
+
33
+ export function renderCall(
34
+ args: { readonly todos?: readonly TodoItem[] } | undefined,
35
+ theme: Theme,
36
+ context: RenderContext
37
+ ): Component {
38
+ return Renderer.renderToolCallTitle({
39
+ label: "Todo",
40
+ title: formatCallTitle(args?.todos ?? []),
41
+ theme,
42
+ context,
43
+ });
44
+ }
45
+
46
+ export function renderResult(
47
+ _result: AgentToolResult<TodoDetails>,
48
+ _options: ToolRenderResultOptions,
49
+ _theme: Theme,
50
+ context: RenderContext
51
+ ): Component {
52
+ return reuseHiddenComponent(context);
53
+ }
54
+
55
+ export function formatCallTitle(items: readonly TodoItem[]): string {
56
+ return formatStatusSummary(items);
57
+ }
58
+
59
+ export function formatWidgetTitle(
60
+ items: readonly TodoItem[],
61
+ theme: Theme
62
+ ): string {
63
+ const noun = items.length === 1 ? "todo" : "todos";
64
+ const total = theme.bold(`${items.length} ${noun}`);
65
+ const summary = formatStatusSummary(items);
66
+ return summary ? `${total} (${summary})` : total;
67
+ }
68
+
69
+ export function renderWidgetLines(
70
+ items: readonly TodoItem[],
71
+ theme: Theme
72
+ ): string[] {
73
+ if (items.length === 0) {
74
+ return [];
75
+ }
76
+
77
+ const visibleItems = selectVisibleWidgetItems(items);
78
+ const hidden = items.length - visibleItems.length;
79
+
80
+ return [
81
+ formatWidgetTitle(items, theme),
82
+ ...visibleItems.map((item) => styleItem(item, theme)),
83
+ ...(hidden > 0 ? [theme.fg("muted", `… +${hidden} more`)] : []),
84
+ ];
85
+ }
86
+
87
+ function reuseHiddenComponent(context: RenderContext): Component {
88
+ return context.lastComponent instanceof HiddenTodoToolRender
89
+ ? context.lastComponent
90
+ : new HiddenTodoToolRender();
91
+ }
92
+
93
+ function selectVisibleWidgetItems(
94
+ items: readonly TodoItem[]
95
+ ): readonly TodoItem[] {
96
+ if (items.length <= MAX_UNTRUNCATED_WIDGET_TODOS) {
97
+ return items;
98
+ }
99
+
100
+ const anchorIndex = findWidgetAnchorIndex(items);
101
+ const start = clamp(
102
+ anchorIndex - WIDGET_ANCHOR_SLOT,
103
+ 0,
104
+ items.length - MAX_TRUNCATED_WIDGET_TODOS
105
+ );
106
+
107
+ return items.slice(start, start + MAX_TRUNCATED_WIDGET_TODOS);
108
+ }
109
+
110
+ function findWidgetAnchorIndex(items: readonly TodoItem[]): number {
111
+ const inProgress = items.findIndex((item) => item.status === "in_progress");
112
+ if (inProgress >= 0) {
113
+ return inProgress;
114
+ }
115
+
116
+ const lastNonPending = items.findLastIndex(
117
+ (item) => item.status !== "pending"
118
+ );
119
+ return lastNonPending >= 0 ? lastNonPending : 0;
120
+ }
121
+
122
+ function clamp(value: number, min: number, max: number): number {
123
+ return Math.min(Math.max(value, min), max);
124
+ }
125
+
126
+ function formatStatusSummary(items: readonly TodoItem[]): string {
127
+ let pending = 0;
128
+ let done = 0;
129
+ let cancelled = 0;
130
+ for (const item of items) {
131
+ switch (item.status) {
132
+ case "pending":
133
+ case "in_progress":
134
+ pending += 1;
135
+ break;
136
+ case "completed":
137
+ done += 1;
138
+ break;
139
+ case "cancelled":
140
+ cancelled += 1;
141
+ break;
142
+ }
143
+ }
144
+
145
+ const segments: string[] = [];
146
+ if (done > 0) {
147
+ segments.push(`${done} done`);
148
+ }
149
+ if (pending > 0) {
150
+ segments.push(`${pending} pending`);
151
+ }
152
+ if (cancelled > 0) {
153
+ segments.push(`${cancelled} cancelled`);
154
+ }
155
+ if (segments.length === 0) {
156
+ return "cleared";
157
+ }
158
+ return segments.join(", ");
159
+ }
160
+
161
+ function styleItem(item: TodoItem, theme: Theme): string {
162
+ switch (item.status) {
163
+ case "pending":
164
+ return `□ ${item.content}`;
165
+ case "in_progress":
166
+ return `${theme.fg("warning", "➤")} ${theme.bold(item.content)}`;
167
+ case "completed":
168
+ return `${theme.fg("success", "✔")} ${theme.fg("muted", item.content)}`;
169
+ case "cancelled":
170
+ return `${theme.fg("muted", "✘")} ${theme.fg("muted", theme.strikethrough(item.content))}`;
171
+ }
172
+ }
@@ -0,0 +1,24 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { type Static, Type } from "typebox";
3
+
4
+ export const STATUSES = [
5
+ "pending",
6
+ "in_progress",
7
+ "completed",
8
+ "cancelled",
9
+ ] as const;
10
+
11
+ export const todoItemSchema = Type.Object({
12
+ content: Type.String(),
13
+ status: StringEnum(STATUSES),
14
+ });
15
+
16
+ export const todoSchema = Type.Object({
17
+ todos: Type.Array(todoItemSchema, {
18
+ description: "The complete replacement task list, in priority order.",
19
+ }),
20
+ });
21
+
22
+ export type TodoInput = Static<typeof todoSchema>;
23
+ export type TodoItem = Static<typeof todoItemSchema>;
24
+ export type TodoStatus = (typeof STATUSES)[number];
@@ -0,0 +1,222 @@
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
+ }