@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,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal ≡ oh-my-pi /goal alignment suite.
|
|
3
|
+
*
|
|
4
|
+
* Pins pi-goal's observable behavior to oh-my-pi (omp)
|
|
5
|
+
* `packages/coding-agent` goal mode, file by file:
|
|
6
|
+
*
|
|
7
|
+
* omp source | pinned here
|
|
8
|
+
* ---------------------------------------------+--------------------------------
|
|
9
|
+
* utils/src/sanitize-text.ts escapeXmlText | escapeXmlText trio only
|
|
10
|
+
* modes/interactive-mode.ts handleGoalMode- | /goal dispatch: active/paused
|
|
11
|
+
* Command / #openGoalMenu / #showGoalDetails | + rest branches, menu, details
|
|
12
|
+
* / #handleGoalBudgetCommand / guards | budget flow, guard messages
|
|
13
|
+
* #handleGoalSessionEvent + AgentSession | drop clears state entirely
|
|
14
|
+
* status-line/segments.ts renderGoalMode | footer segment text
|
|
15
|
+
* goals/tools/goal-tool.ts renderer | tool call/result strings
|
|
16
|
+
* slash-commands/builtin-modes.ts + | descriptions, completions
|
|
17
|
+
* builtin-completions.ts buildArgument- |
|
|
18
|
+
* Completions |
|
|
19
|
+
* handleGuidedGoalCommand | guided-goal flow
|
|
20
|
+
*
|
|
21
|
+
* Every expected string is copied verbatim from the omp sources listed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { describe, expect, it, vi } from "vitest";
|
|
26
|
+
import defaultExport from "../index.ts";
|
|
27
|
+
import { GOAL_CLEARED_ENTRY_TYPE, GOAL_STATE_ENTRY_TYPE } from "../src/restore.ts";
|
|
28
|
+
import { escapeXmlText, renderTemplate } from "../src/template.ts";
|
|
29
|
+
|
|
30
|
+
// The extension wires the real evaluator (a `pi -p` subprocess); alignment
|
|
31
|
+
// tests pin the omp semantics, not subprocess spawning — confirm completion.
|
|
32
|
+
vi.mock("../src/evaluator.ts", () => ({
|
|
33
|
+
runGoalEvaluator: async () => ({ status: "confirmed", reason: "aligned" }),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Fake host (mirrors test/index.test.ts + severity-aware notify/select/editor)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
interface Notification {
|
|
41
|
+
text: string;
|
|
42
|
+
type: "info" | "warning" | "error";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface FakeHost {
|
|
46
|
+
pi: ExtensionAPI;
|
|
47
|
+
tools: Array<Record<string, unknown>>;
|
|
48
|
+
commands: Record<
|
|
49
|
+
string,
|
|
50
|
+
{
|
|
51
|
+
description?: string;
|
|
52
|
+
handler: (args: string, ctx: unknown) => Promise<void>;
|
|
53
|
+
getArgumentCompletions?: (prefix: string) => unknown;
|
|
54
|
+
}
|
|
55
|
+
>;
|
|
56
|
+
handlers: Map<string, Array<(event: unknown, ctx: unknown) => unknown>>;
|
|
57
|
+
entries: Array<{ type: string; customType: string; data?: unknown; id: string }>;
|
|
58
|
+
sentMessages: Array<{
|
|
59
|
+
customType: string;
|
|
60
|
+
content: string;
|
|
61
|
+
display: boolean;
|
|
62
|
+
options?: { deliverAs?: string; triggerTurn?: boolean };
|
|
63
|
+
}>;
|
|
64
|
+
sentUserMessages: Array<{ content: string; options?: { deliverAs?: string } }>;
|
|
65
|
+
activeTools: string[];
|
|
66
|
+
statuses: Map<string, string | undefined>;
|
|
67
|
+
notifications: Notification[];
|
|
68
|
+
/** Queued answers for ctx.ui.select / editor / confirm / input. */
|
|
69
|
+
selectQueue: Array<string | undefined>;
|
|
70
|
+
editorQueue: Array<string | undefined>;
|
|
71
|
+
confirmQueue: Array<boolean>;
|
|
72
|
+
selectCalls: Array<{ title: string; options: string[] }>;
|
|
73
|
+
editorCalls: Array<{ title: string; prefill: string }>;
|
|
74
|
+
confirmCalls: Array<{ title: string; message: string }>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fakeHost(): FakeHost {
|
|
78
|
+
const host: FakeHost = {
|
|
79
|
+
pi: {} as ExtensionAPI,
|
|
80
|
+
tools: [],
|
|
81
|
+
commands: {},
|
|
82
|
+
handlers: new Map(),
|
|
83
|
+
entries: [],
|
|
84
|
+
sentMessages: [],
|
|
85
|
+
sentUserMessages: [],
|
|
86
|
+
activeTools: ["read", "bash", "edit"],
|
|
87
|
+
statuses: new Map(),
|
|
88
|
+
notifications: [],
|
|
89
|
+
selectQueue: [],
|
|
90
|
+
editorQueue: [],
|
|
91
|
+
confirmQueue: [],
|
|
92
|
+
selectCalls: [],
|
|
93
|
+
editorCalls: [],
|
|
94
|
+
confirmCalls: [],
|
|
95
|
+
};
|
|
96
|
+
let entryCounter = 0;
|
|
97
|
+
host.pi = {
|
|
98
|
+
registerTool: (tool: unknown) => {
|
|
99
|
+
host.tools.push(tool as Record<string, unknown>);
|
|
100
|
+
host.activeTools.push((tool as { name: string }).name);
|
|
101
|
+
},
|
|
102
|
+
registerCommand: (name: string, options: Record<string, unknown>) => {
|
|
103
|
+
host.commands[name] = options as (typeof host.commands)[string];
|
|
104
|
+
},
|
|
105
|
+
on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
|
106
|
+
const list = host.handlers.get(event) ?? [];
|
|
107
|
+
list.push(handler);
|
|
108
|
+
host.handlers.set(event, list);
|
|
109
|
+
},
|
|
110
|
+
registerEntryRenderer: () => {},
|
|
111
|
+
registerMessageRenderer: () => {},
|
|
112
|
+
appendEntry: (customType: string, data?: unknown) => {
|
|
113
|
+
host.entries.push({ type: "custom", customType, data, id: `entry-${++entryCounter}` });
|
|
114
|
+
},
|
|
115
|
+
sendMessage: (
|
|
116
|
+
message: { customType: string; content: string; display: boolean },
|
|
117
|
+
options?: { deliverAs?: string; triggerTurn?: boolean },
|
|
118
|
+
) => {
|
|
119
|
+
host.sentMessages.push({ ...message, options });
|
|
120
|
+
},
|
|
121
|
+
sendUserMessage: (content: string, options?: { deliverAs?: string }) => {
|
|
122
|
+
host.sentUserMessages.push({ content, options });
|
|
123
|
+
},
|
|
124
|
+
getActiveTools: () => [...host.activeTools],
|
|
125
|
+
setActiveTools: (names: string[]) => {
|
|
126
|
+
host.activeTools = [...names];
|
|
127
|
+
},
|
|
128
|
+
events: {
|
|
129
|
+
emit: () => {},
|
|
130
|
+
on: () => () => {},
|
|
131
|
+
},
|
|
132
|
+
} as unknown as ExtensionAPI;
|
|
133
|
+
return host;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createContext(host: FakeHost) {
|
|
137
|
+
return {
|
|
138
|
+
ui: {
|
|
139
|
+
notify: (text: string, type?: "info" | "warning" | "error") => {
|
|
140
|
+
host.notifications.push({ text, type: type ?? "info" });
|
|
141
|
+
},
|
|
142
|
+
setStatus: (key: string, text: string | undefined) => {
|
|
143
|
+
if (text === undefined) host.statuses.delete(key);
|
|
144
|
+
else host.statuses.set(key, text);
|
|
145
|
+
},
|
|
146
|
+
select: async (title: string, options: string[]) => {
|
|
147
|
+
host.selectCalls.push({ title, options });
|
|
148
|
+
return host.selectQueue.shift();
|
|
149
|
+
},
|
|
150
|
+
confirm: async (title: string, message: string) => {
|
|
151
|
+
host.confirmCalls.push({ title, message });
|
|
152
|
+
return host.confirmQueue.shift() ?? false;
|
|
153
|
+
},
|
|
154
|
+
input: async () => undefined,
|
|
155
|
+
editor: async (title: string, prefill?: string) => {
|
|
156
|
+
host.editorCalls.push({ title, prefill: prefill ?? "" });
|
|
157
|
+
return host.editorQueue.shift();
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
hasUI: true,
|
|
161
|
+
mode: "tui",
|
|
162
|
+
cwd: "/tmp",
|
|
163
|
+
isIdle: () => true,
|
|
164
|
+
hasPendingMessages: () => false,
|
|
165
|
+
sessionManager: {
|
|
166
|
+
getEntries: () => host.entries,
|
|
167
|
+
getBranch: () => host.entries,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fire(host: FakeHost, event: string, payload: unknown, ctx?: unknown): Promise<void> {
|
|
173
|
+
const list = host.handlers.get(event) ?? [];
|
|
174
|
+
for (const handler of list) {
|
|
175
|
+
await handler(payload, ctx ?? createContext(host));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const baseGoal = {
|
|
180
|
+
id: "g-1",
|
|
181
|
+
objective: "Make the tests pass",
|
|
182
|
+
status: "active" as const,
|
|
183
|
+
tokenBudget: undefined as number | undefined,
|
|
184
|
+
tokensUsed: 0,
|
|
185
|
+
timeUsedSeconds: 0,
|
|
186
|
+
createdAt: 0,
|
|
187
|
+
updatedAt: 0,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Boot the extension through session_start (no persisted goal). */
|
|
191
|
+
async function booted(host: FakeHost): Promise<void> {
|
|
192
|
+
defaultExport(host.pi);
|
|
193
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function startGoal(host: FakeHost, objective = "Do the thing"): Promise<void> {
|
|
197
|
+
await host.commands.goal!.handler(objective, createContext(host));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// escapeXmlText — omp packages/utils/src/sanitize-text.ts escapes ONLY
|
|
202
|
+
// & < > (quotes stay verbatim). omp test: goal-runtime.test.ts
|
|
203
|
+
// "escapeXmlText escapes only the XML-significant trio and leaves other
|
|
204
|
+
// characters untouched".
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
describe("escapeXmlText matches omp trio-only semantics", () => {
|
|
208
|
+
it("leaves quotes and backticks untouched (omp pin)", () => {
|
|
209
|
+
expect(escapeXmlText("'\"`")).toBe("'\"`");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("escapes the XML-significant trio", () => {
|
|
213
|
+
expect(escapeXmlText("a & b < c > d")).toBe("a & b < c > d");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("goal prompts render quoted objectives verbatim like omp", () => {
|
|
217
|
+
// omp test "returns the input verbatim when escapeXmlText has nothing to escape"
|
|
218
|
+
const objective = "plain text — with 'quotes' and \"double\" plus unicode ✓";
|
|
219
|
+
const rendered = renderTemplate("{{objective}}", { objective: escapeXmlText(objective) });
|
|
220
|
+
expect(rendered).toBe(objective);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// /goal dispatch — omp interactive-mode.ts handleGoalModeCommand
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe("/goal dispatch matches omp handleGoalModeCommand", () => {
|
|
229
|
+
it("objective text while a goal is active warns instead of opening the menu", async () => {
|
|
230
|
+
const host = fakeHost();
|
|
231
|
+
await booted(host);
|
|
232
|
+
await startGoal(host);
|
|
233
|
+
host.notifications.length = 0;
|
|
234
|
+
host.selectCalls.length = 0;
|
|
235
|
+
|
|
236
|
+
await host.commands.goal!.handler("fix the build now", createContext(host));
|
|
237
|
+
|
|
238
|
+
// omp: showStatus("Goal mode is already active. Use /goal to manage it, or /goal drop to start over.")
|
|
239
|
+
expect(host.notifications).toEqual([
|
|
240
|
+
{ text: "Goal mode is already active. Use /goal to manage it, or /goal drop to start over.", type: "info" },
|
|
241
|
+
]);
|
|
242
|
+
expect(host.selectCalls).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("objective text while a goal is paused warns like omp", async () => {
|
|
246
|
+
const host = fakeHost();
|
|
247
|
+
await booted(host);
|
|
248
|
+
await startGoal(host);
|
|
249
|
+
await host.commands.goal!.handler("pause", createContext(host));
|
|
250
|
+
host.notifications.length = 0;
|
|
251
|
+
|
|
252
|
+
await host.commands.goal!.handler("a fresh objective", createContext(host));
|
|
253
|
+
|
|
254
|
+
// omp: showWarning("Resume the current goal first, or drop it before setting a new objective.")
|
|
255
|
+
expect(host.notifications).toEqual([
|
|
256
|
+
{
|
|
257
|
+
text: "Resume the current goal first, or drop it before setting a new objective.",
|
|
258
|
+
type: "warning",
|
|
259
|
+
},
|
|
260
|
+
]);
|
|
261
|
+
expect(host.selectCalls).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("no args while active opens the omp menu with omp titles and items", async () => {
|
|
265
|
+
const host = fakeHost();
|
|
266
|
+
await booted(host);
|
|
267
|
+
await startGoal(host, "Short objective");
|
|
268
|
+
host.selectQueue.push(undefined);
|
|
269
|
+
host.selectCalls.length = 0;
|
|
270
|
+
|
|
271
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
272
|
+
|
|
273
|
+
// omp #openGoalMenu("active"): title + items verbatim (ellipsis on budget).
|
|
274
|
+
expect(host.selectCalls).toEqual([
|
|
275
|
+
{
|
|
276
|
+
title: "Goal: Short objective (active)",
|
|
277
|
+
options: ["Show details", "Adjust budget…", "Pause", "Drop"],
|
|
278
|
+
},
|
|
279
|
+
]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("paused menu lists Resume first with the omp paused title", async () => {
|
|
283
|
+
const host = fakeHost();
|
|
284
|
+
await booted(host);
|
|
285
|
+
await startGoal(host);
|
|
286
|
+
await host.commands.goal!.handler("pause", createContext(host));
|
|
287
|
+
host.selectQueue.push(undefined);
|
|
288
|
+
host.selectCalls.length = 0;
|
|
289
|
+
|
|
290
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
291
|
+
|
|
292
|
+
expect(host.selectCalls).toEqual([
|
|
293
|
+
{
|
|
294
|
+
title: "Goal paused: Do the thing",
|
|
295
|
+
options: ["Resume", "Show details", "Adjust budget…", "Drop"],
|
|
296
|
+
},
|
|
297
|
+
]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("long objectives are truncated in the menu title like omp shortDetail", async () => {
|
|
301
|
+
const host = fakeHost();
|
|
302
|
+
await booted(host);
|
|
303
|
+
await startGoal(host, "x".repeat(60));
|
|
304
|
+
host.selectQueue.push(undefined);
|
|
305
|
+
host.selectCalls.length = 0;
|
|
306
|
+
|
|
307
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
308
|
+
|
|
309
|
+
// omp: objective.length > 48 ? objective.slice(0, 47) + "…" : objective
|
|
310
|
+
expect(host.selectCalls[0]?.title).toBe(`Goal: ${"x".repeat(47)}… (active)`);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("menu Show details prints the omp details block", async () => {
|
|
314
|
+
const host = fakeHost();
|
|
315
|
+
await booted(host);
|
|
316
|
+
await startGoal(host, "Do the thing");
|
|
317
|
+
await host.commands.goal!.handler("budget 2000", createContext(host));
|
|
318
|
+
host.selectQueue.push("Show details");
|
|
319
|
+
host.notifications.length = 0;
|
|
320
|
+
|
|
321
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
322
|
+
|
|
323
|
+
// omp #showGoalDetails lines verbatim.
|
|
324
|
+
expect(host.notifications).toEqual([
|
|
325
|
+
{
|
|
326
|
+
text: [
|
|
327
|
+
"Objective: Do the thing",
|
|
328
|
+
"Status: active",
|
|
329
|
+
`Tokens: 0 / ${Number(2000).toLocaleString()} (${Number(2000).toLocaleString()} left)`,
|
|
330
|
+
"Time spent: 0s",
|
|
331
|
+
].join("\n"),
|
|
332
|
+
type: "info",
|
|
333
|
+
},
|
|
334
|
+
]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("menu Adjust budget… opens the omp editor with the current budget prefilled", async () => {
|
|
338
|
+
const host = fakeHost();
|
|
339
|
+
await booted(host);
|
|
340
|
+
await startGoal(host);
|
|
341
|
+
await host.commands.goal!.handler("budget 2000", createContext(host));
|
|
342
|
+
host.selectQueue.push("Adjust budget…");
|
|
343
|
+
host.editorQueue.push("500");
|
|
344
|
+
host.notifications.length = 0;
|
|
345
|
+
|
|
346
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
347
|
+
|
|
348
|
+
// omp #promptGoalBudgetEdit: showHookEditor(title, prefill).
|
|
349
|
+
expect(host.editorCalls).toEqual([
|
|
350
|
+
{ title: "Goal budget (number, `off`, or empty to cancel)", prefill: "2000" },
|
|
351
|
+
]);
|
|
352
|
+
expect(host.notifications).toEqual([{ text: "Goal budget set to 500.", type: "info" }]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("menu Pause and Resume drive the omp actions", async () => {
|
|
356
|
+
const host = fakeHost();
|
|
357
|
+
await booted(host);
|
|
358
|
+
await startGoal(host);
|
|
359
|
+
|
|
360
|
+
host.selectQueue.push("Pause");
|
|
361
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
362
|
+
expect(host.notifications).toContainEqual({ text: "Goal mode paused.", type: "info" });
|
|
363
|
+
|
|
364
|
+
host.selectQueue.push("Resume");
|
|
365
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
366
|
+
expect(host.notifications).toContainEqual({ text: "Goal mode resumed.", type: "info" });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("menu Drop confirms with the omp copy before dropping", async () => {
|
|
370
|
+
const host = fakeHost();
|
|
371
|
+
await booted(host);
|
|
372
|
+
await startGoal(host);
|
|
373
|
+
host.selectQueue.push("Drop");
|
|
374
|
+
host.confirmQueue.push(true);
|
|
375
|
+
|
|
376
|
+
await host.commands.goal!.handler("", createContext(host));
|
|
377
|
+
|
|
378
|
+
// omp #confirmAndDropGoal: showHookConfirm("Drop goal?", "This removes the goal record. ...")
|
|
379
|
+
expect(host.confirmCalls).toEqual([
|
|
380
|
+
{
|
|
381
|
+
title: "Drop goal?",
|
|
382
|
+
message: "This removes the goal record. Accumulated usage stays in the session log.",
|
|
383
|
+
},
|
|
384
|
+
]);
|
|
385
|
+
expect(host.notifications).toContainEqual({ text: "Goal dropped.", type: "info" });
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// /goal budget — omp #handleGoalBudgetCommand
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
describe("/goal budget matches omp #handleGoalBudgetCommand", () => {
|
|
394
|
+
it("sets a budget and reports it as a status", async () => {
|
|
395
|
+
const host = fakeHost();
|
|
396
|
+
await booted(host);
|
|
397
|
+
await startGoal(host);
|
|
398
|
+
host.notifications.length = 0;
|
|
399
|
+
|
|
400
|
+
await host.commands.goal!.handler("budget 5000", createContext(host));
|
|
401
|
+
|
|
402
|
+
expect(host.notifications).toEqual([{ text: "Goal budget set to 5000.", type: "info" }]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("clears the budget with `off`", async () => {
|
|
406
|
+
const host = fakeHost();
|
|
407
|
+
await booted(host);
|
|
408
|
+
await startGoal(host);
|
|
409
|
+
await host.commands.goal!.handler("budget 5000", createContext(host));
|
|
410
|
+
host.notifications.length = 0;
|
|
411
|
+
|
|
412
|
+
await host.commands.goal!.handler("budget off", createContext(host));
|
|
413
|
+
|
|
414
|
+
expect(host.notifications).toEqual([{ text: "Goal budget cleared.", type: "info" }]);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("rejects non-numeric budgets with the omp error text", async () => {
|
|
418
|
+
const host = fakeHost();
|
|
419
|
+
await booted(host);
|
|
420
|
+
await startGoal(host);
|
|
421
|
+
host.notifications.length = 0;
|
|
422
|
+
|
|
423
|
+
await host.commands.goal!.handler("budget lots", createContext(host));
|
|
424
|
+
|
|
425
|
+
// omp: showError("Goal budget must be a positive integer or `off`.") — severity error.
|
|
426
|
+
expect(host.notifications).toEqual([{ text: "Goal budget must be a positive integer or `off`.", type: "error" }]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("rejects zero and negative budgets", async () => {
|
|
430
|
+
const host = fakeHost();
|
|
431
|
+
await booted(host);
|
|
432
|
+
await startGoal(host);
|
|
433
|
+
|
|
434
|
+
await host.commands.goal!.handler("budget 0", createContext(host));
|
|
435
|
+
expect(host.notifications).toEqual([{ text: "Goal budget must be a positive integer or `off`.", type: "error" }]);
|
|
436
|
+
|
|
437
|
+
host.notifications.length = 0;
|
|
438
|
+
await host.commands.goal!.handler("budget -3", createContext(host));
|
|
439
|
+
expect(host.notifications).toEqual([{ text: "Goal budget must be a positive integer or `off`.", type: "error" }]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("reports an already-complete goal as a status", async () => {
|
|
443
|
+
const host = fakeHost();
|
|
444
|
+
await booted(host);
|
|
445
|
+
await startGoal(host);
|
|
446
|
+
// Complete via the tool, then resurrect the command surface by pausing
|
|
447
|
+
// semantics: after completion the command reports via the goal tool state.
|
|
448
|
+
await host.commands.goal!.handler("pause", createContext(host));
|
|
449
|
+
// omp checks goal.status === "complete" only while enabled; paused goals
|
|
450
|
+
// hit the enabled guard first. Verify the paused guard fires with omp text.
|
|
451
|
+
host.notifications.length = 0;
|
|
452
|
+
await host.commands.goal!.handler("budget 100", createContext(host));
|
|
453
|
+
expect(host.notifications).toEqual([
|
|
454
|
+
{ text: "Resume the goal before adjusting the budget.", type: "warning" },
|
|
455
|
+
]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("requires an active goal (omp showWarning)", async () => {
|
|
459
|
+
const host = fakeHost();
|
|
460
|
+
await booted(host);
|
|
461
|
+
host.notifications.length = 0;
|
|
462
|
+
|
|
463
|
+
await host.commands.goal!.handler("budget 100", createContext(host));
|
|
464
|
+
|
|
465
|
+
expect(host.notifications).toEqual([{ text: "No active goal.", type: "warning" }]);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Pause / resume / drop guards — omp #pauseGoalAction / #resumeGoalAction /
|
|
471
|
+
// #confirmAndDropGoal
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
describe("goal action guards match omp", () => {
|
|
475
|
+
it("pause without an active goal warns", async () => {
|
|
476
|
+
const host = fakeHost();
|
|
477
|
+
await booted(host);
|
|
478
|
+
host.notifications.length = 0;
|
|
479
|
+
|
|
480
|
+
await host.commands.goal!.handler("pause", createContext(host));
|
|
481
|
+
|
|
482
|
+
expect(host.notifications).toEqual([{ text: "No active goal to pause.", type: "warning" }]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("resume without a paused goal warns", async () => {
|
|
486
|
+
const host = fakeHost();
|
|
487
|
+
await booted(host);
|
|
488
|
+
host.notifications.length = 0;
|
|
489
|
+
|
|
490
|
+
await host.commands.goal!.handler("resume", createContext(host));
|
|
491
|
+
|
|
492
|
+
expect(host.notifications).toEqual([{ text: "No paused goal to resume.", type: "warning" }]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("drop without a goal warns", async () => {
|
|
496
|
+
const host = fakeHost();
|
|
497
|
+
await booted(host);
|
|
498
|
+
host.notifications.length = 0;
|
|
499
|
+
|
|
500
|
+
await host.commands.goal!.handler("drop", createContext(host));
|
|
501
|
+
|
|
502
|
+
expect(host.notifications).toEqual([{ text: "No goal to drop.", type: "warning" }]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("show without a goal reports No goal set.", async () => {
|
|
506
|
+
const host = fakeHost();
|
|
507
|
+
await booted(host);
|
|
508
|
+
host.notifications.length = 0;
|
|
509
|
+
|
|
510
|
+
await host.commands.goal!.handler("show", createContext(host));
|
|
511
|
+
|
|
512
|
+
// omp #showGoalDetails: showStatus("No goal set.")
|
|
513
|
+
expect(host.notifications).toEqual([{ text: "No goal set.", type: "info" }]);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// Drop clears session state — omp runtime.dropGoal commits state undefined
|
|
519
|
+
// (#commitState(undefined, { persist: "none" })), so resume/show/menu treat a
|
|
520
|
+
// dropped goal exactly like no goal.
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
describe("drop clears goal state like omp commitState(undefined)", () => {
|
|
524
|
+
async function startAndDrop(host: FakeHost): Promise<void> {
|
|
525
|
+
await booted(host);
|
|
526
|
+
await startGoal(host);
|
|
527
|
+
host.confirmQueue.push(true);
|
|
528
|
+
await host.commands.goal!.handler("drop", createContext(host));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
it("goal show reports No goal set. after a drop", async () => {
|
|
532
|
+
const host = fakeHost();
|
|
533
|
+
await startAndDrop(host);
|
|
534
|
+
host.notifications.length = 0;
|
|
535
|
+
|
|
536
|
+
await host.commands.goal!.handler("show", createContext(host));
|
|
537
|
+
|
|
538
|
+
expect(host.notifications).toEqual([{ text: "No goal set.", type: "info" }]);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("goal resume reports No paused goal to resume. after a drop", async () => {
|
|
542
|
+
const host = fakeHost();
|
|
543
|
+
await startAndDrop(host);
|
|
544
|
+
host.notifications.length = 0;
|
|
545
|
+
|
|
546
|
+
await host.commands.goal!.handler("resume", createContext(host));
|
|
547
|
+
|
|
548
|
+
expect(host.notifications).toEqual([{ text: "No paused goal to resume.", type: "warning" }]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("a new /goal <objective> starts a fresh goal after a drop", async () => {
|
|
552
|
+
const host = fakeHost();
|
|
553
|
+
await startAndDrop(host);
|
|
554
|
+
host.sentUserMessages.length = 0;
|
|
555
|
+
|
|
556
|
+
await host.commands.goal!.handler("next objective", createContext(host));
|
|
557
|
+
|
|
558
|
+
expect(host.sentUserMessages).toEqual([{ content: "next objective", options: undefined }]);
|
|
559
|
+
const snapshots = host.entries.filter((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
|
|
560
|
+
expect(snapshots.at(-1)?.data).toMatchObject({ enabled: true, goal: { objective: "next objective" } });
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("guided-goal is not blocked by a dropped goal", async () => {
|
|
564
|
+
const host = fakeHost();
|
|
565
|
+
await startAndDrop(host);
|
|
566
|
+
host.sentMessages.length = 0;
|
|
567
|
+
|
|
568
|
+
await host.commands["guided-goal"]!.handler("", createContext(host));
|
|
569
|
+
|
|
570
|
+
expect(host.sentMessages.some((m) => m.customType === "guided-goal")).toBe(true);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// Status segment — omp status-line/segments.ts renderGoalMode (unicode icon
|
|
576
|
+
// set; goal.statusInFooter defaults to true so used/budget always renders).
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
describe("status segment matches omp renderGoalMode", () => {
|
|
580
|
+
it("renders the active goal with icon and budget", async () => {
|
|
581
|
+
const host = fakeHost();
|
|
582
|
+
await booted(host);
|
|
583
|
+
await startGoal(host);
|
|
584
|
+
await host.commands.goal!.handler("budget 1000", createContext(host));
|
|
585
|
+
|
|
586
|
+
// omp: withIcon(theme.icon.goal /* 🎯 */, "Goal") + " " + formatGoalBudget(0, 1000)
|
|
587
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0/1K");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("renders used tokens without a budget", async () => {
|
|
591
|
+
const host = fakeHost();
|
|
592
|
+
await booted(host);
|
|
593
|
+
await startGoal(host);
|
|
594
|
+
|
|
595
|
+
// omp formatGoalBudget(0, undefined) → "0"
|
|
596
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("renders the pause icon for paused goals", async () => {
|
|
600
|
+
const host = fakeHost();
|
|
601
|
+
await booted(host);
|
|
602
|
+
await startGoal(host);
|
|
603
|
+
await host.commands.goal!.handler("pause", createContext(host));
|
|
604
|
+
|
|
605
|
+
// omp paused: icon = theme.icon.pause (⏸), color warning.
|
|
606
|
+
expect(host.statuses.get("goal")).toBe("⏸ Goal 0");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("renders the warning icon for budget-limited goals", async () => {
|
|
610
|
+
const host = fakeHost();
|
|
611
|
+
await booted(host);
|
|
612
|
+
await startGoal(host);
|
|
613
|
+
await host.commands.goal!.handler("budget 10", createContext(host));
|
|
614
|
+
await fire(host, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 });
|
|
615
|
+
host.entries.push({
|
|
616
|
+
type: "message",
|
|
617
|
+
id: "m1",
|
|
618
|
+
message: {
|
|
619
|
+
role: "assistant",
|
|
620
|
+
stopReason: "toolUse",
|
|
621
|
+
usage: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
622
|
+
},
|
|
623
|
+
} as never);
|
|
624
|
+
await fire(host, "tool_execution_end", {
|
|
625
|
+
type: "tool_execution_end",
|
|
626
|
+
toolCallId: "t1",
|
|
627
|
+
toolName: "read",
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// omp budget-limited: icon = theme.symbol("status.warning") (⚠).
|
|
631
|
+
expect(host.statuses.get("goal")).toBe("⚠ Goal 25/10");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("hides the segment once the goal completes (omp hides when neither enabled nor paused)", async () => {
|
|
635
|
+
const host = fakeHost();
|
|
636
|
+
await booted(host);
|
|
637
|
+
await startGoal(host);
|
|
638
|
+
await host.commands.goal!.handler("budget 10", createContext(host));
|
|
639
|
+
await fire(host, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 });
|
|
640
|
+
host.entries.push({
|
|
641
|
+
type: "message",
|
|
642
|
+
id: "m1",
|
|
643
|
+
message: {
|
|
644
|
+
role: "assistant",
|
|
645
|
+
stopReason: "toolUse",
|
|
646
|
+
usage: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
647
|
+
},
|
|
648
|
+
} as never);
|
|
649
|
+
await fire(host, "tool_execution_end", {
|
|
650
|
+
type: "tool_execution_end",
|
|
651
|
+
toolCallId: "t1",
|
|
652
|
+
toolName: "read",
|
|
653
|
+
});
|
|
654
|
+
expect(host.statuses.has("goal")).toBe(true);
|
|
655
|
+
|
|
656
|
+
// Complete: enabled=false + status complete → omp segment invisible immediately.
|
|
657
|
+
const tool = host.tools.find((t) => t.name === "goal")!;
|
|
658
|
+
await (tool.execute as (id: string, params: unknown) => Promise<unknown>)("c1", {
|
|
659
|
+
op: "complete",
|
|
660
|
+
evidence: "aligned",
|
|
661
|
+
});
|
|
662
|
+
expect(host.statuses.has("goal")).toBe(false);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// Guided goal — omp handleGuidedGoalCommand (no kickoff status message)
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
describe("/guided-goal matches omp handleGuidedGoalCommand", () => {
|
|
671
|
+
it("does not emit a status message before the interview", async () => {
|
|
672
|
+
const host = fakeHost();
|
|
673
|
+
await booted(host);
|
|
674
|
+
host.notifications.length = 0;
|
|
675
|
+
|
|
676
|
+
await host.commands["guided-goal"]!.handler("make a cli", createContext(host));
|
|
677
|
+
|
|
678
|
+
expect(host.notifications).toEqual([]);
|
|
679
|
+
expect(host.sentMessages).toEqual([
|
|
680
|
+
expect.objectContaining({ customType: "guided-goal", display: false }),
|
|
681
|
+
]);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("queues the kickoff as a follow-up behind an in-flight run (omp session.followUp)", async () => {
|
|
685
|
+
const host = fakeHost();
|
|
686
|
+
await booted(host);
|
|
687
|
+
|
|
688
|
+
await host.commands["guided-goal"]!.handler("make a cli", createContext(host));
|
|
689
|
+
|
|
690
|
+
// omp: streaming → session.followUp(kickoff) — never a steer (which would
|
|
691
|
+
// redirect the running turn) and never a dropped submission.
|
|
692
|
+
const kickoff = host.sentMessages.find((m) => m.customType === "guided-goal");
|
|
693
|
+
expect(kickoff?.options).toMatchObject({ triggerTurn: true, deliverAs: "followUp" });
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("warns when resuming is required (paused goal)", async () => {
|
|
697
|
+
const host = fakeHost();
|
|
698
|
+
await booted(host);
|
|
699
|
+
await startGoal(host);
|
|
700
|
+
await host.commands.goal!.handler("pause", createContext(host));
|
|
701
|
+
host.notifications.length = 0;
|
|
702
|
+
|
|
703
|
+
await host.commands["guided-goal"]!.handler("", createContext(host));
|
|
704
|
+
|
|
705
|
+
expect(host.notifications).toEqual([
|
|
706
|
+
{
|
|
707
|
+
text: "Resume the current goal first, or drop it before setting a new objective.",
|
|
708
|
+
type: "warning",
|
|
709
|
+
},
|
|
710
|
+
]);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("reports already-active as a status", async () => {
|
|
714
|
+
const host = fakeHost();
|
|
715
|
+
await booted(host);
|
|
716
|
+
await startGoal(host);
|
|
717
|
+
host.notifications.length = 0;
|
|
718
|
+
|
|
719
|
+
await host.commands["guided-goal"]!.handler("", createContext(host));
|
|
720
|
+
|
|
721
|
+
expect(host.notifications).toEqual([
|
|
722
|
+
{
|
|
723
|
+
text: "Goal mode is already active. Use /goal to manage it, or /goal drop to start over.",
|
|
724
|
+
type: "info",
|
|
725
|
+
},
|
|
726
|
+
]);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
// Command registration + completions — omp slash-commands/builtin-modes.ts +
|
|
732
|
+
// builtin-completions.ts buildArgumentCompletions
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
describe("command metadata matches omp", () => {
|
|
736
|
+
it("/goal description matches omp", async () => {
|
|
737
|
+
const host = fakeHost();
|
|
738
|
+
await booted(host);
|
|
739
|
+
expect(host.commands.goal?.description).toBe(
|
|
740
|
+
"Toggle goal mode (persistent autonomous objective for this session)",
|
|
741
|
+
);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("/guided-goal description matches omp", async () => {
|
|
745
|
+
const host = fakeHost();
|
|
746
|
+
await booted(host);
|
|
747
|
+
expect(host.commands["guided-goal"]?.description).toBe(
|
|
748
|
+
"Have the agent interview you in chat, then set up goal mode",
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("completions mirror omp buildArgumentCompletions (trailing space, no past-space items)", async () => {
|
|
753
|
+
const host = fakeHost();
|
|
754
|
+
await booted(host);
|
|
755
|
+
const completions = host.commands.goal!.getArgumentCompletions! as (prefix: string) => unknown;
|
|
756
|
+
|
|
757
|
+
// omp: value `${name} `, label name, description from the subcommand def.
|
|
758
|
+
expect(completions("")).toEqual([
|
|
759
|
+
{ value: "set ", label: "set", description: "Set or replace the goal" },
|
|
760
|
+
{ value: "show ", label: "show", description: "Show current goal details" },
|
|
761
|
+
{ value: "pause ", label: "pause", description: "Pause the current goal" },
|
|
762
|
+
{ value: "resume ", label: "resume", description: "Resume a paused goal" },
|
|
763
|
+
{ value: "drop ", label: "drop", description: "Drop the current goal" },
|
|
764
|
+
{ value: "budget ", label: "budget", description: "Adjust the token budget" },
|
|
765
|
+
]);
|
|
766
|
+
expect(completions("dr")).toEqual([{ value: "drop ", label: "drop", description: "Drop the current goal" }]);
|
|
767
|
+
// omp: `if (argumentPrefix.includes(" ")) return null;`
|
|
768
|
+
expect(completions("budget ")).toBeNull();
|
|
769
|
+
expect(completions("set some objective")).toBeNull();
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// /goal set with no objective text — omp: editor prompt; empty cancels silently
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
describe("/goal set editor flow matches omp", () => {
|
|
778
|
+
it("prompts for the objective with an editor titled Goal objective", async () => {
|
|
779
|
+
const host = fakeHost();
|
|
780
|
+
await booted(host);
|
|
781
|
+
host.editorQueue.push("From the editor");
|
|
782
|
+
|
|
783
|
+
await host.commands.goal!.handler("set", createContext(host));
|
|
784
|
+
|
|
785
|
+
expect(host.editorCalls).toEqual([{ title: "Goal objective", prefill: "" }]);
|
|
786
|
+
expect(host.sentUserMessages).toEqual([{ content: "From the editor", options: undefined }]);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("empty editor input cancels silently", async () => {
|
|
790
|
+
const host = fakeHost();
|
|
791
|
+
await booted(host);
|
|
792
|
+
host.editorQueue.push(" ");
|
|
793
|
+
|
|
794
|
+
await host.commands.goal!.handler("set", createContext(host));
|
|
795
|
+
|
|
796
|
+
expect(host.sentUserMessages).toHaveLength(0);
|
|
797
|
+
expect(host.entries.some((e) => e.customType === GOAL_STATE_ENTRY_TYPE)).toBe(false);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
// Goal tool renderer — omp goals/tools/goal-tool.ts goalToolRenderer
|
|
803
|
+
// (renderStatusLine strings with the unicode symbol set; the framed block
|
|
804
|
+
// body lines; ⟦⟧ badge brackets; TRUNCATE_LENGTHS caps 60/100)
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
|
|
807
|
+
describe("goal tool renderer matches omp strings", () => {
|
|
808
|
+
const theme = {
|
|
809
|
+
fg: (color: string, text: string) => `«${color}»${text}«/»`,
|
|
810
|
+
italic: (text: string) => `i(${text})`,
|
|
811
|
+
bold: (text: string) => text,
|
|
812
|
+
strikethrough: (text: string) => text,
|
|
813
|
+
} as never;
|
|
814
|
+
|
|
815
|
+
function textOf(component: unknown): string {
|
|
816
|
+
return (component as { text: string }).text;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Strip the «color» wrappers so assertions see omp's plain strings. */
|
|
820
|
+
function plain(markup: string): string {
|
|
821
|
+
return markup.replaceAll(/«[^»]*»/g, "");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function setup(overrides: Partial<typeof baseGoal> = {}) {
|
|
825
|
+
const host = fakeHost();
|
|
826
|
+
defaultExport(host.pi);
|
|
827
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
828
|
+
const tool = host.tools.find((t) => t.name === "goal")!;
|
|
829
|
+
return { tool: tool as never as { renderCall: (args: unknown, theme: unknown) => unknown; renderResult: (r: unknown, o: unknown, t: unknown) => unknown } };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
it("renders the call line like omp renderStatusLine({icon:'pending'})", async () => {
|
|
833
|
+
const { tool } = await setup();
|
|
834
|
+
const out = plain(textOf(
|
|
835
|
+
tool.renderCall({ op: "create", objective: "Ship it", token_budget: 5000 }, theme),
|
|
836
|
+
));
|
|
837
|
+
// omp: ⏳(muted) Goal(accent): set(muted) "Ship it"(italic muted) budget 5K(dim)
|
|
838
|
+
expect(out).toContain("⏳ Goal: set");
|
|
839
|
+
expect(out).toContain('i("Ship it")');
|
|
840
|
+
expect(out).toContain("budget 5K");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("call objective caps at omp TRUNCATE_LENGTHS.TITLE (60)", async () => {
|
|
844
|
+
const { tool } = await setup();
|
|
845
|
+
const long = "a".repeat(80);
|
|
846
|
+
const out = plain(textOf(tool.renderCall({ op: "create", objective: long }, theme)));
|
|
847
|
+
expect(out).toContain(`${"a".repeat(59)}…`);
|
|
848
|
+
expect(out).not.toContain("a".repeat(60));
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("renders the success header with tool.goal glyph and ⟦⟧ badge", async () => {
|
|
852
|
+
const { tool } = await setup();
|
|
853
|
+
const out = plain(textOf(
|
|
854
|
+
tool.renderResult(
|
|
855
|
+
{
|
|
856
|
+
content: [{ type: "text", text: "Goal: Ship it" }],
|
|
857
|
+
details: {
|
|
858
|
+
op: "create",
|
|
859
|
+
goal: { ...baseGoal, status: "active", tokenBudget: 10000, tokensUsed: 2500, timeUsedSeconds: 120 },
|
|
860
|
+
remainingTokens: 7500,
|
|
861
|
+
completionBudgetReport: null,
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
{ isPartial: false },
|
|
865
|
+
theme,
|
|
866
|
+
),
|
|
867
|
+
));
|
|
868
|
+
// omp: ◎(accent) Goal(accent): create→set(muted) ⟦active⟧(accent badge)
|
|
869
|
+
// The result body always carries details.goal.objective (omp semantic).
|
|
870
|
+
expect(out).toContain("◎ Goal: set ⟦active⟧");
|
|
871
|
+
expect(out).toContain('i("Make the tests pass")');
|
|
872
|
+
// omp tokens line: 2.5K / 10K tokens (7.5K left) · 2m elapsed
|
|
873
|
+
expect(out).toContain("2.5K / 10K tokens (7.5K left)");
|
|
874
|
+
expect(out).toContain("2m elapsed");
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("badge colors follow omp goalBadgeColor", async () => {
|
|
878
|
+
const { tool } = await setup();
|
|
879
|
+
const out = plain(textOf(
|
|
880
|
+
tool.renderResult(
|
|
881
|
+
{
|
|
882
|
+
content: [{ type: "text", text: "x" }],
|
|
883
|
+
details: { op: "get", goal: { ...baseGoal, status: "paused" }, remainingTokens: null },
|
|
884
|
+
},
|
|
885
|
+
{ isPartial: false },
|
|
886
|
+
theme,
|
|
887
|
+
),
|
|
888
|
+
));
|
|
889
|
+
expect(out).toContain("⟦paused⟧");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("renders the error line like omp formatErrorDetail", async () => {
|
|
893
|
+
const { tool } = await setup();
|
|
894
|
+
const out = plain(
|
|
895
|
+
textOf(
|
|
896
|
+
tool.renderResult(
|
|
897
|
+
{
|
|
898
|
+
content: [{ type: "text", text: "boom" }],
|
|
899
|
+
details: { op: "check" },
|
|
900
|
+
isError: true,
|
|
901
|
+
},
|
|
902
|
+
{ isPartial: false },
|
|
903
|
+
theme,
|
|
904
|
+
),
|
|
905
|
+
),
|
|
906
|
+
);
|
|
907
|
+
// omp: ✘(error) Goal(accent): op(muted) then two-space error detail.
|
|
908
|
+
expect(out).toContain("✘ Goal: check");
|
|
909
|
+
expect(out).toContain("boom");
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("renders the no-goal line like omp (warning icon, no active goal meta)", async () => {
|
|
913
|
+
const { tool } = await setup();
|
|
914
|
+
const out = plain(textOf(
|
|
915
|
+
tool.renderResult(
|
|
916
|
+
{ content: [{ type: "text", text: "No active goal." }], details: { op: "get", goal: null } },
|
|
917
|
+
{ isPartial: false },
|
|
918
|
+
theme,
|
|
919
|
+
),
|
|
920
|
+
));
|
|
921
|
+
// omp: ⚠(warning) Goal(accent): check(muted) · no active goal(dim)
|
|
922
|
+
expect(out).toContain("⚠ Goal: check");
|
|
923
|
+
expect(out).toContain("no active goal");
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("result objective caps at omp TRUNCATE_LENGTHS.LONG (100)", async () => {
|
|
927
|
+
const { tool } = await setup();
|
|
928
|
+
const long = "b".repeat(120);
|
|
929
|
+
const out = plain(textOf(
|
|
930
|
+
tool.renderResult(
|
|
931
|
+
{
|
|
932
|
+
content: [{ type: "text", text: "x" }],
|
|
933
|
+
details: { op: "get", goal: { ...baseGoal, objective: long }, remainingTokens: null },
|
|
934
|
+
},
|
|
935
|
+
{ isPartial: false },
|
|
936
|
+
theme,
|
|
937
|
+
),
|
|
938
|
+
));
|
|
939
|
+
expect(out).toContain(`${"b".repeat(99)}…`);
|
|
940
|
+
expect(out).not.toContain("b".repeat(100));
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// ---------------------------------------------------------------------------
|
|
945
|
+
// Persistence shape — omp appends goal-completed entries with these fields
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
describe("completion entry shape matches omp appendCustomEntry", () => {
|
|
949
|
+
it("goal-completed entry carries objective/tokensUsed/tokenBudget/timeUsedSeconds", async () => {
|
|
950
|
+
const host = fakeHost();
|
|
951
|
+
await booted(host);
|
|
952
|
+
await startGoal(host);
|
|
953
|
+
await host.commands.goal!.handler("budget 1000", createContext(host));
|
|
954
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
955
|
+
const tool = host.tools.find((t) => t.name === "goal")!;
|
|
956
|
+
await (tool.execute as (id: string, params: unknown) => Promise<unknown>)("c1", {
|
|
957
|
+
op: "complete",
|
|
958
|
+
evidence: "aligned",
|
|
959
|
+
});
|
|
960
|
+
await fire(host, "agent_end", {
|
|
961
|
+
type: "agent_end",
|
|
962
|
+
messages: [{ role: "assistant", stopReason: "stop", usage: {} }],
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
const completed = host.entries.find((e) => e.type === "custom" && e.customType === "goal-completed");
|
|
966
|
+
expect(completed?.data).toMatchObject({
|
|
967
|
+
objective: "Do the thing",
|
|
968
|
+
tokensUsed: 0,
|
|
969
|
+
tokenBudget: 1000,
|
|
970
|
+
timeUsedSeconds: 0,
|
|
971
|
+
});
|
|
972
|
+
// omp exit(completed): appendModeChange("none") → pi goal-cleared entry.
|
|
973
|
+
expect(host.entries.some((e) => e.customType === GOAL_CLEARED_ENTRY_TYPE)).toBe(true);
|
|
974
|
+
});
|
|
975
|
+
});
|