@pi-archimedes/todo 2.2.0 → 2.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/todo",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "2.2.0"
14
+ "@pi-archimedes/core": "2.3.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -19,6 +19,9 @@
19
19
  "@earendil-works/pi-tui": ">=0.1.0"
20
20
  },
21
21
  "devDependencies": {
22
+ "@earendil-works/pi-ai": "^0.84.2",
23
+ "@earendil-works/pi-coding-agent": "^0.84.2",
24
+ "@earendil-works/pi-tui": "^0.84.2",
22
25
  "typescript": "^6.0.0"
23
26
  },
24
27
  "pi": {
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ deriveTitleFromDescription,
4
+ normalizeTodoItem,
5
+ prepareTodoArguments,
6
+ } from "./prepare-args.js";
7
+
8
+ // Arguments captured (after JSON decoding) from real failing tool calls in
9
+ // archived sessions — the cases this module exists to repair.
10
+ const REAL_STRINGIFIED_LIST =
11
+ '[{"id": 1", "title": "Catalog all undocumented env vars", "description": ' +
12
+ '"Compare env vars from all K8s manifests against what is in AI-SETUP.md", ' +
13
+ '"status": "completed"}, {"id": 2", "title": "Add K8s env vars section", ' +
14
+ '"description": "Document all container-level env vars", ' +
15
+ '"status": "in-progress"}]';
16
+
17
+ const REAL_MISSING_TITLE = {
18
+ operation: "write",
19
+ todoList: [
20
+ { description: "Understand what c12d4a0 fixed and how the current base interacts with it", id: 1, status: "in-progress" },
21
+ { description: "Trace user_api_key_auth() catch-all behaviour", id: 2, status: "not-started" },
22
+ ],
23
+ };
24
+
25
+ const REAL_MISSING_STATUS = {
26
+ operation: "write",
27
+ todoList: [
28
+ { id: 1, title: "Fix shell command interpolation", description: "fetchNewToken() builds cmd as string" },
29
+ { id: 2, title: "Update tests", description: "add regression test" },
30
+ ],
31
+ };
32
+
33
+ describe("prepareTodoArguments", () => {
34
+ it("passes through already-valid arguments unchanged", () => {
35
+ const input = {
36
+ operation: "write",
37
+ todoList: [
38
+ { id: 1, title: "Task", description: "Desc", status: "in-progress" },
39
+ { id: 2, title: "Other", description: "More", status: "completed" },
40
+ ],
41
+ } as const;
42
+ expect(prepareTodoArguments(input)).toEqual(input);
43
+ });
44
+
45
+ it("passes non-object arguments through untouched", () => {
46
+ for (const garbage of [null, undefined, 42, "write", [1, 2]]) {
47
+ expect(prepareTodoArguments(garbage)).toBe(garbage);
48
+ }
49
+ });
50
+
51
+ it("parses a stringified todoList (real-world case, incl. mangled quotes)", () => {
52
+ const out = prepareTodoArguments({ operation: "write", todoList: REAL_STRINGIFIED_LIST });
53
+ expect(out.operation).toBe("write");
54
+ const list = out.todoList as Array<Record<string, unknown>>;
55
+ expect(Array.isArray(list)).toBe(true);
56
+ expect(list).toHaveLength(2);
57
+ expect(list[0]).toEqual({ id: 1, title: "Catalog all undocumented env vars", description: "Compare env vars from all K8s manifests against what is in AI-SETUP.md", status: "completed" });
58
+ expect(list[1]).toEqual({ id: 2, title: "Add K8s env vars section", description: "Document all container-level env vars", status: "in-progress" });
59
+ });
60
+
61
+ it("returns a stringified list it cannot parse, so the schema error still fires", () => {
62
+ const out = prepareTodoArguments({ operation: "write", todoList: "[{ def 《" });
63
+ expect(out.todoList).toBe("[{ def 《");
64
+ });
65
+
66
+ it("derives a title when the model only sent description", () => {
67
+ const out = prepareTodoArguments(parseJSON(JSON.stringify(REAL_MISSING_TITLE)));
68
+ const list = out.todoList as Array<Record<string, unknown>>;
69
+ expect(list[0]?.title).not.toBe("");
70
+ expect(list[0]?.description).toBe("Understand what c12d4a0 fixed and how the current base interacts with it");
71
+ expect(list[1]).toEqual({ id: 2, title: expect.any(String), description: "Trace user_api_key_auth() catch-all behaviour", status: "not-started" });
72
+ });
73
+
74
+ it("defaults missing status to not-started", () => {
75
+ const out = prepareTodoArguments(parseJSON(JSON.stringify(REAL_MISSING_STATUS)));
76
+ const list = out.todoList as Array<Record<string, unknown>>;
77
+ expect(list.every((item) => item?.status === "not-started")).toBe(true);
78
+ });
79
+
80
+ it("normalizes status synonyms and spacing", () => {
81
+ const out = prepareTodoArguments({
82
+ operation: "write",
83
+ todoList: [
84
+ { id: 1, title: "a", description: "a", status: "In_Progress" },
85
+ { id: 2, title: "b", description: "b", status: "Done" },
86
+ { id: 3, title: "c", description: "c", status: "doing" },
87
+ { id: 4, title: "d", description: "d", status: "Pending" },
88
+ { id: 5, title: "e", description: "e", status: "CLOSED" },
89
+ { id: 6, title: "f", description: "f", status: "in-progress" },
90
+ ],
91
+ });
92
+ const list = out.todoList as Array<Record<string, unknown>>;
93
+ const statuses = list.map((item) => item?.status);
94
+ expect(statuses).toEqual(["in-progress", "completed", "in-progress", "not-started", "completed", "in-progress"]);
95
+ });
96
+
97
+ it("repairs missing ids (string, null, missing) to sequential numbers", () => {
98
+ const out = prepareTodoArguments({
99
+ operation: "write",
100
+ todoList: [
101
+ { title: "a", description: "a", status: "not-started" },
102
+ { id: "4", title: "b", description: "b", status: "not-started" },
103
+ { id: null, title: "c", description: "c", status: "not-started" },
104
+ ],
105
+ });
106
+ const list = out.todoList as Array<Record<string, unknown>>;
107
+ expect(list.map((item) => item?.id)).toEqual([1, 4, 3]);
108
+ });
109
+
110
+ it("wraps bare-string items into full items", () => {
111
+ const out = prepareTodoArguments({ operation: "write", todoList: ["write tests", "deploy it"] });
112
+ const list = out.todoList as Array<Record<string, unknown>>;
113
+ expect(list).toEqual([
114
+ { id: 1, title: "write tests", description: "write tests", status: "not-started" },
115
+ { id: 2, title: "deploy it", description: "deploy it", status: "not-started" },
116
+ ]);
117
+ });
118
+
119
+ it("wraps a single bare object in an array and drops extra keys (item + top level)", () => {
120
+ const out = prepareTodoArguments({
121
+ operation: "write",
122
+ extraTop: "drop me",
123
+ todoList: { id: 1, title: "a", description: "a", status: "completed", fluff: true },
124
+ });
125
+ expect(out).toEqual({
126
+ operation: "write",
127
+ todoList: [{ id: 1, title: "a", description: "a", status: "completed" }],
128
+ });
129
+ });
130
+
131
+ it("drops a null todoList and doesn't fabricate one", () => {
132
+ const out = prepareTodoArguments({ operation: "write", todoList: null });
133
+ expect(out).toEqual({ operation: "write" });
134
+ expect(Object.hasOwn(out, "todoList")).toBe(false);
135
+ });
136
+
137
+ it("infers operation from a present list, tolerates casing, and defaults to read", () => {
138
+ const withList = prepareTodoArguments({ operation: "WRITE-please", todoList: ["x"] });
139
+ expect(withList.operation).toBe("write");
140
+ const read = prepareTodoArguments({});
141
+ expect(read).toEqual({ operation: "read" });
142
+ });
143
+
144
+ it("looks up title/description under the keys models sometimes pick", () => {
145
+ const out = prepareTodoArguments({
146
+ operation: "write",
147
+ todoList: [{ id: 1, content: "Write the thing", details: "Long context here", status: "not-started" }],
148
+ });
149
+ const item = (out.todoList as Array<Record<string, unknown>>)[0];
150
+ expect(item).toEqual({ id: 1, title: "Write the thing", description: "Long context here", status: "not-started" });
151
+ });
152
+
153
+ it("keeps null items null so the schema error stays meaningful", () => {
154
+ const out = prepareTodoArguments({ operation: "write", todoList: [null, "x"] });
155
+ expect(out.todoList).toEqual([null, { id: 2, title: "x", description: "x", status: "not-started" }]);
156
+ });
157
+
158
+ it("is idempotent on repaired output", () => {
159
+ const parsed = parseJSON(
160
+ JSON.stringify({ ...REAL_MISSING_TITLE, extra: "gap", todoListX: [null] }),
161
+ ) as Record<string, unknown>;
162
+ const once = prepareTodoArguments(parsed);
163
+ const twice = prepareTodoArguments(once);
164
+ expect(twice).toEqual(once);
165
+ });
166
+
167
+ it("is idempotent on the real-world string + missing-title failures", () => {
168
+ const once = prepareTodoArguments({ operation: "write", todoList: REAL_STRINGIFIED_LIST });
169
+ expect(prepareTodoArguments(once)).toEqual(once);
170
+ expect(prepareTodoArguments(prepareTodoArguments(REAL_MISSING_TITLE))).toEqual(
171
+ prepareTodoArguments(REAL_MISSING_TITLE),
172
+ );
173
+ });
174
+ });
175
+
176
+ describe("deriveTitleFromDescription", () => {
177
+ it("returns short descriptions verbatim", () => {
178
+ expect(deriveTitleFromDescription(" Fix the build ")).toBe("Fix the build");
179
+ });
180
+
181
+ it("cuts long descriptions at a word boundary with an ellipsis", () => {
182
+ const long = "Understand what c12d4a0 fixed and how the current base interacts with it including patch 8";
183
+ const title = deriveTitleFromDescription(long);
184
+ expect(title.endsWith("…")).toBe(true);
185
+ expect(title.length).toBeLessThanOrEqual(61);
186
+ expect(title.endsWith(" ")).toBe(false);
187
+ });
188
+ });
189
+
190
+ describe("normalizeTodoItem", () => {
191
+ it("keeps a textless item as an empty item", () => {
192
+ expect(normalizeTodoItem({}, 0)).toEqual({ id: 1, title: "", description: "", status: "not-started" });
193
+ });
194
+
195
+ it("returns non-object items unchanged", () => {
196
+ expect(normalizeTodoItem(3, 0)).toBe(3);
197
+ expect(normalizeTodoItem("", 0)).toBe("");
198
+ });
199
+ });
200
+
201
+ // ── helpers ─────────────────────────────────────────────────────────────────
202
+
203
+ // Deep-clone fixture objects, mirroring how arguments arrive (post-JSON).
204
+ function parseJSON(s: string): unknown {
205
+ return JSON.parse(s) as unknown;
206
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Compatibility shim for LLM tool-call arguments.
3
+ *
4
+ * Wires into the tool via `prepareArguments`, which pi runs BEFORE schema
5
+ * validation (and before `execute`). The model occasionally sends
6
+ * malformed-but-recoverable `manage_todo_list` arguments; observed in practice:
7
+ *
8
+ * - `todoList` is a stringified JSON array instead of an array (sometimes
9
+ * with mangled escaping, e.g. `"id": 1"` — an extra quote after the number)
10
+ * - items are missing `title` because the model considers it redundant
11
+ * with `description`
12
+ * - items are missing `status` (or it is a non-enum phrase like "doing")
13
+ * - items are plain strings
14
+ * - `id` missing or a numeric string
15
+ * - stray top-level / per-item keys outside the schema
16
+ *
17
+ * Only *recoverable* deviations are repaired. Anything unrecoverable is passed
18
+ * through untouched so the standard validation error still surfaces with the
19
+ * real complaint. The public schema stays strict; this module is purely a
20
+ * repair layer.
21
+ */
22
+
23
+ import type { ManageTodoListInput } from "./tool.js";
24
+ import type { TodoStatus } from "./types.js";
25
+
26
+ type Record_ = Record<string, unknown>;
27
+
28
+ const VALID_STATUSES: ReadonlySet<string> = new Set<string>([
29
+ "not-started",
30
+ "in-progress",
31
+ "completed",
32
+ ]);
33
+
34
+ /** LLM synonyms for the three enum values (after case/spacing normalization). */
35
+ const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStatus>([
36
+ ["done", "completed"],
37
+ ["complete", "completed"],
38
+ ["finished", "completed"],
39
+ ["closed", "completed"],
40
+ ["success", "completed"],
41
+ ["passed", "completed"],
42
+ ["doing", "in-progress"],
43
+ ["started", "in-progress"],
44
+ ["working", "in-progress"],
45
+ ["active", "in-progress"],
46
+ ["wip", "in-progress"],
47
+ ["ongoing", "in-progress"],
48
+ ["pending", "not-started"],
49
+ ["todo", "not-started"],
50
+ ["planned", "not-started"],
51
+ ["untouched", "not-started"],
52
+ ]);
53
+
54
+ /**
55
+ * Keys the model sometimes uses instead of `title`/`description`. Checked
56
+ * only when the canonical field is itself missing, so a model that DID send
57
+ * `title`/`description` is never overwritten.
58
+ */
59
+ const TITLE_FALLBACK_KEYS = ["content", "task", "text", "name", "label"] as const;
60
+ const DESCRIPTION_FALLBACK_KEYS = ["details", "notes", "summary", "context"] as const;
61
+
62
+ /** Upper bound for a `title` derived from `description`. */
63
+ const DERIVED_TITLE_MAX = 60;
64
+
65
+ function isRecord(value: unknown): value is Record_ {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value);
67
+ }
68
+
69
+ function firstStringOf(record: Record_, keys: readonly string[]): string {
70
+ for (const key of keys) {
71
+ const value = record[key];
72
+ if (typeof value === "string" && value.trim() !== "") return value;
73
+ }
74
+ return "";
75
+ }
76
+
77
+ /**
78
+ * Collapse whitespace and cut at a word boundary so a derived title stays a
79
+ * short, human-readable label in the widget.
80
+ */
81
+ export function deriveTitleFromDescription(description: string): string {
82
+ const flat = description.replace(/\s+/g, " ").trim();
83
+ if (flat.length <= DERIVED_TITLE_MAX) return flat;
84
+ const cut = flat.slice(0, DERIVED_TITLE_MAX);
85
+ const lastSpace = cut.lastIndexOf(" ");
86
+ const head = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
87
+ return `${head.trimEnd()}…`;
88
+ }
89
+
90
+ function normalizeId(raw: unknown, index: number): number {
91
+ let n: number;
92
+ if (typeof raw === "number") {
93
+ n = raw;
94
+ } else if (typeof raw === "string" && raw.trim() !== "") {
95
+ n = Number(raw);
96
+ } else {
97
+ n = Number.NaN;
98
+ }
99
+ return Number.isFinite(n) ? Math.trunc(n) : index + 1;
100
+ }
101
+
102
+ function normalizeStatus(raw: unknown): TodoStatus {
103
+ if (typeof raw !== "string") {
104
+ // Missing, null, number, … — no signal about intent.
105
+ return "not-started";
106
+ }
107
+ // "in progress" / "In_Progress" / "IN-PROGRESS" → "in-progress"
108
+ const normalized = raw.trim().toLowerCase().replace(/[_\s]+/g, "-");
109
+ if (VALID_STATUSES.has(normalized)) return normalized as TodoStatus;
110
+ return STATUS_ALIASES.get(normalized) ?? "not-started";
111
+ }
112
+
113
+ /**
114
+ * Normalize one todo item. Returns a fresh object containing ONLY the four
115
+ * schema keys (extra keys are dropped — the schema rejects them with "must
116
+ * not have additional properties"). Unrecoverable items are returned as-is so
117
+ * the schema error keeps its meaning.
118
+ */
119
+ export function normalizeTodoItem(raw: unknown, index: number): unknown {
120
+ // Laziest form: ["write tests", "deploy"]
121
+ if (typeof raw === "string") {
122
+ const text = raw.trim();
123
+ if (text === "") return raw;
124
+ return {
125
+ id: index + 1,
126
+ title: deriveTitleFromDescription(text),
127
+ description: text,
128
+ status: "not-started" as TodoStatus,
129
+ };
130
+ }
131
+
132
+ if (!isRecord(raw)) return raw; // null / number / nested array → schema error
133
+
134
+ const description =
135
+ (typeof raw.description === "string" ? raw.description.trim() : "") ||
136
+ firstStringOf(raw, DESCRIPTION_FALLBACK_KEYS);
137
+ let title = typeof raw.title === "string" ? raw.title.trim() : "";
138
+ if (title === "") title = firstStringOf(raw, TITLE_FALLBACK_KEYS);
139
+ if (title === "" && description !== "") title = deriveTitleFromDescription(description);
140
+
141
+ // No text at all: keep the (empty) fields so the schema error lists the
142
+ // missing `title`/`description` instead of masking it with a derivation.
143
+ return {
144
+ id: normalizeId(raw.id, index),
145
+ title,
146
+ description: description || title,
147
+ status: normalizeStatus(raw.status),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Repair the occasional mangled JSON: `"id": 1"` (an extra quote after the
153
+ * number). The replacement is safe to apply blindly — that shape is invalid
154
+ * JSON to begin with.
155
+ */
156
+ // The long backslash sequence is spelled out as char codes for robustness.
157
+ const ID_FIX_PATTERN = String.fromCharCode(40, 34, 105, 100, 34, 92, 115, 42, 58, 92, 115, 42, 92, 100, 43, 41, 92, 34);
158
+ const ID_QUOTE_FIX = new RegExp(ID_FIX_PATTERN, "g");
159
+ function repairStringifiedJson(s: string): string {
160
+ return s.replace(ID_QUOTE_FIX, "$1");
161
+ }
162
+
163
+ function parseStringifiedList(raw: string): unknown {
164
+ try {
165
+ return JSON.parse(repairStringifiedJson(raw));
166
+ } catch {
167
+ return raw; // Unparseable — keep the string; schema error says "must be array"
168
+ }
169
+ }
170
+
171
+ function looksLikeTodoItem(value: unknown): boolean {
172
+ return (
173
+ isRecord(value) &&
174
+ ("id" in value || "title" in value || "description" in value || "status" in value)
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Normalize the `todoList` argument. `kept` is false only when the field was
180
+ * absent and must not appear in the output object at all.
181
+ */
182
+ function normalizeTodoList(raw: unknown): { value: unknown; kept: boolean } {
183
+ let value: unknown = raw;
184
+ if (typeof raw === "string") value = parseStringifiedList(raw);
185
+
186
+ if (value === null) {
187
+ // Required-schema fields reject null; dropping the key yields the clearer
188
+ // "todoList is required for write operation" from execute().
189
+ return { value: undefined, kept: true };
190
+ }
191
+
192
+ if (value === undefined) return { value, kept: false };
193
+
194
+ if (Array.isArray(value)) {
195
+ return { value: value.map((item, i) => normalizeTodoItem(item, i)), kept: true };
196
+ }
197
+
198
+ // A single bare item instead of an array.
199
+ if (looksLikeTodoItem(value)) {
200
+ return { value: [normalizeTodoItem(value, 0)], kept: true };
201
+ }
202
+
203
+ return { value, kept: true }; // Unmendable — original validation error stands
204
+ }
205
+
206
+ function normalizeOperation(raw: unknown, listPresent: boolean): "write" | "read" {
207
+ if (raw === "write" || raw === "read") return raw;
208
+ if (typeof raw === "string") {
209
+ const lowered = raw.trim().toLowerCase();
210
+ if (lowered === "write" || lowered === "read") return lowered;
211
+ }
212
+ // Model omitted/garbled the operation: a list implies a write.
213
+ return listPresent ? "write" : "read";
214
+ }
215
+
216
+ /**
217
+ * Repair raw `manage_todo_list` call arguments before schema validation.
218
+ * Never throws, never mutates its input, and passes through anything it
219
+ * cannot recover.
220
+ */
221
+ export function prepareTodoArguments(args: unknown): ManageTodoListInput {
222
+ if (!isRecord(args)) return args as ManageTodoListInput;
223
+
224
+ const normalized =
225
+ args.todoList === undefined ? undefined : normalizeTodoList(args.todoList);
226
+ const present =
227
+ normalized !== undefined && normalized.kept && normalized.value !== undefined;
228
+
229
+ const out: Record_ = {
230
+ operation: normalizeOperation(args.operation, present),
231
+ };
232
+ if (present) {
233
+ out.todoList = (normalized as { value: unknown }).value;
234
+ }
235
+ return out as ManageTodoListInput;
236
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
3
+ import { createManageTodoListTool } from "./tool.js";
4
+ import { TodoStateManager } from "./state-manager.js";
5
+ import type { TodoDetails } from "./types.js";
6
+
7
+ const VALIDATION_ERROR_TEXT = [
8
+ 'Validation failed for tool "manage_todo_list":',
9
+ ' - todoList.0: must be object',
10
+ "",
11
+ "Received arguments:",
12
+ '{',
13
+ ' "operation": "write",',
14
+ ' "todoList": "this is definitely not a list, ascii only"',
15
+ "}",
16
+ ].join("\n");
17
+
18
+ // Theme stub: render (token, text) as [token]text so assertions can
19
+ // tell apart the status colour, the glyph, and the label.
20
+ const theme = {
21
+ fg: (token: unknown, text: unknown) => `[${token}]${text}`,
22
+ } as unknown as Theme;
23
+
24
+ const tool = createManageTodoListTool(new TodoStateManager(), () => {});
25
+
26
+ function renderLines(result: AgentToolResult<TodoDetails | undefined>, expanded: boolean): string {
27
+ const view = tool.renderResult(result, { expanded, isPartial: false }, theme) as {
28
+ render(width: number): string[];
29
+ };
30
+ expect(view).toBeDefined();
31
+ return view.render(200).map((line) => line.trimEnd()).join("\n");
32
+ }
33
+
34
+ const harnessErrorResult = (text: string) =>
35
+ ({
36
+ content: [{ type: "text" as const, text }],
37
+ // Exactly what the harness attaches to rejected calls (createErrorToolResult):
38
+ // a truthy empty details object.
39
+ details: {},
40
+ }) as unknown as AgentToolResult<TodoDetails | undefined>;
41
+
42
+ describe("renderResult — harness error shapes", () => {
43
+ it("collapsed validation failure → single red ✗ line, no raw argument dump", () => {
44
+ const lines = renderLines(harnessErrorResult(VALIDATION_ERROR_TEXT), false).split("\n");
45
+ expect(lines.length).toBe(1);
46
+ const [line] = lines;
47
+ expect(line).toContain("✗");
48
+ expect(line).toContain("invalid arguments");
49
+ expect(line).not.toContain("Validation failed");
50
+ expect(line).not.toContain("Received arguments");
51
+ expect(line).not.toContain("definitely not a list");
52
+ });
53
+
54
+ it("expanded validation failure → the full harness message", () => {
55
+ const lines = renderLines(harnessErrorResult(VALIDATION_ERROR_TEXT), true);
56
+ expect(lines).toBe(VALIDATION_ERROR_TEXT);
57
+ });
58
+
59
+ it("results without any details at all → same compact treatment", () => {
60
+ const noDetails = {
61
+ content: [{ type: "text" as const, text: "Tool execution was blocked" }],
62
+ } as unknown as AgentToolResult<TodoDetails | undefined>;
63
+ const [line] = renderLines(noDetails, false).split("\n");
64
+ expect(line).toContain("✗");
65
+ expect(line).toContain("blocked");
66
+ });
67
+
68
+ it("non-validation errors stay short and line-less-newline", () => {
69
+ const [line] = renderLines(harnessErrorResult("Some multi-line\nharness message here"), false).split("\n");
70
+ expect(line).toContain("✗");
71
+ expect(line).not.toContain("\n");
72
+ });
73
+ });
74
+
75
+ describe("renderResult — successful results unchanged", () => {
76
+ it("renders the completed counter from details.todos", () => {
77
+ const result = {
78
+ content: [{ type: "text" as const, text: "3/3 completed." }],
79
+ details: {
80
+ operation: "write" as const,
81
+ todos: [
82
+ { id: 1, title: "a", description: "a", status: "completed" as const },
83
+ { id: 2, title: "b", description: "b", status: "completed" as const },
84
+ { id: 3, title: "c", description: "c", status: "completed" as const },
85
+ ],
86
+ },
87
+ } as unknown as AgentToolResult<TodoDetails>;
88
+ const [line] = renderLines(result, false).split("\n");
89
+ expect(line).toContain("3/3 completed");
90
+ });
91
+ });
package/src/tool.ts CHANGED
@@ -2,6 +2,8 @@ import { type Static, StringEnum, Type } from "@earendil-works/pi-ai";
2
2
  import type { AgentToolResult, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { getBus, Events } from "@pi-archimedes/core/bus";
5
+ import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
6
+ import { prepareTodoArguments } from "./prepare-args.js";
5
7
  import type { TodoStateManager } from "./state-manager.js";
6
8
  import { STATUS_ICONS } from "./types.js";
7
9
  import type { TodoDetails } from "./types.js";
@@ -68,6 +70,11 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
68
70
  description: TOOL_DESCRIPTION,
69
71
  parameters: ManageTodoListParams,
70
72
 
73
+ // Repair common model mistakes (stringified arrays, missing
74
+ // title/status, bare-string items) BEFORE schema validation. See
75
+ // prepare-args.ts.
76
+ prepareArguments: prepareTodoArguments,
77
+
71
78
  async execute(
72
79
  _toolCallId: string,
73
80
  params: ManageTodoListInput,
@@ -135,41 +142,50 @@ export function createManageTodoListTool(state: TodoStateManager, onUpdate: () =
135
142
  },
136
143
 
137
144
  renderCall(args: ManageTodoListInput, theme: Theme) {
138
- let text = theme.fg("toolTitle", theme.bold("manage_todo_list "));
139
- text += theme.fg("muted", args.operation);
140
-
141
- if (args.operation === "write" && args.todoList) {
142
- const count = args.todoList.length;
143
- text += theme.fg("dim", ` (${count} item${count !== 1 ? "s" : ""})`);
144
- }
145
-
146
- return new Text(text, 0, 0);
145
+ return new Text(renderToolHeader("todo", args.operation, theme), 0, 0);
147
146
  },
148
147
 
149
148
  renderResult(
150
149
  result: AgentToolResult<TodoDetails | undefined>,
151
- { expanded }: ToolRenderResultOptions,
150
+ { expanded, isPartial }: ToolRenderResultOptions,
152
151
  theme: Theme
153
152
  ) {
154
153
  const details = result.details;
155
- if (!details) {
154
+
155
+ // execute() always carries details.todos. Anything else — including
156
+ // the harness' rejection results, which attach details: {} — means
157
+ // execute() never ran (validation failure, blocked call, abort).
158
+ // Collapsed, show a single red ✗ line; the full message on expand.
159
+ if (!details || !Array.isArray(details.todos)) {
156
160
  const first = result.content[0];
157
- return new Text(first && "text" in first ? first.text : "", 0, 0);
161
+ const text = first && "text" in first && typeof first.text === "string" ? first.text : "";
162
+ if (!expanded) {
163
+ const label = /validation failed for tool/i.test(text)
164
+ ? "invalid arguments"
165
+ : text.replace(/\s+/g, " ").trim().slice(0, 100);
166
+ return new Text(label ? renderStatusLabel("error", label, theme) : "", 0, 0);
167
+ }
168
+ return new Text(text, 0, 0);
158
169
  }
159
170
 
160
171
  if (details.error) {
161
- return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
172
+ return new Text(renderStatusLabel("error", details.error, theme), 0, 0);
162
173
  }
163
174
 
164
175
  const todos = details.todos;
165
176
  const completed = todos.filter((t) => t.status === "completed").length;
166
177
  const total = todos.length;
178
+ const label = `${completed}/${total} completed`;
179
+
180
+ if (isPartial) {
181
+ return new Text(renderStatusLabel("running", label, theme), 0, 0);
182
+ }
167
183
 
168
184
  if (total === 0) {
169
185
  return new Text(theme.fg("dim", "No todos"), 0, 0);
170
186
  }
171
187
 
172
- let text = theme.fg("success", "✓ ") + theme.fg("muted", `${completed}/${total} completed`);
188
+ let text = renderStatusLabel("success", label, theme);
173
189
 
174
190
  if (expanded) {
175
191
  for (const todo of todos) {