@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,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — extension wiring tests (fake host, pi-ask-user test style).
|
|
3
|
+
*
|
|
4
|
+
* Drives the default export through a fake ExtensionAPI to pin the event
|
|
5
|
+
* wiring: toolset toggling, context injection, continuation loop, suppression,
|
|
6
|
+
* interrupt pausing, completion exit, and restore.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
import defaultExport from "../index.ts";
|
|
12
|
+
import { GOAL_CLEARED_ENTRY_TYPE, GOAL_COMPLETED_ENTRY_TYPE, GOAL_STATE_ENTRY_TYPE } from "../src/restore.ts";
|
|
13
|
+
import type { Goal } from "../src/state.ts";
|
|
14
|
+
|
|
15
|
+
// The real evaluator spawns a `pi -p` subprocess; wiring tests pin event
|
|
16
|
+
// flow, not spawning — confirm every evaluation.
|
|
17
|
+
vi.mock("../src/evaluator.ts", () => ({
|
|
18
|
+
runGoalEvaluator: async () => ({ status: "confirmed", reason: "wiring" }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Fake host
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface FakeHost {
|
|
26
|
+
pi: ExtensionAPI;
|
|
27
|
+
tools: Array<Record<string, unknown>>;
|
|
28
|
+
commands: Record<
|
|
29
|
+
string,
|
|
30
|
+
{ handler: (args: string, ctx: unknown) => Promise<void>; getArgumentCompletions?: (prefix: string) => unknown }
|
|
31
|
+
>;
|
|
32
|
+
handlers: Map<string, Array<(event: unknown, ctx: unknown) => unknown>>;
|
|
33
|
+
entries: Array<{ type: string; customType: string; data?: unknown; id: string }>;
|
|
34
|
+
sentMessages: Array<{
|
|
35
|
+
customType: string;
|
|
36
|
+
content: string;
|
|
37
|
+
display: boolean;
|
|
38
|
+
options?: { deliverAs?: string; triggerTurn?: boolean };
|
|
39
|
+
}>;
|
|
40
|
+
sentUserMessages: Array<{ content: string; options?: { deliverAs?: string } }>;
|
|
41
|
+
emitted: Array<{ channel: string; data: unknown }>;
|
|
42
|
+
activeTools: string[];
|
|
43
|
+
statuses: Map<string, string | undefined>;
|
|
44
|
+
notifications: string[];
|
|
45
|
+
entryRenderers: Map<string, (entry: unknown, options: unknown, theme: unknown) => unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fakeHost(): FakeHost {
|
|
49
|
+
const host: FakeHost = {
|
|
50
|
+
pi: {} as ExtensionAPI,
|
|
51
|
+
tools: [],
|
|
52
|
+
commands: {},
|
|
53
|
+
handlers: new Map(),
|
|
54
|
+
entries: [],
|
|
55
|
+
sentMessages: [],
|
|
56
|
+
sentUserMessages: [],
|
|
57
|
+
emitted: [],
|
|
58
|
+
activeTools: ["read", "bash", "edit"],
|
|
59
|
+
statuses: new Map(),
|
|
60
|
+
notifications: [],
|
|
61
|
+
entryRenderers: new Map(),
|
|
62
|
+
};
|
|
63
|
+
let entryCounter = 0;
|
|
64
|
+
host.pi = {
|
|
65
|
+
registerTool: (tool: unknown) => {
|
|
66
|
+
host.tools.push(tool as Record<string, unknown>);
|
|
67
|
+
host.activeTools.push((tool as { name: string }).name);
|
|
68
|
+
},
|
|
69
|
+
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => {
|
|
70
|
+
host.commands[name] = options;
|
|
71
|
+
},
|
|
72
|
+
on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
|
73
|
+
const list = host.handlers.get(event) ?? [];
|
|
74
|
+
list.push(handler);
|
|
75
|
+
host.handlers.set(event, list);
|
|
76
|
+
},
|
|
77
|
+
registerEntryRenderer: (
|
|
78
|
+
customType: string,
|
|
79
|
+
renderer: (entry: unknown, options: unknown, theme: unknown) => unknown,
|
|
80
|
+
) => {
|
|
81
|
+
host.entryRenderers.set(customType, renderer);
|
|
82
|
+
},
|
|
83
|
+
registerMessageRenderer: () => {},
|
|
84
|
+
appendEntry: (customType: string, data?: unknown) => {
|
|
85
|
+
host.entries.push({ type: "custom", customType, data, id: `entry-${++entryCounter}` });
|
|
86
|
+
},
|
|
87
|
+
sendMessage: (
|
|
88
|
+
message: { customType: string; content: string; display: boolean },
|
|
89
|
+
options?: { deliverAs?: string; triggerTurn?: boolean },
|
|
90
|
+
) => {
|
|
91
|
+
host.sentMessages.push({ ...message, options });
|
|
92
|
+
},
|
|
93
|
+
sendUserMessage: (content: string, options?: { deliverAs?: string }) => {
|
|
94
|
+
host.sentUserMessages.push({ content, options });
|
|
95
|
+
},
|
|
96
|
+
getActiveTools: () => [...host.activeTools],
|
|
97
|
+
setActiveTools: (names: string[]) => {
|
|
98
|
+
host.activeTools = [...names];
|
|
99
|
+
},
|
|
100
|
+
events: {
|
|
101
|
+
emit: (channel: string, data: unknown) => {
|
|
102
|
+
host.emitted.push({ channel, data });
|
|
103
|
+
},
|
|
104
|
+
on: () => () => {},
|
|
105
|
+
},
|
|
106
|
+
} as unknown as ExtensionAPI;
|
|
107
|
+
return host;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createContext(host: FakeHost, overrides: { entries?: unknown[]; pending?: boolean; idle?: boolean } = {}) {
|
|
111
|
+
return {
|
|
112
|
+
ui: {
|
|
113
|
+
notify: (text: string) => {
|
|
114
|
+
host.notifications.push(text);
|
|
115
|
+
},
|
|
116
|
+
setStatus: (key: string, text: string | undefined) => {
|
|
117
|
+
if (text === undefined) host.statuses.delete(key);
|
|
118
|
+
else host.statuses.set(key, text);
|
|
119
|
+
},
|
|
120
|
+
confirm: async () => true,
|
|
121
|
+
select: async () => undefined,
|
|
122
|
+
input: async () => undefined,
|
|
123
|
+
editor: async () => undefined,
|
|
124
|
+
},
|
|
125
|
+
hasUI: true,
|
|
126
|
+
mode: "tui",
|
|
127
|
+
cwd: "/tmp",
|
|
128
|
+
isIdle: () => overrides.idle ?? true,
|
|
129
|
+
hasPendingMessages: () => overrides.pending ?? false,
|
|
130
|
+
sessionManager: {
|
|
131
|
+
getEntries: () => overrides.entries ?? host.entries,
|
|
132
|
+
getBranch: () => overrides.entries ?? host.entries,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function fire(host: FakeHost, event: string, payload: unknown, ctx?: unknown): Promise<void> {
|
|
138
|
+
const list = host.handlers.get(event) ?? [];
|
|
139
|
+
for (const handler of list) {
|
|
140
|
+
await handler(payload, ctx ?? createContext(host));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function goalTool(host: FakeHost): Record<string, unknown> {
|
|
145
|
+
const tool = host.tools.find((t) => t.name === "goal");
|
|
146
|
+
if (!tool) throw new Error("goal tool not registered");
|
|
147
|
+
return tool;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function callGoalTool(
|
|
151
|
+
host: FakeHost,
|
|
152
|
+
params: Record<string, unknown>,
|
|
153
|
+
): Promise<{ details: { goal?: Goal | null } }> {
|
|
154
|
+
const tool = goalTool(host);
|
|
155
|
+
const execute = tool.execute as (
|
|
156
|
+
id: string,
|
|
157
|
+
params: unknown,
|
|
158
|
+
signal: undefined,
|
|
159
|
+
onUpdate: undefined,
|
|
160
|
+
ctx: unknown,
|
|
161
|
+
) => Promise<{ details: { goal?: Goal | null } }>;
|
|
162
|
+
return execute("call-1", params, undefined, undefined, createContext(host));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function makeEntries(goal: Goal, enabled: boolean): unknown[] {
|
|
166
|
+
return [
|
|
167
|
+
{
|
|
168
|
+
type: "custom",
|
|
169
|
+
customType: GOAL_STATE_ENTRY_TYPE,
|
|
170
|
+
id: "e1",
|
|
171
|
+
data: { enabled, goal: { ...goal } },
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const baseGoal: Goal = {
|
|
177
|
+
id: "g-1",
|
|
178
|
+
objective: "Make the tests pass",
|
|
179
|
+
status: "active",
|
|
180
|
+
tokenBudget: undefined,
|
|
181
|
+
tokensUsed: 0,
|
|
182
|
+
timeUsedSeconds: 0,
|
|
183
|
+
createdAt: 0,
|
|
184
|
+
updatedAt: 0,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function assistantMessage(stopReason: string): unknown {
|
|
188
|
+
return { role: "assistant", stopReason, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Wiring
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
describe("pi-goal extension wiring", () => {
|
|
196
|
+
it("registers the goal tool, /goal, and /guided-goal", () => {
|
|
197
|
+
const host = fakeHost();
|
|
198
|
+
defaultExport(host.pi);
|
|
199
|
+
expect(host.tools.map((t) => t.name)).toContain("goal");
|
|
200
|
+
expect(host.commands.goal).toBeDefined();
|
|
201
|
+
expect(host.commands["guided-goal"]).toBeDefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("session_start with no goal removes the goal tool from the active set", async () => {
|
|
205
|
+
const host = fakeHost();
|
|
206
|
+
defaultExport(host.pi);
|
|
207
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
208
|
+
expect(host.activeTools).not.toContain("goal");
|
|
209
|
+
expect(host.activeTools).toContain("read");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("session_start restores a persisted active goal, re-adds the tool, then pauses it (omp onThreadResumed)", async () => {
|
|
213
|
+
const host = fakeHost();
|
|
214
|
+
defaultExport(host.pi);
|
|
215
|
+
const ctx = createContext(host, { entries: makeEntries(baseGoal, true) });
|
|
216
|
+
await fire(host, "session_start", { type: "session_start", reason: "resume" }, ctx);
|
|
217
|
+
|
|
218
|
+
expect(host.activeTools).toContain("goal");
|
|
219
|
+
// Cold resume auto-pauses active goals and persists that.
|
|
220
|
+
const pauseEntry = [...host.entries].reverse().find((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
|
|
221
|
+
expect(pauseEntry?.data).toMatchObject({ enabled: false, goal: { status: "paused" } });
|
|
222
|
+
// omp footer segment: pause icon + usage.
|
|
223
|
+
expect(host.statuses.get("goal")).toBe("⏸ Goal 0");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("/goal <objective> creates the goal, toggles tools, and submits the objective", async () => {
|
|
227
|
+
const host = fakeHost();
|
|
228
|
+
defaultExport(host.pi);
|
|
229
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
230
|
+
|
|
231
|
+
await host.commands.goal!.handler("Rewrite the docs", createContext(host));
|
|
232
|
+
|
|
233
|
+
expect(host.activeTools).toContain("goal");
|
|
234
|
+
const stateEntry = host.entries.find((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
|
|
235
|
+
expect(stateEntry?.data).toMatchObject({
|
|
236
|
+
enabled: true,
|
|
237
|
+
goal: { objective: "Rewrite the docs", status: "active" },
|
|
238
|
+
});
|
|
239
|
+
expect(host.sentUserMessages).toHaveLength(1);
|
|
240
|
+
expect(host.sentUserMessages[0]?.content).toBe("Rewrite the docs");
|
|
241
|
+
expect(host.emitted.some((e) => e.channel === "goal_updated")).toBe(true);
|
|
242
|
+
// omp footer segment: goal icon + usage.
|
|
243
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("before_agent_start injects the hidden goal-mode-context message only while a goal is active", async () => {
|
|
247
|
+
const host = fakeHost();
|
|
248
|
+
defaultExport(host.pi);
|
|
249
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
250
|
+
|
|
251
|
+
let result: unknown;
|
|
252
|
+
const handler = host.handlers.get("before_agent_start")?.[0];
|
|
253
|
+
result = await handler!({ prompt: "hi", systemPrompt: "" }, createContext(host));
|
|
254
|
+
expect(result).toBeUndefined();
|
|
255
|
+
|
|
256
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
257
|
+
result = await handler!({ prompt: "hi", systemPrompt: "" }, createContext(host));
|
|
258
|
+
expect(result).toMatchObject({ message: { customType: "goal-mode-context", display: false } });
|
|
259
|
+
const content = (result as { message: { content: string } }).message.content;
|
|
260
|
+
expect(content).toContain("<goal_context>");
|
|
261
|
+
expect(content).toContain("Do the thing");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("agent_end schedules a continuation when the goal is still active", async () => {
|
|
265
|
+
const host = fakeHost();
|
|
266
|
+
defaultExport(host.pi);
|
|
267
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
268
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
269
|
+
host.sentMessages.length = 0;
|
|
270
|
+
|
|
271
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
272
|
+
await fire(host, "tool_execution_end", {
|
|
273
|
+
type: "tool_execution_end",
|
|
274
|
+
toolCallId: "t1",
|
|
275
|
+
toolName: "bash",
|
|
276
|
+
result: {},
|
|
277
|
+
isError: false,
|
|
278
|
+
});
|
|
279
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("toolUse")] });
|
|
280
|
+
|
|
281
|
+
const continuation = host.sentMessages.find((m) => m.customType === "goal-continuation");
|
|
282
|
+
expect(continuation).toBeDefined();
|
|
283
|
+
expect(continuation?.display).toBe(false);
|
|
284
|
+
expect(continuation?.options).toMatchObject({ triggerTurn: true, deliverAs: "followUp" });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("suppresses the next continuation when a continuation turn produced no tool calls", async () => {
|
|
288
|
+
const host = fakeHost();
|
|
289
|
+
defaultExport(host.pi);
|
|
290
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
291
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
292
|
+
host.sentMessages.length = 0;
|
|
293
|
+
|
|
294
|
+
// Continuation turn: agent replies with no tool calls.
|
|
295
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
296
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
297
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(1);
|
|
298
|
+
|
|
299
|
+
// That turn's end marks suppression: the following end schedules nothing.
|
|
300
|
+
host.sentMessages.length = 0;
|
|
301
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
302
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
303
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
|
|
304
|
+
|
|
305
|
+
// A real user message re-arms the loop.
|
|
306
|
+
await fire(host, "message_start", { type: "message_start", message: { role: "user" } });
|
|
307
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
308
|
+
await fire(host, "tool_execution_end", {
|
|
309
|
+
type: "tool_execution_end",
|
|
310
|
+
toolCallId: "t1",
|
|
311
|
+
toolName: "read",
|
|
312
|
+
result: {},
|
|
313
|
+
isError: false,
|
|
314
|
+
});
|
|
315
|
+
host.sentMessages.length = 0;
|
|
316
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
317
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("interrupt aborts pause the goal instead of continuing", async () => {
|
|
321
|
+
const host = fakeHost();
|
|
322
|
+
defaultExport(host.pi);
|
|
323
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
324
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
325
|
+
host.sentMessages.length = 0;
|
|
326
|
+
|
|
327
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
328
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("aborted")] });
|
|
329
|
+
|
|
330
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
|
|
331
|
+
// omp footer segment: pause icon + usage.
|
|
332
|
+
expect(host.statuses.get("goal")).toBe("⏸ Goal 0");
|
|
333
|
+
const pauseEntry = [...host.entries].reverse().find((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
|
|
334
|
+
expect(pauseEntry?.data).toMatchObject({ enabled: false, goal: { status: "paused" } });
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("tool-driven complete exits goal mode at agent_end with the completion summary", async () => {
|
|
338
|
+
const host = fakeHost();
|
|
339
|
+
defaultExport(host.pi);
|
|
340
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
341
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
342
|
+
|
|
343
|
+
await callGoalTool(host, { op: "complete", evidence: "tests pass" });
|
|
344
|
+
// Still active toolset until the run ends.
|
|
345
|
+
expect(host.activeTools).toContain("goal");
|
|
346
|
+
|
|
347
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
348
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
349
|
+
|
|
350
|
+
expect(host.activeTools).not.toContain("goal");
|
|
351
|
+
expect(host.entries.some((e) => e.customType === GOAL_COMPLETED_ENTRY_TYPE)).toBe(true);
|
|
352
|
+
expect(host.entries.some((e) => e.customType === GOAL_CLEARED_ENTRY_TYPE)).toBe(true);
|
|
353
|
+
expect(host.notifications).toContain("Goal mode completed.");
|
|
354
|
+
expect(host.statuses.has("goal")).toBe(false);
|
|
355
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("budget-limit steering sends one hidden steer message when usage crosses the budget", async () => {
|
|
359
|
+
const host = fakeHost();
|
|
360
|
+
defaultExport(host.pi);
|
|
361
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
362
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
363
|
+
await host.commands.goal!.handler("budget 10", createContext(host));
|
|
364
|
+
|
|
365
|
+
// turn_start snapshots the baseline (no usage entries yet).
|
|
366
|
+
await fire(host, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 });
|
|
367
|
+
// Usage arrives via an assistant message entry (same shape pi persists).
|
|
368
|
+
host.entries.push({
|
|
369
|
+
type: "message",
|
|
370
|
+
customType: "",
|
|
371
|
+
id: "m1",
|
|
372
|
+
data: undefined,
|
|
373
|
+
message: {
|
|
374
|
+
role: "assistant",
|
|
375
|
+
stopReason: "toolUse",
|
|
376
|
+
usage: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
377
|
+
},
|
|
378
|
+
} as never);
|
|
379
|
+
|
|
380
|
+
await fire(host, "tool_execution_end", {
|
|
381
|
+
type: "tool_execution_end",
|
|
382
|
+
toolCallId: "t1",
|
|
383
|
+
toolName: "read",
|
|
384
|
+
result: {},
|
|
385
|
+
isError: false,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const steer = host.sentMessages.find((m) => m.customType === "goal-budget-limit");
|
|
389
|
+
expect(steer).toBeDefined();
|
|
390
|
+
expect(steer?.display).toBe(false);
|
|
391
|
+
expect(steer?.options).toMatchObject({ deliverAs: "steer" });
|
|
392
|
+
// omp footer segment: warning icon + used/budget.
|
|
393
|
+
expect(host.statuses.get("goal")).toBe("⚠ Goal 25/10");
|
|
394
|
+
|
|
395
|
+
// A second flush must not steer again (once per goal id).
|
|
396
|
+
host.sentMessages.length = 0;
|
|
397
|
+
host.entries.push({
|
|
398
|
+
type: "message",
|
|
399
|
+
customType: "",
|
|
400
|
+
id: "m2",
|
|
401
|
+
data: undefined,
|
|
402
|
+
message: {
|
|
403
|
+
role: "assistant",
|
|
404
|
+
stopReason: "toolUse",
|
|
405
|
+
usage: { input: 50, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
406
|
+
},
|
|
407
|
+
} as never);
|
|
408
|
+
await fire(host, "tool_execution_end", {
|
|
409
|
+
type: "tool_execution_end",
|
|
410
|
+
toolCallId: "t2",
|
|
411
|
+
toolName: "read",
|
|
412
|
+
result: {},
|
|
413
|
+
isError: false,
|
|
414
|
+
});
|
|
415
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-budget-limit")).toHaveLength(0);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("/guided-goal queues a hidden interview kickoff", async () => {
|
|
419
|
+
const host = fakeHost();
|
|
420
|
+
defaultExport(host.pi);
|
|
421
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
422
|
+
|
|
423
|
+
await host.commands["guided-goal"]!.handler("make a cli", createContext(host));
|
|
424
|
+
const kickoff = host.sentMessages.find((m) => m.customType === "guided-goal");
|
|
425
|
+
expect(kickoff).toBeDefined();
|
|
426
|
+
expect(kickoff?.display).toBe(false);
|
|
427
|
+
expect(kickoff?.content).toContain("<rough-goal>");
|
|
428
|
+
expect(kickoff?.content).toContain("make a cli");
|
|
429
|
+
expect(host.activeTools).toContain("goal");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("/goal pause and /goal resume round-trip through the runtime", async () => {
|
|
433
|
+
const host = fakeHost();
|
|
434
|
+
defaultExport(host.pi);
|
|
435
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
436
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
437
|
+
|
|
438
|
+
await host.commands.goal!.handler("pause", createContext(host));
|
|
439
|
+
expect(host.activeTools).not.toContain("goal");
|
|
440
|
+
expect(host.notifications).toContain("Goal mode paused.");
|
|
441
|
+
|
|
442
|
+
await host.commands.goal!.handler("resume", createContext(host));
|
|
443
|
+
expect(host.activeTools).toContain("goal");
|
|
444
|
+
expect(host.notifications).toContain("Goal mode resumed.");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("/goal drop requires confirmation and clears state", async () => {
|
|
448
|
+
const host = fakeHost();
|
|
449
|
+
defaultExport(host.pi);
|
|
450
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
451
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
452
|
+
|
|
453
|
+
await host.commands.goal!.handler("drop", createContext(host));
|
|
454
|
+
expect(host.entries.some((e) => e.customType === GOAL_CLEARED_ENTRY_TYPE)).toBe(true);
|
|
455
|
+
expect(host.notifications).toContain("Goal dropped.");
|
|
456
|
+
expect(host.statuses.has("goal")).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ----------------------------------------------------------------
|
|
460
|
+
// docs/extensions.md conformance: ui_prompt_start/end, agent_settled,
|
|
461
|
+
// getArgumentCompletions, registerEntryRenderer
|
|
462
|
+
// ----------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
it("withholds continuation while a blocking UI prompt is open, resumes when it closes while idle", async () => {
|
|
465
|
+
const host = fakeHost();
|
|
466
|
+
defaultExport(host.pi);
|
|
467
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
468
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
469
|
+
host.sentMessages.length = 0;
|
|
470
|
+
|
|
471
|
+
// Agent settles while a dialog (e.g. /goal drop confirm) is open.
|
|
472
|
+
// omp's footer segment has no dialog state; the status text stays put.
|
|
473
|
+
await fire(host, "ui_prompt_start", { type: "ui_prompt_start", reason: "ui_prompt", kind: "confirm" });
|
|
474
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
475
|
+
|
|
476
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
477
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
478
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
|
|
479
|
+
|
|
480
|
+
// Dialog closes while idle: the withheld continuation is scheduled now.
|
|
481
|
+
const idleCtx = createContext(host, { idle: true });
|
|
482
|
+
await fire(host, "ui_prompt_end", { type: "ui_prompt_end", reason: "ui_prompt", kind: "confirm" }, idleCtx);
|
|
483
|
+
expect(host.statuses.get("goal")).not.toContain("waiting for user");
|
|
484
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("does not schedule on ui_prompt_end while the agent is still streaming", async () => {
|
|
488
|
+
const host = fakeHost();
|
|
489
|
+
defaultExport(host.pi);
|
|
490
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
491
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
492
|
+
host.sentMessages.length = 0;
|
|
493
|
+
|
|
494
|
+
// Another extension's ask_user dialog opens mid-run and closes; the run
|
|
495
|
+
// is still going, so scheduling must wait for its agent_end.
|
|
496
|
+
await fire(host, "ui_prompt_start", { type: "ui_prompt_start", reason: "ui_prompt", kind: "custom" });
|
|
497
|
+
await fire(host, "agent_start", { type: "agent_start" });
|
|
498
|
+
await fire(
|
|
499
|
+
host,
|
|
500
|
+
"ui_prompt_end",
|
|
501
|
+
{ type: "ui_prompt_end", reason: "ui_prompt", kind: "custom" },
|
|
502
|
+
createContext(host, { idle: false }),
|
|
503
|
+
);
|
|
504
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
|
|
505
|
+
|
|
506
|
+
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
507
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(1);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("refreshes the status on agent_settled (status integrations per docs)", async () => {
|
|
511
|
+
const host = fakeHost();
|
|
512
|
+
defaultExport(host.pi);
|
|
513
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
514
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
515
|
+
|
|
516
|
+
host.statuses.clear();
|
|
517
|
+
await fire(host, "agent_settled", { type: "agent_settled" });
|
|
518
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("registers /goal argument completions (omp buildArgumentCompletions shape)", () => {
|
|
522
|
+
const host = fakeHost();
|
|
523
|
+
defaultExport(host.pi);
|
|
524
|
+
const completions = host.commands.goal!.getArgumentCompletions;
|
|
525
|
+
expect(completions).toBeDefined();
|
|
526
|
+
|
|
527
|
+
const subs = completions!("") as Array<{ value: string }>;
|
|
528
|
+
expect(subs.map((s) => s.value)).toEqual(["set ", "show ", "pause ", "resume ", "drop ", "budget "]);
|
|
529
|
+
expect(completions!("dr") as Array<{ value: string }>).toEqual([
|
|
530
|
+
{ value: "drop ", label: "drop", description: "Drop the current goal" },
|
|
531
|
+
]);
|
|
532
|
+
// omp: null once the prefix passes the subcommand word.
|
|
533
|
+
expect(completions!("budget ")).toBeNull();
|
|
534
|
+
expect(completions!("set some objective")).toBeNull();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("renders the goal-completed entry persistently (registerEntryRenderer per docs)", () => {
|
|
538
|
+
const host = fakeHost();
|
|
539
|
+
defaultExport(host.pi);
|
|
540
|
+
const renderer = host.entryRenderers.get(GOAL_COMPLETED_ENTRY_TYPE);
|
|
541
|
+
expect(renderer).toBeDefined();
|
|
542
|
+
|
|
543
|
+
const passthroughTheme = { fg: (_color: string, text: string) => text };
|
|
544
|
+
const component = renderer!(
|
|
545
|
+
{
|
|
546
|
+
type: "custom",
|
|
547
|
+
customType: GOAL_COMPLETED_ENTRY_TYPE,
|
|
548
|
+
data: { objective: "Ship it", tokensUsed: 6049, timeUsedSeconds: 20 },
|
|
549
|
+
},
|
|
550
|
+
{ expanded: false },
|
|
551
|
+
passthroughTheme,
|
|
552
|
+
) as { text: string };
|
|
553
|
+
expect(component.text).toContain("Goal completed: Ship it");
|
|
554
|
+
expect(component.text).toContain("6K (no budget) tokens");
|
|
555
|
+
expect(component.text).toContain("20s");
|
|
556
|
+
});
|
|
557
|
+
});
|