@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.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/index.ts +651 -0
- package/package.json +65 -0
- package/src/commands.ts +325 -0
- package/src/evaluator.ts +218 -0
- package/src/format.ts +35 -0
- package/src/prompts/evaluator-complete.md +26 -0
- package/src/prompts/evaluator-impossible.md +20 -0
- package/src/prompts/goal-budget-limit.md +15 -0
- package/src/prompts/goal-continuation.md +30 -0
- package/src/prompts/goal-mode-active.md +23 -0
- package/src/prompts/goal-mode-context.md +4 -0
- package/src/prompts/goal-todo-context.md +12 -0
- package/src/prompts/goal.md +11 -0
- package/src/prompts/guided-goal-interview.md +37 -0
- package/src/restore.ts +92 -0
- package/src/runtime.ts +565 -0
- package/src/state.ts +124 -0
- package/src/template.ts +154 -0
- package/src/todo-bridge.ts +136 -0
- package/src/tool.ts +557 -0
- package/test/evaluator.test.ts +186 -0
- package/test/index.test.ts +557 -0
- package/test/omp-alignment.test.ts +975 -0
- package/test/runtime.test.ts +471 -0
- package/test/template.test.ts +89 -0
- package/test/tool.test.ts +528 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — runtime tests.
|
|
3
|
+
*
|
|
4
|
+
* Scenarios ported from oh-my-pi `test/goals/goal-runtime.test.ts`
|
|
5
|
+
* (bun:test → vitest; the harness shape is carried over). Together they pin
|
|
6
|
+
* the budget-accounting semantics that must never drift: cache writes count,
|
|
7
|
+
* cache reads do not; wall-clock accounting advances in whole persisted
|
|
8
|
+
* seconds; budget-limit steering fires once per goal id; interrupts pause;
|
|
9
|
+
* resume auto-pauses.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
GoalRuntime,
|
|
15
|
+
type GoalRuntimeHost,
|
|
16
|
+
goalTokenDelta,
|
|
17
|
+
renderGoalPrompt,
|
|
18
|
+
renderTrustedObjective,
|
|
19
|
+
} from "../src/runtime.ts";
|
|
20
|
+
import type { Goal, GoalModeState, GoalRuntimeEvent, GoalTokenUsage } from "../src/state.ts";
|
|
21
|
+
import { escapeXmlText } from "../src/template.ts";
|
|
22
|
+
|
|
23
|
+
function createUsage(overrides: Partial<GoalTokenUsage> = {}): GoalTokenUsage {
|
|
24
|
+
return {
|
|
25
|
+
input: 0,
|
|
26
|
+
output: 0,
|
|
27
|
+
cacheRead: 0,
|
|
28
|
+
cacheWrite: 0,
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createGoal(overrides: Partial<Goal> = {}): Goal {
|
|
34
|
+
return {
|
|
35
|
+
id: "goal-1",
|
|
36
|
+
objective: "Ship <fast> & safely",
|
|
37
|
+
status: "active",
|
|
38
|
+
tokenBudget: undefined,
|
|
39
|
+
tokensUsed: 0,
|
|
40
|
+
timeUsedSeconds: 0,
|
|
41
|
+
createdAt: 0,
|
|
42
|
+
updatedAt: 0,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cloneGoal(goal: Goal): Goal {
|
|
48
|
+
return { ...goal };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cloneState(state: GoalModeState | undefined): GoalModeState | undefined {
|
|
52
|
+
return state ? { ...state, goal: cloneGoal(state.goal) } : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cloneEvent(event: GoalRuntimeEvent): GoalRuntimeEvent {
|
|
56
|
+
if (event.type === "goal_updated") {
|
|
57
|
+
return {
|
|
58
|
+
...event,
|
|
59
|
+
goal: event.goal ? cloneGoal(event.goal) : null,
|
|
60
|
+
state: cloneState(event.state),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { ...event };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createHarness(initial: { state?: GoalModeState; usage?: GoalTokenUsage; now?: number } = {}) {
|
|
67
|
+
let state = cloneState(initial.state);
|
|
68
|
+
let usage = createUsage(initial.usage);
|
|
69
|
+
let now = initial.now ?? 0;
|
|
70
|
+
const events: GoalRuntimeEvent[] = [];
|
|
71
|
+
const persists: Array<{ mode: "goal" | "goal_paused" | "none"; state?: GoalModeState }> = [];
|
|
72
|
+
const hiddenMessages: Array<{ customType: string; content: string; deliverAs?: "steer" | "followUp" | "nextTurn" }> =
|
|
73
|
+
[];
|
|
74
|
+
const host: GoalRuntimeHost = {
|
|
75
|
+
getState: () => cloneState(state),
|
|
76
|
+
setState: (next) => {
|
|
77
|
+
state = cloneState(next);
|
|
78
|
+
},
|
|
79
|
+
getCurrentUsage: () => createUsage(usage),
|
|
80
|
+
emit: async (event) => {
|
|
81
|
+
events.push(cloneEvent(event));
|
|
82
|
+
},
|
|
83
|
+
persist: (mode, persistedState) => {
|
|
84
|
+
persists.push({ mode, state: cloneState(persistedState) });
|
|
85
|
+
},
|
|
86
|
+
sendHiddenMessage: async (message) => {
|
|
87
|
+
hiddenMessages.push({ ...message });
|
|
88
|
+
},
|
|
89
|
+
now: () => now,
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
runtime: new GoalRuntime(host),
|
|
93
|
+
getState: () => cloneState(state),
|
|
94
|
+
setState: (next: GoalModeState | undefined) => {
|
|
95
|
+
state = cloneState(next);
|
|
96
|
+
},
|
|
97
|
+
setUsage: (next: Partial<GoalTokenUsage>) => {
|
|
98
|
+
usage = createUsage(next);
|
|
99
|
+
},
|
|
100
|
+
advance: (ms: number) => {
|
|
101
|
+
now += ms;
|
|
102
|
+
},
|
|
103
|
+
events,
|
|
104
|
+
persists,
|
|
105
|
+
hiddenMessages,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("goal runtime", () => {
|
|
110
|
+
it("counts cache writes but ignores cache reads in token deltas", () => {
|
|
111
|
+
expect(
|
|
112
|
+
goalTokenDelta(
|
|
113
|
+
createUsage({ input: 13, output: 6, cacheRead: 999, cacheWrite: 8 }),
|
|
114
|
+
createUsage({ input: 10, output: 4, cacheRead: 1, cacheWrite: 5 }),
|
|
115
|
+
),
|
|
116
|
+
).toBe(8);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("clamps token deltas at zero across usage resets", () => {
|
|
120
|
+
expect(
|
|
121
|
+
goalTokenDelta(
|
|
122
|
+
createUsage({ input: 10, output: 5, cacheRead: 0, cacheWrite: 2 }),
|
|
123
|
+
createUsage({ input: 100, output: 50, cacheRead: 500, cacheWrite: 20 }),
|
|
124
|
+
),
|
|
125
|
+
).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("advances wall-clock accounting only by persisted whole seconds", async () => {
|
|
129
|
+
const harness = createHarness({
|
|
130
|
+
state: { enabled: true, mode: "active", goal: createGoal() },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
harness.runtime.onTurnStart("turn-1", createUsage());
|
|
134
|
+
harness.advance(2_500);
|
|
135
|
+
harness.setUsage(createUsage({ input: 1 }));
|
|
136
|
+
await harness.runtime.flushUsage("suppressed");
|
|
137
|
+
expect(harness.getState()?.goal.timeUsedSeconds).toBe(2);
|
|
138
|
+
expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(2_000);
|
|
139
|
+
expect(harness.persists).toHaveLength(1);
|
|
140
|
+
|
|
141
|
+
harness.advance(400);
|
|
142
|
+
await harness.runtime.flushUsage("suppressed");
|
|
143
|
+
expect(harness.getState()?.goal.timeUsedSeconds).toBe(2);
|
|
144
|
+
expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(2_000);
|
|
145
|
+
expect(harness.persists).toHaveLength(1);
|
|
146
|
+
|
|
147
|
+
harness.advance(700);
|
|
148
|
+
harness.setUsage(createUsage({ input: 2 }));
|
|
149
|
+
await harness.runtime.flushUsage("suppressed");
|
|
150
|
+
expect(harness.getState()?.goal.timeUsedSeconds).toBe(3);
|
|
151
|
+
expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(3_000);
|
|
152
|
+
expect(harness.persists).toHaveLength(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not persist snapshots on wall-clock-only flushes", async () => {
|
|
156
|
+
const harness = createHarness({
|
|
157
|
+
state: { enabled: true, mode: "active", goal: createGoal() },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
harness.runtime.onTurnStart("turn-1", createUsage());
|
|
161
|
+
harness.advance(2_500);
|
|
162
|
+
// Flush wall-clock time without any token usage changes.
|
|
163
|
+
await harness.runtime.flushUsage("suppressed");
|
|
164
|
+
// The in-memory state should still be updated.
|
|
165
|
+
expect(harness.getState()?.goal.timeUsedSeconds).toBe(2);
|
|
166
|
+
// But it should not write/persist to the session log.
|
|
167
|
+
expect(harness.persists).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("persists wall-clock-only usage before internal compaction or session-switch aborts", async () => {
|
|
171
|
+
const harness = createHarness({
|
|
172
|
+
state: { enabled: true, mode: "active", goal: createGoal() },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
harness.runtime.onTurnStart("turn-1", createUsage());
|
|
176
|
+
harness.advance(2_500);
|
|
177
|
+
await harness.runtime.onTaskAborted({ reason: "internal" });
|
|
178
|
+
|
|
179
|
+
expect(harness.getState()?.enabled).toBe(true);
|
|
180
|
+
expect(harness.getState()?.goal.status).toBe("active");
|
|
181
|
+
expect(harness.getState()?.goal.timeUsedSeconds).toBe(2);
|
|
182
|
+
expect(harness.persists).toHaveLength(1);
|
|
183
|
+
expect(harness.persists[0]).toMatchObject({
|
|
184
|
+
mode: "goal",
|
|
185
|
+
state: { goal: { timeUsedSeconds: 2 } },
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("resets wall-clock baseline when preserving an active goal after a no-goal switch", async () => {
|
|
190
|
+
const goal = createGoal();
|
|
191
|
+
const harness = createHarness({
|
|
192
|
+
state: { enabled: true, mode: "active", goal },
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
harness.runtime.onTurnStart("turn-1", createUsage());
|
|
196
|
+
harness.setState(undefined);
|
|
197
|
+
harness.advance(10_000);
|
|
198
|
+
harness.setState({ enabled: true, mode: "active", goal });
|
|
199
|
+
|
|
200
|
+
const resumed = await harness.runtime.onThreadResumed({ preserveActiveGoal: true });
|
|
201
|
+
harness.advance(1_000);
|
|
202
|
+
await harness.runtime.flushUsage("suppressed");
|
|
203
|
+
|
|
204
|
+
expect(resumed?.goal.status).toBe("active");
|
|
205
|
+
expect(harness.getState()?.goal.timeUsedSeconds).toBe(1);
|
|
206
|
+
expect(harness.runtime.snapshot.wallClock.lastAccountedAt).toBe(11_000);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("steers only once until a budget mutation resets the cycle", async () => {
|
|
210
|
+
const harness = createHarness({
|
|
211
|
+
state: {
|
|
212
|
+
enabled: true,
|
|
213
|
+
mode: "active",
|
|
214
|
+
goal: createGoal({ tokenBudget: 10, tokensUsed: 8 }),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
harness.runtime.onTurnStart("turn-1", createUsage());
|
|
219
|
+
harness.setUsage({ input: 2 });
|
|
220
|
+
await harness.runtime.flushUsage("allowed");
|
|
221
|
+
expect(harness.getState()?.goal.status).toBe("budget-limited");
|
|
222
|
+
expect(harness.hiddenMessages).toHaveLength(1);
|
|
223
|
+
expect(harness.hiddenMessages[0]).toMatchObject({
|
|
224
|
+
customType: "goal-budget-limit",
|
|
225
|
+
deliverAs: "steer",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
harness.setUsage({ input: 5 });
|
|
229
|
+
await harness.runtime.flushUsage("allowed");
|
|
230
|
+
expect(harness.hiddenMessages).toHaveLength(1);
|
|
231
|
+
|
|
232
|
+
await harness.runtime.onBudgetMutated(20);
|
|
233
|
+
expect(harness.getState()?.enabled).toBe(true);
|
|
234
|
+
expect(harness.getState()?.goal.status).toBe("active");
|
|
235
|
+
expect(harness.getState()?.goal.tokenBudget).toBe(20);
|
|
236
|
+
expect(harness.hiddenMessages).toHaveLength(1);
|
|
237
|
+
|
|
238
|
+
harness.setUsage({ input: 15 });
|
|
239
|
+
await harness.runtime.flushUsage("allowed");
|
|
240
|
+
expect(harness.getState()?.goal.status).toBe("budget-limited");
|
|
241
|
+
expect(harness.hiddenMessages).toHaveLength(2);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("pauses an active goal when an interruption aborts the task", async () => {
|
|
245
|
+
const harness = createHarness({
|
|
246
|
+
state: { enabled: true, mode: "active", goal: createGoal() },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
harness.runtime.onTurnStart("turn-1", createUsage());
|
|
250
|
+
harness.advance(1_000);
|
|
251
|
+
harness.setUsage({ output: 4 });
|
|
252
|
+
await harness.runtime.onTaskAborted({ reason: "interrupted" });
|
|
253
|
+
|
|
254
|
+
const state = harness.getState();
|
|
255
|
+
expect(state?.enabled).toBe(false);
|
|
256
|
+
expect(state?.goal.status).toBe("paused");
|
|
257
|
+
expect(state?.goal.tokensUsed).toBe(4);
|
|
258
|
+
expect(state?.goal.timeUsedSeconds).toBe(1);
|
|
259
|
+
expect(harness.persists.at(-1)?.mode).toBe("goal_paused");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("auto-pauses active goals when a thread resumes", async () => {
|
|
263
|
+
const harness = createHarness({
|
|
264
|
+
state: { enabled: true, mode: "active", goal: createGoal() },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const resumed = await harness.runtime.onThreadResumed();
|
|
268
|
+
expect(resumed?.enabled).toBe(false);
|
|
269
|
+
expect(resumed?.goal.status).toBe("paused");
|
|
270
|
+
expect(harness.getState()?.enabled).toBe(false);
|
|
271
|
+
expect(harness.getState()?.goal.status).toBe("paused");
|
|
272
|
+
expect(harness.persists.at(-1)?.mode).toBe("goal_paused");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("preserves an active goal during internal session-switch reconciliation", async () => {
|
|
276
|
+
const harness = createHarness({
|
|
277
|
+
state: { enabled: true, mode: "active", goal: createGoal() },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const resumed = await harness.runtime.onThreadResumed({ preserveActiveGoal: true });
|
|
281
|
+
|
|
282
|
+
expect(resumed?.enabled).toBe(true);
|
|
283
|
+
expect(resumed?.goal.status).toBe("active");
|
|
284
|
+
expect(harness.getState()?.enabled).toBe(true);
|
|
285
|
+
expect(harness.getState()?.goal.status).toBe("active");
|
|
286
|
+
expect(harness.persists).toHaveLength(0);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("escapes XML in goal helpers and rendered prompts", () => {
|
|
290
|
+
const objective = "Fix <root>&keep>safe";
|
|
291
|
+
const goal = createGoal({ objective });
|
|
292
|
+
const prompt = renderGoalPrompt("active", goal);
|
|
293
|
+
|
|
294
|
+
expect(renderTrustedObjective(objective)).toBe("<objective>\nFix <root>&keep>safe\n</objective>");
|
|
295
|
+
expect(prompt).toContain("Fix <root>&keep>safe");
|
|
296
|
+
expect(prompt).not.toContain(objective);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("escapeXmlText escapes the XML-significant characters", () => {
|
|
300
|
+
expect(escapeXmlText("a & b < c > d")).toBe("a & b < c > d");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("onBudgetMutated downward to below current usage flips active to budget-limited and steers", async () => {
|
|
304
|
+
const harness = createHarness({
|
|
305
|
+
state: {
|
|
306
|
+
enabled: true,
|
|
307
|
+
mode: "active",
|
|
308
|
+
goal: createGoal({ tokenBudget: 100, tokensUsed: 30, status: "active" }),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const next = await harness.runtime.onBudgetMutated(20);
|
|
313
|
+
|
|
314
|
+
expect(next?.goal.status).toBe("budget-limited");
|
|
315
|
+
expect(next?.goal.tokenBudget).toBe(20);
|
|
316
|
+
expect(next?.goal.tokensUsed).toBe(30);
|
|
317
|
+
expect(harness.hiddenMessages).toHaveLength(1);
|
|
318
|
+
expect(harness.hiddenMessages[0]?.customType).toBe("goal-budget-limit");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("completeGoalFromTool clears enabled and flips status to complete with mode exiting", async () => {
|
|
322
|
+
const harness = createHarness({
|
|
323
|
+
state: {
|
|
324
|
+
enabled: true,
|
|
325
|
+
mode: "active",
|
|
326
|
+
goal: createGoal({ tokenBudget: 100, tokensUsed: 42, timeUsedSeconds: 7 }),
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const completed = await harness.runtime.completeGoalFromTool();
|
|
331
|
+
|
|
332
|
+
expect(completed.status).toBe("complete");
|
|
333
|
+
const state = harness.getState();
|
|
334
|
+
expect(state?.enabled).toBe(false);
|
|
335
|
+
expect(state?.mode).toBe("exiting");
|
|
336
|
+
expect(state?.reason).toBe("completed");
|
|
337
|
+
expect(state?.goal.status).toBe("complete");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("recordImpossibleReport bumps the dispute counter and persists", async () => {
|
|
341
|
+
const harness = createHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
|
|
342
|
+
|
|
343
|
+
const first = await harness.runtime.recordImpossibleReport();
|
|
344
|
+
expect(first.impossibleReports).toBe(1);
|
|
345
|
+
const second = await harness.runtime.recordImpossibleReport();
|
|
346
|
+
expect(second.impossibleReports).toBe(2);
|
|
347
|
+
expect(harness.getState()?.goal.impossibleReports).toBe(2);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("recordImpossibleReport requires an accounting-active goal", async () => {
|
|
351
|
+
const harness = createHarness({
|
|
352
|
+
state: { enabled: false, mode: "active", goal: createGoal({ status: "paused" }) },
|
|
353
|
+
});
|
|
354
|
+
await expect(harness.runtime.recordImpossibleReport()).rejects.toThrow(
|
|
355
|
+
"cannot report impossibility because no active goal exists",
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("pauseGoal carries an explicit reason (impossible outcomes)", async () => {
|
|
360
|
+
const harness = createHarness({ state: { enabled: true, mode: "active", goal: createGoal() } });
|
|
361
|
+
|
|
362
|
+
const paused = await harness.runtime.pauseGoal({ reason: "impossible-confirmed" });
|
|
363
|
+
|
|
364
|
+
expect(paused?.enabled).toBe(false);
|
|
365
|
+
expect(paused?.goal.status).toBe("paused");
|
|
366
|
+
expect(paused?.reason).toBe("impossible-confirmed");
|
|
367
|
+
expect(harness.getState()?.reason).toBe("impossible-confirmed");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("dropGoal emits goal_updated with the dropped goal and clears persisted state", async () => {
|
|
371
|
+
const harness = createHarness({
|
|
372
|
+
state: {
|
|
373
|
+
enabled: true,
|
|
374
|
+
mode: "active",
|
|
375
|
+
goal: createGoal({ id: "g-99", objective: "Ship soon" }),
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const dropped = await harness.runtime.dropGoal();
|
|
380
|
+
|
|
381
|
+
expect(dropped?.status).toBe("dropped");
|
|
382
|
+
expect(dropped?.id).toBe("g-99");
|
|
383
|
+
expect(harness.getState()).toBeUndefined();
|
|
384
|
+
const lastEvent = harness.events.at(-1);
|
|
385
|
+
if (lastEvent?.type !== "goal_updated") {
|
|
386
|
+
throw new Error("expected goal_updated event after dropGoal");
|
|
387
|
+
}
|
|
388
|
+
expect(lastEvent.goal?.status).toBe("dropped");
|
|
389
|
+
expect(lastEvent.state?.enabled).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("rejects op=create on the runtime when a non-dropped goal already exists", async () => {
|
|
393
|
+
const harness = createHarness({
|
|
394
|
+
state: {
|
|
395
|
+
enabled: true,
|
|
396
|
+
mode: "active",
|
|
397
|
+
goal: createGoal({ objective: "Existing" }),
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await expect(harness.runtime.createGoal({ objective: "Second" })).rejects.toThrow(
|
|
402
|
+
"cannot create a new goal because this session already has a goal",
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("replaces an active goal with a fresh active goal", async () => {
|
|
407
|
+
const harness = createHarness({
|
|
408
|
+
state: {
|
|
409
|
+
enabled: true,
|
|
410
|
+
mode: "active",
|
|
411
|
+
goal: createGoal({ objective: "Existing", tokenBudget: 100 }),
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
harness.runtime.onTurnStart("turn-1", createUsage());
|
|
416
|
+
harness.advance(1_000);
|
|
417
|
+
harness.setUsage({ input: 12 });
|
|
418
|
+
|
|
419
|
+
const next = await harness.runtime.replaceGoal({ objective: "Second", tokenBudget: 25 });
|
|
420
|
+
|
|
421
|
+
expect(next.enabled).toBe(true);
|
|
422
|
+
expect(next.goal.objective).toBe("Second");
|
|
423
|
+
expect(next.goal.status).toBe("active");
|
|
424
|
+
expect(next.goal.tokenBudget).toBe(25);
|
|
425
|
+
expect(next.goal.tokensUsed).toBe(0);
|
|
426
|
+
expect(next.goal.timeUsedSeconds).toBe(0);
|
|
427
|
+
expect(next.goal.id).not.toBe("goal-1");
|
|
428
|
+
expect(harness.persists.at(-1)?.state?.goal.objective).toBe("Second");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("allows creating a new goal after the previous one is complete", async () => {
|
|
432
|
+
const harness = createHarness({
|
|
433
|
+
state: {
|
|
434
|
+
enabled: false,
|
|
435
|
+
mode: "exiting",
|
|
436
|
+
reason: "completed",
|
|
437
|
+
goal: createGoal({ status: "complete" }),
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const next = await harness.runtime.createGoal({ objective: "Phase 4" });
|
|
442
|
+
expect(next.goal.objective).toBe("Phase 4");
|
|
443
|
+
expect(next.goal.status).toBe("active");
|
|
444
|
+
expect(next.enabled).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("completeGoalFromTool succeeds for a paused goal (enabled=false)", async () => {
|
|
448
|
+
const harness = createHarness({
|
|
449
|
+
state: {
|
|
450
|
+
enabled: false,
|
|
451
|
+
mode: "active",
|
|
452
|
+
goal: createGoal({ status: "paused", tokensUsed: 30, timeUsedSeconds: 5 }),
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const completed = await harness.runtime.completeGoalFromTool();
|
|
457
|
+
expect(completed.status).toBe("complete");
|
|
458
|
+
const state = harness.getState();
|
|
459
|
+
expect(state?.enabled).toBe(false);
|
|
460
|
+
expect(state?.mode).toBe("exiting");
|
|
461
|
+
expect(state?.goal.status).toBe("complete");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("goal ids are unique (randomUUID replaced omp Snowflake)", async () => {
|
|
465
|
+
const harness = createHarness();
|
|
466
|
+
const first = await harness.runtime.createGoal({ objective: "One" });
|
|
467
|
+
harness.setState(undefined);
|
|
468
|
+
const second = await harness.runtime.createGoal({ objective: "Two" });
|
|
469
|
+
expect(first.goal.id).not.toBe(second.goal.id);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — template renderer tests.
|
|
3
|
+
*
|
|
4
|
+
* The renderer replaces omp `prompt.render` for the goal prompt templates; it
|
|
5
|
+
* must support exactly the constructs those templates use and nothing more.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { escapeXmlText, renderTemplate } from "../src/template.ts";
|
|
10
|
+
|
|
11
|
+
describe("renderTemplate", () => {
|
|
12
|
+
it("interpolates variables", () => {
|
|
13
|
+
expect(renderTemplate("Hello {{name}}, {{count}}!", { name: "world", count: "3" })).toBe("Hello world, 3!");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("renders unknown variables as empty strings", () => {
|
|
17
|
+
expect(renderTemplate("a{{missing}}b", {})).toBe("ab");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("supports if with a truthy condition", () => {
|
|
21
|
+
expect(renderTemplate("A{{#if flag}}yes{{/if}}B", { flag: "x" })).toBe("AyesB");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("supports if with a falsy condition", () => {
|
|
25
|
+
expect(renderTemplate("A{{#if flag}}yes{{/if}}B", { flag: "" })).toBe("AB");
|
|
26
|
+
expect(renderTemplate("A{{#if flag}}yes{{/if}}B", {})).toBe("AB");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("supports else branches", () => {
|
|
30
|
+
const template = "{{#if todoContext}}[{{todoContext}}]{{/if}}";
|
|
31
|
+
expect(renderTemplate(template, { todoContext: "T" })).toBe("[T]");
|
|
32
|
+
expect(renderTemplate(template, {})).toBe("");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders the goal-mode-context template shape (if with newline body)", () => {
|
|
36
|
+
const template = "{{goalContext}}\n{{#if todoContext}}\n{{todoContext}}\n{{/if}}";
|
|
37
|
+
// omp prompt.render emits the same blank line between the two blocks.
|
|
38
|
+
expect(renderTemplate(template, { goalContext: "G", todoContext: "T" })).toBe("G\n\nT\n");
|
|
39
|
+
expect(renderTemplate(template, { goalContext: "G" })).toBe("G\n");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("iterates each with item field shadowing", () => {
|
|
43
|
+
const template = "{{#each phases}}- {{name}}:\n{{#each tasks}} - [{{status}}] {{content}}\n{{/each}}{{/each}}";
|
|
44
|
+
const out = renderTemplate(template, {
|
|
45
|
+
phases: [
|
|
46
|
+
{
|
|
47
|
+
name: "P1",
|
|
48
|
+
tasks: [
|
|
49
|
+
{ status: "done", content: "a" },
|
|
50
|
+
{ status: "open", content: "b" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{ name: "P2", tasks: [{ status: "open", content: "c" }] },
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
expect(out).toBe("- P1:\n - [done] a\n - [open] b\n- P2:\n - [open] c\n");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders {{#if initial}}...{{else}}...{{/if}} like the guided-goal template", () => {
|
|
60
|
+
const template =
|
|
61
|
+
"{{#if initial}}\n<rough-goal>\n{{initial}}\n</rough-goal>\n{{else}}\nNo objective stated.\n{{/if}}";
|
|
62
|
+
expect(renderTemplate(template, { initial: "build a birdhouse" })).toBe(
|
|
63
|
+
"\n<rough-goal>\nbuild a birdhouse\n</rough-goal>\n",
|
|
64
|
+
);
|
|
65
|
+
expect(renderTemplate(template, {})).toBe("\nNo objective stated.\n");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("skips each over non-array values", () => {
|
|
69
|
+
expect(renderTemplate("a{{#each items}}x{{/each}}b", { items: "nope" })).toBe("ab");
|
|
70
|
+
expect(renderTemplate("a{{#each items}}x{{/each}}b", {})).toBe("ab");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("throws on unclosed blocks and stray closers", () => {
|
|
74
|
+
expect(() => renderTemplate("{{#if flag}}oops", { flag: 1 })).toThrow();
|
|
75
|
+
expect(() => renderTemplate("{{/if}}", {})).toThrow();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("caches parses (same template rendered twice)", () => {
|
|
79
|
+
const template = "{{#if x}}y{{/if}}";
|
|
80
|
+
expect(renderTemplate(template, { x: 1 })).toBe("y");
|
|
81
|
+
expect(renderTemplate(template, {})).toBe("");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("escapeXmlText", () => {
|
|
86
|
+
it("escapes the XML-significant trio and leaves quotes verbatim (omp semantics)", () => {
|
|
87
|
+
expect(escapeXmlText(`&<>'"`)).toBe("&<>'\"");
|
|
88
|
+
});
|
|
89
|
+
});
|