@fyeeme/pi-goal 1.0.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.
@@ -0,0 +1,528 @@
1
+ /**
2
+ * pi-goal — tool + restore + todo-bridge tests.
3
+ *
4
+ * Tool scenarios ported from oh-my-pi `test/goals/goal-tool.test.ts`; restore
5
+ * and todo-bridge tests pin the pi-side persistence adapters.
6
+ */
7
+
8
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
9
+ import { describe, expect, it } from "vitest";
10
+ import type { GoalEvaluatorOutcome, GoalEvaluatorRequest } from "../src/evaluator.ts";
11
+ import { IMPOSSIBLE_REPORT_CAP } from "../src/tool.ts";
12
+ import { GOAL_CLEARED_ENTRY_TYPE, GOAL_STATE_ENTRY_TYPE, restoreGoalFromEntries } from "../src/restore.ts";
13
+ import { GoalRuntime, type GoalRuntimeHost } from "../src/runtime.ts";
14
+ import type { Goal, GoalModeState, GoalRuntimeEvent, GoalTokenUsage, GoalToolDetails } from "../src/state.ts";
15
+ import { buildTodoContext, restoreTodoPhases } from "../src/todo-bridge.ts";
16
+ import {
17
+ buildGoalToolResponse,
18
+ createGoalTool,
19
+ type GoalRenderArgs,
20
+ renderGoalCall,
21
+ renderGoalResult,
22
+ } from "../src/tool.ts";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Runtime harness (same shape as runtime.test.ts)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function createUsage(overrides: Partial<GoalTokenUsage> = {}): GoalTokenUsage {
29
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, ...overrides };
30
+ }
31
+
32
+ function createGoal(overrides: Partial<Goal> = {}): Goal {
33
+ return {
34
+ id: "goal-1",
35
+ objective: "Ship it",
36
+ status: "active",
37
+ tokenBudget: undefined,
38
+ tokensUsed: 0,
39
+ timeUsedSeconds: 0,
40
+ createdAt: 0,
41
+ updatedAt: 0,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function createRuntimeHarness(initial?: { state?: GoalModeState }) {
47
+ let state = initial?.state ? { ...initial.state, goal: { ...initial.state.goal } } : undefined;
48
+ const events: GoalRuntimeEvent[] = [];
49
+ const host: GoalRuntimeHost = {
50
+ getState: () => (state ? { ...state, goal: { ...state.goal } } : undefined),
51
+ setState: (next) => {
52
+ state = next ? { ...next, goal: { ...next.goal } } : undefined;
53
+ },
54
+ getCurrentUsage: () => createUsage(),
55
+ emit: async (event) => {
56
+ events.push(event);
57
+ },
58
+ persist: () => {},
59
+ sendHiddenMessage: async () => {},
60
+ now: () => 0,
61
+ };
62
+ return { runtime: new GoalRuntime(host), getState: () => state, events };
63
+ }
64
+
65
+ async function executeTool(
66
+ tool: ReturnType<typeof createGoalTool>,
67
+ params: unknown,
68
+ ): Promise<AgentToolResult<GoalToolDetails>> {
69
+ const execute = tool.execute as (
70
+ id: string,
71
+ params: unknown,
72
+ signal?: AbortSignal,
73
+ onUpdate?: undefined,
74
+ ctx?: { cwd?: string },
75
+ ) => Promise<AgentToolResult<GoalToolDetails>>;
76
+ return execute("call-1", params, undefined, undefined, { cwd: "/tmp/repo" });
77
+ }
78
+
79
+ /** Evaluator stub: records requests, returns the given outcome. */
80
+ function stubEvaluator(outcome: GoalEvaluatorOutcome) {
81
+ const calls: Array<{ request: GoalEvaluatorRequest; cwd?: string }> = [];
82
+ const fn = async (request: GoalEvaluatorRequest, opts: { cwd?: string }) => {
83
+ calls.push({ request, cwd: opts.cwd });
84
+ return outcome;
85
+ };
86
+ return { fn, calls };
87
+ }
88
+
89
+ /** Tool factory wiring a runtime harness with an evaluator stub. */
90
+ function createTestTool(
91
+ harness: ReturnType<typeof createRuntimeHarness>,
92
+ evaluator: (request: GoalEvaluatorRequest, opts: { cwd?: string }) => Promise<GoalEvaluatorOutcome>,
93
+ ) {
94
+ return createGoalTool({ getRuntime: () => harness.runtime, getState: harness.getState, runEvaluator: evaluator });
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Tool ops
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe("goal tool", () => {
102
+ it("create starts a goal and returns the objective/status text", async () => {
103
+ const harness = createRuntimeHarness();
104
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "verified" }));
105
+
106
+ const result = await executeTool(tool, { op: "create", objective: " Ship it ", token_budget: 100 });
107
+ expect(result.details.op).toBe("create");
108
+ expect(result.details.goal?.objective).toBe("Ship it");
109
+ expect(result.details.goal?.status).toBe("active");
110
+ expect(result.details.remainingTokens).toBe(100);
111
+ const text = (result.content[0] as { text: string }).text ?? "";
112
+ expect(text).toContain("Goal: Ship it");
113
+ expect(text).toContain("Status: active");
114
+ expect(text).toContain("Remaining tokens: 100");
115
+ });
116
+
117
+ it("create requires objective and a positive integer budget", async () => {
118
+ const harness = createRuntimeHarness();
119
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "verified" }));
120
+
121
+ await expect(executeTool(tool, { op: "create" })).rejects.toThrow("objective is required when op=create");
122
+ await expect(executeTool(tool, { op: "create", objective: "x", token_budget: 0 })).rejects.toThrow(
123
+ "token_budget must be a positive integer when provided",
124
+ );
125
+ await expect(executeTool(tool, { op: "create", objective: "x", token_budget: 1.5 })).rejects.toThrow(
126
+ "token_budget must be a positive integer when provided",
127
+ );
128
+ });
129
+
130
+ it("get reads the current goal state", async () => {
131
+ const harness = createRuntimeHarness({
132
+ state: { enabled: true, mode: "active", goal: createGoal({ tokenBudget: 100, tokensUsed: 40 }) },
133
+ });
134
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "verified" }));
135
+
136
+ const result = await executeTool(tool, { op: "get" });
137
+ expect(result.details.goal?.objective).toBe("Ship it");
138
+ expect(result.details.remainingTokens).toBe(60);
139
+ });
140
+
141
+ it("get without a goal reports no active goal", async () => {
142
+ const harness = createRuntimeHarness();
143
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "verified" }));
144
+
145
+ const result = await executeTool(tool, { op: "get" });
146
+ expect(result.details.goal).toBeNull();
147
+ expect((result.content[0] as { text: string }).text).toBe("No active goal.");
148
+ });
149
+
150
+ it("resume reactivates a paused goal", async () => {
151
+ const harness = createRuntimeHarness({
152
+ state: { enabled: false, mode: "active", goal: createGoal({ status: "paused" }) },
153
+ });
154
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "verified" }));
155
+
156
+ const result = await executeTool(tool, { op: "resume" });
157
+ expect(result.details.goal?.status).toBe("active");
158
+ expect(harness.getState()?.enabled).toBe(true);
159
+ });
160
+
161
+ it("drop discards the goal", async () => {
162
+ const harness = createRuntimeHarness({
163
+ state: { enabled: true, mode: "active", goal: createGoal() },
164
+ });
165
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "verified" }));
166
+
167
+ const result = await executeTool(tool, { op: "drop" });
168
+ expect(result.details.goal?.status).toBe("dropped");
169
+ expect(harness.getState()).toBeUndefined();
170
+ });
171
+
172
+ it("complete marks complete and attaches the budget report", async () => {
173
+ const harness = createRuntimeHarness({
174
+ state: {
175
+ enabled: true,
176
+ mode: "active",
177
+ goal: createGoal({ tokenBudget: 100, tokensUsed: 42, timeUsedSeconds: 7 }),
178
+ },
179
+ });
180
+ const evaluator = stubEvaluator({ status: "confirmed", reason: "tests pass, file present" });
181
+ const tool = createTestTool(harness, evaluator.fn);
182
+
183
+ const result = await executeTool(tool, { op: "complete", evidence: "ran npm test; all green" });
184
+ expect(result.details.goal?.status).toBe("complete");
185
+ expect(result.details.evaluator?.verdict).toBe("confirmed");
186
+ expect(result.details.completionBudgetReport).toContain("tokens used: 42 of 100");
187
+ expect((result.content[0] as { text: string }).text).toContain("Goal achieved. Report final budget usage");
188
+ // The evaluator received the objective and the evidence claim.
189
+ expect(evaluator.calls).toHaveLength(1);
190
+ expect(evaluator.calls[0]!.request).toEqual({
191
+ mode: "complete",
192
+ objective: "Ship it",
193
+ claim: "ran npm test; all green",
194
+ });
195
+ expect(evaluator.calls[0]!.cwd).toBe("/tmp/repo");
196
+ });
197
+
198
+ it("complete requires evidence for the evaluator", async () => {
199
+ const harness = createRuntimeHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
200
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "ok" }));
201
+
202
+ await expect(executeTool(tool, { op: "complete" })).rejects.toThrow("evidence is required when op=complete");
203
+ await expect(executeTool(tool, { op: "complete", evidence: " " })).rejects.toThrow(
204
+ "evidence is required when op=complete",
205
+ );
206
+ });
207
+
208
+ it("complete keeps omp error semantics before spending an evaluator run", async () => {
209
+ const harness = createRuntimeHarness({
210
+ state: { enabled: false, mode: "exiting", reason: "completed", goal: createGoal({ status: "complete" }) },
211
+ });
212
+ const evaluator = stubEvaluator({ status: "confirmed", reason: "ok" });
213
+ const tool = createTestTool(harness, evaluator.fn);
214
+
215
+ await expect(executeTool(tool, { op: "complete", evidence: "x" })).rejects.toThrow("goal is already complete");
216
+ expect(evaluator.calls).toHaveLength(0);
217
+ });
218
+
219
+ it("complete is gated: a refuted claim keeps the goal active and returns the findings", async () => {
220
+ const harness = createRuntimeHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
221
+ const tool = createTestTool(
222
+ harness,
223
+ async () => ({ status: "refuted", reason: "test/foo.test.ts exits 1: expected 2 got 3" }),
224
+ );
225
+
226
+ const result = await executeTool(tool, { op: "complete", evidence: "looks done" });
227
+ expect(result.details.goal?.status).toBe("active");
228
+ expect(result.details.evaluator?.verdict).toBe("rejected");
229
+ expect(harness.getState()?.goal.status).toBe("active");
230
+ expect(harness.getState()?.enabled).toBe(true);
231
+ const text = (result.content[0] as { text: string }).text ?? "";
232
+ expect(text).toContain("Completion REJECTED by the independent evaluator");
233
+ expect(text).toContain("test/foo.test.ts exits 1");
234
+ });
235
+
236
+ it("complete falls back to self-audit when the evaluator is unavailable", async () => {
237
+ const harness = createRuntimeHarness({
238
+ state: { enabled: true, mode: "active", goal: createGoal({ tokenBudget: 10, tokensUsed: 4 }) },
239
+ });
240
+ const tool = createTestTool(harness, async () => ({ status: "unavailable", detail: "spawn failed" }));
241
+
242
+ const result = await executeTool(tool, { op: "complete", evidence: "ran the checks" });
243
+ expect(result.details.goal?.status).toBe("complete");
244
+ expect(result.details.evaluator?.verdict).toBe("unavailable-fallback");
245
+ const text = (result.content[0] as { text: string }).text ?? "";
246
+ expect(text).toContain("Independent evaluator unavailable (spawn failed)");
247
+ expect(text).toContain("self-audit fallback");
248
+ });
249
+
250
+ it("impossible refuted keeps the goal active and counts the dispute", async () => {
251
+ const harness = createRuntimeHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
252
+ const evaluator = stubEvaluator({
253
+ status: "refuted",
254
+ reason: "found a workable path: the missing API key can be read from env",
255
+ });
256
+ const tool = createTestTool(harness, evaluator.fn);
257
+
258
+ const result = await executeTool(tool, { op: "impossible", reason: "the API is unreachable" });
259
+ expect(result.details.goal?.status).toBe("active");
260
+ expect(result.details.evaluator?.verdict).toBe("impossible-refuted");
261
+ expect(harness.getState()?.goal.status).toBe("active");
262
+ expect(harness.getState()?.goal.impossibleReports).toBe(1);
263
+ const text = (result.content[0] as { text: string }).text ?? "";
264
+ expect(text).toContain("Impossibility claim REFUTED");
265
+ expect(text).toContain("workable path");
266
+ expect(evaluator.calls[0]!.request).toEqual({
267
+ mode: "impossible",
268
+ objective: "Ship it",
269
+ claim: "the API is unreachable",
270
+ });
271
+ });
272
+
273
+ it("impossible confirmed pauses the goal for the user", async () => {
274
+ const harness = createRuntimeHarness({
275
+ state: { enabled: true, mode: "active", goal: createGoal({ tokenBudget: 50, tokensUsed: 10 }) },
276
+ });
277
+ const tool = createTestTool(
278
+ harness,
279
+ async () => ({ status: "confirmed", reason: "the condition contradicts the platform's capabilities" }),
280
+ );
281
+
282
+ const result = await executeTool(tool, { op: "impossible", reason: "no network in this session" });
283
+ expect(result.details.goal?.status).toBe("paused");
284
+ expect(result.details.evaluator?.verdict).toBe("impossible-confirmed");
285
+ const state = harness.getState();
286
+ expect(state?.enabled).toBe(false);
287
+ expect(state?.goal.status).toBe("paused");
288
+ expect(state?.reason).toBe("impossible-confirmed");
289
+ const text = (result.content[0] as { text: string }).text ?? "";
290
+ expect(text).toContain("CONFIRMED the impossibility claim");
291
+ expect(text).toContain("paused");
292
+ });
293
+
294
+ it(`impossible refuted ${IMPOSSIBLE_REPORT_CAP} times pauses the goal for a human decision`, async () => {
295
+ const harness = createRuntimeHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
296
+ const tool = createTestTool(harness, async () => ({ status: "refuted", reason: "still workable" }));
297
+
298
+ const first = await executeTool(tool, { op: "impossible", reason: "blocked" });
299
+ expect(first.details.goal?.status).toBe("active");
300
+
301
+ const second = await executeTool(tool, { op: "impossible", reason: "still blocked" });
302
+ expect(second.details.evaluator?.verdict).toBe("impossible-disputed");
303
+ expect(second.details.goal?.status).toBe("paused");
304
+ const state = harness.getState();
305
+ expect(state?.enabled).toBe(false);
306
+ expect(state?.goal.status).toBe("paused");
307
+ expect(state?.reason).toBe("impossible-disputed");
308
+ expect(state?.goal.impossibleReports).toBe(IMPOSSIBLE_REPORT_CAP);
309
+ const text = (second.content[0] as { text: string }).text ?? "";
310
+ expect(text).toContain("paused for a human decision");
311
+ });
312
+
313
+ it("impossible requires a reason and an active goal", async () => {
314
+ const harness = createRuntimeHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
315
+ const tool = createTestTool(harness, async () => ({ status: "confirmed", reason: "ok" }));
316
+
317
+ await expect(executeTool(tool, { op: "impossible" })).rejects.toThrow("reason is required when op=impossible");
318
+
319
+ const empty = createRuntimeHarness();
320
+ const emptyTool = createTestTool(empty, async () => ({ status: "confirmed", reason: "ok" }));
321
+ await expect(executeTool(emptyTool, { op: "impossible", reason: "nope" })).rejects.toThrow(
322
+ "cannot report impossibility because no active goal exists",
323
+ );
324
+ });
325
+
326
+ it("impossible with an unavailable evaluator keeps the goal active", async () => {
327
+ const harness = createRuntimeHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
328
+ const tool = createTestTool(harness, async () => ({ status: "unavailable", detail: "timed out" }));
329
+
330
+ const result = await executeTool(tool, { op: "impossible", reason: "dead end" });
331
+ expect(result.details.goal?.status).toBe("active");
332
+ expect(result.details.evaluator?.verdict).toBe("unavailable");
333
+ expect(harness.getState()?.goal.status).toBe("active");
334
+ expect(harness.getState()?.goal.impossibleReports).toBe(1);
335
+ const text = (result.content[0] as { text: string }).text ?? "";
336
+ expect(text).toContain("was NOT adjudicated");
337
+ });
338
+
339
+ it("propagates a caller abort from the evaluator", async () => {
340
+ const harness = createRuntimeHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
341
+ const tool = createTestTool(harness, async () => {
342
+ throw new Error("goal evaluation aborted");
343
+ });
344
+
345
+ await expect(executeTool(tool, { op: "complete", evidence: "x" })).rejects.toThrow("goal evaluation aborted");
346
+ expect(harness.getState()?.goal.status).toBe("active");
347
+ });
348
+
349
+ it("buildGoalToolResponse includes the completion report only for complete goals", () => {
350
+ const active = buildGoalToolResponse(createGoal());
351
+ expect(active.completionBudgetReport).toBeNull();
352
+ const complete = buildGoalToolResponse(createGoal({ status: "complete", tokenBudget: 10, tokensUsed: 4 }), {
353
+ includeCompletionReport: true,
354
+ });
355
+ expect(complete.completionBudgetReport).toContain("tokens used: 4 of 10");
356
+ const completeWithoutFlag = buildGoalToolResponse(createGoal({ status: "complete" }), {
357
+ includeCompletionReport: false,
358
+ });
359
+ expect(completeWithoutFlag.completionBudgetReport).toBeNull();
360
+ });
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Rendering
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe("goal tool rendering", () => {
368
+ const passthroughTheme = {
369
+ fg: (_color: string, text: string) => text,
370
+ italic: (text: string) => text,
371
+ bold: (text: string) => text,
372
+ strikethrough: (text: string) => text,
373
+ } as never;
374
+
375
+ it("renders the call line with objective and budget", () => {
376
+ const args: GoalRenderArgs = { op: "create", objective: "Ship it", token_budget: 5000 };
377
+ const component = renderGoalCall(args, passthroughTheme);
378
+ const text = (component as unknown as { text: string }).text;
379
+ expect(text).toContain("Goal: set");
380
+ expect(text).toContain('"Ship it"');
381
+ expect(text).toContain("budget 5K");
382
+ });
383
+
384
+ it("renders the result with status badge and token accounting", () => {
385
+ const component = renderGoalResult(
386
+ {
387
+ content: [{ type: "text", text: "Goal: Ship it" }],
388
+ details: {
389
+ op: "create",
390
+ goal: createGoal({ status: "active", tokenBudget: 10000, tokensUsed: 2500, timeUsedSeconds: 120 }),
391
+ remainingTokens: 7500,
392
+ completionBudgetReport: null,
393
+ },
394
+ },
395
+ { isPartial: false },
396
+ passthroughTheme,
397
+ );
398
+ const text = (component as unknown as { text: string }).text;
399
+ expect(text).toContain("⟦active⟧");
400
+ expect(text).toContain('"Ship it"');
401
+ expect(text).toContain("2.5K / 10K tokens");
402
+ expect(text).toContain("7.5K left");
403
+ expect(text).toContain("2m elapsed");
404
+ });
405
+
406
+ it("renders errors from the fallback content", () => {
407
+ const component = renderGoalResult(
408
+ { content: [{ type: "text", text: "boom" }], isError: true },
409
+ { isPartial: false },
410
+ passthroughTheme,
411
+ );
412
+ const text = (component as unknown as { text: string }).text;
413
+ expect(text).toContain("boom");
414
+ });
415
+ });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Restore
419
+ // ---------------------------------------------------------------------------
420
+
421
+ describe("restore", () => {
422
+ it("restores the latest goal-state snapshot", () => {
423
+ const entries = [
424
+ {
425
+ type: "custom",
426
+ customType: GOAL_STATE_ENTRY_TYPE,
427
+ id: "e1",
428
+ data: { enabled: true, goal: createGoal({ id: "g1" }) },
429
+ },
430
+ {
431
+ type: "custom",
432
+ customType: GOAL_STATE_ENTRY_TYPE,
433
+ id: "e2",
434
+ data: { enabled: false, goal: createGoal({ id: "g2", status: "paused" }) },
435
+ },
436
+ ];
437
+ const restored = restoreGoalFromEntries(entries);
438
+ expect(restored?.goal.id).toBe("g2");
439
+ expect(restored?.enabled).toBe(false);
440
+ expect(restored?.mode).toBe("active");
441
+ });
442
+
443
+ it("a newer goal-cleared marker erases the goal", () => {
444
+ const entries = [
445
+ { type: "custom", customType: GOAL_STATE_ENTRY_TYPE, id: "e1", data: { enabled: true, goal: createGoal() } },
446
+ { type: "custom", customType: GOAL_CLEARED_ENTRY_TYPE, id: "e2", data: { clearedAt: 1 } },
447
+ ];
448
+ expect(restoreGoalFromEntries(entries)).toBeUndefined();
449
+ });
450
+
451
+ it("a goal-state snapshot after the cleared marker wins", () => {
452
+ const entries = [
453
+ { type: "custom", customType: GOAL_CLEARED_ENTRY_TYPE, id: "e1", data: { clearedAt: 1 } },
454
+ {
455
+ type: "custom",
456
+ customType: GOAL_STATE_ENTRY_TYPE,
457
+ id: "e2",
458
+ data: { enabled: true, goal: createGoal({ id: "g3" }) },
459
+ },
460
+ ];
461
+ expect(restoreGoalFromEntries(entries)?.goal.id).toBe("g3");
462
+ });
463
+
464
+ it("skips malformed snapshots", () => {
465
+ const entries = [
466
+ { type: "custom", customType: GOAL_STATE_ENTRY_TYPE, id: "e1", data: { enabled: "yes", goal: {} } },
467
+ {
468
+ type: "custom",
469
+ customType: GOAL_STATE_ENTRY_TYPE,
470
+ id: "e2",
471
+ data: { enabled: true, goal: createGoal({ id: "g4" }) },
472
+ },
473
+ ];
474
+ expect(restoreGoalFromEntries(entries)?.goal.id).toBe("g4");
475
+ });
476
+
477
+ it("returns undefined with no entries", () => {
478
+ expect(restoreGoalFromEntries([])).toBeUndefined();
479
+ });
480
+ });
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Todo bridge
484
+ // ---------------------------------------------------------------------------
485
+
486
+ describe("todo bridge", () => {
487
+ it("restores pi-todo phases from session entries", () => {
488
+ const entries = [
489
+ {
490
+ type: "custom",
491
+ customType: "todo-phases",
492
+ data: { phases: [{ name: "P1", tasks: [{ content: "a", status: "in_progress" }] }] },
493
+ },
494
+ ];
495
+ const phases = restoreTodoPhases(entries);
496
+ expect(phases).toHaveLength(1);
497
+ expect(phases[0]?.tasks[0]?.status).toBe("in_progress");
498
+ });
499
+
500
+ it("renders the todo context block with counts", () => {
501
+ const context = buildTodoContext(
502
+ [
503
+ {
504
+ name: "Foundation",
505
+ tasks: [
506
+ { content: "done task", status: "completed" },
507
+ { content: "open task", status: "in_progress" },
508
+ ],
509
+ },
510
+ ],
511
+ true,
512
+ );
513
+ expect(context).toContain("<todo_context>");
514
+ expect(context).toContain("Overall: 1/2 done, 1 open.");
515
+ expect(context).toContain("- [in_progress] open task");
516
+ });
517
+
518
+ it("returns undefined when the todo tool is inactive or phases are empty", () => {
519
+ expect(buildTodoContext([{ name: "P", tasks: [{ content: "a", status: "pending" }] }], false)).toBeUndefined();
520
+ expect(buildTodoContext([], true)).toBeUndefined();
521
+ });
522
+
523
+ it("escapes XML-sensitive todo text (omp #sanitizeGoalTodoText)", () => {
524
+ const context = buildTodoContext([{ name: "P<1>", tasks: [{ content: "a & b", status: "pending" }] }], true);
525
+ expect(context).toContain("P&lt;1&gt;");
526
+ expect(context).toContain("a &amp; b");
527
+ });
528
+ });