@fyeeme/pi-goal 1.0.3 → 1.0.5
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 +10 -0
- package/index.ts +121 -12
- package/package.json +10 -10
- package/test/index.test.ts +155 -28
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.0.5] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Relaxed the `@earendil-works/pi-agent-core` peer dependency from exact `0.99.2` to `>=0.99.0`, matching the other peer floors.
|
|
10
|
+
- Dev toolchain pinned to `@earendil-works/pi-agent-core`/`pi-ai`/`pi-coding-agent`/`pi-tui` 1.0.0; typecheck and tests pass against 1.0.0 unchanged.
|
|
11
|
+
|
|
12
|
+
## [1.0.4] - 2026-10-01
|
|
13
|
+
|
|
5
14
|
### Added
|
|
6
15
|
|
|
7
16
|
- Objective length cap (`MAX_OBJECTIVE_CHARS`, 4,000 code points) enforced by the runtime on `create` and `replace` — the objective is re-injected into context on every continuation and passed to the evaluator subprocess, so an oversized one silently burns budget every turn; the error guides long instructions into a referenced file. Counting is code-point-based, not UTF-16 units. (Borrowed from mitsuhiko/agent-stuff `extensions/goal.ts`.)
|
|
@@ -16,6 +25,7 @@
|
|
|
16
25
|
|
|
17
26
|
- Context hygiene: hidden goal messages no longer accumulate in the LLM's view. A `context` handler keeps only the newest `goal-mode-context` and `goal-budget-limit` messages plus the newest `goal-continuation` stamped for the currently active goal id (`details.goalId`); stale ones — including all continuations once no goal is active — are dropped from the model's view, not from the transcript. (Borrowed from mitsuhiko/agent-stuff `extensions/goal.ts`.)
|
|
18
27
|
- Run-error handling: when a run ends with an assistant `stopReason: "error"`, the active goal now pauses (persisted) instead of letting the continuation loop fire into a likely retry loop, with a classified notice — provider usage/rate/quota/limit errors read differently from generic faults. Abort behavior is unchanged. (Borrowed from mitsuhiko/agent-stuff `extensions/goal.ts`.)
|
|
28
|
+
- Peer dependency floor raised to `@earendil-works/pi-coding-agent >= 0.99.0`; dev toolchain pinned to 0.99.2 (typecheck and tests pass against 0.99.2 unchanged).
|
|
19
29
|
|
|
20
30
|
## [1.0.2] - 2026-09-16
|
|
21
31
|
|
package/index.ts
CHANGED
|
@@ -27,12 +27,18 @@
|
|
|
27
27
|
* goal_updated session event → pi.events.emit("goal_updated")
|
|
28
28
|
* sendHiddenMessage (budget steer) → pi.sendMessage display:false
|
|
29
29
|
* prompt-time prependMessages goal context
|
|
30
|
-
* →
|
|
31
|
-
* injection (
|
|
30
|
+
* → context_with_system per-request
|
|
31
|
+
* injection (0.87.0): survives
|
|
32
|
+
* compaction, supersedes transcript
|
|
33
|
+
* copies instead of accumulating
|
|
32
34
|
* #scheduleGoalContinuation 800ms TUI timer
|
|
33
|
-
* →
|
|
34
|
-
* (
|
|
35
|
-
*
|
|
35
|
+
* → agent_before_settle actionable
|
|
36
|
+
* boundary (0.87.0):
|
|
37
|
+
* { entries: [continuation draft],
|
|
38
|
+
* continue: true } — exactly one
|
|
39
|
+
* next provider request; sendMessage
|
|
40
|
+
* fallback kept for continuations
|
|
41
|
+
* withheld after settlement
|
|
36
42
|
* setActiveToolsByName → pi.setActiveTools
|
|
37
43
|
* status-line segment → ctx.ui.setStatus("goal", ...)
|
|
38
44
|
* settings goal.enabled / continuationModes / statusInFooter
|
|
@@ -59,7 +65,12 @@
|
|
|
59
65
|
import { readFileSync } from "node:fs";
|
|
60
66
|
import * as path from "node:path";
|
|
61
67
|
import { fileURLToPath } from "node:url";
|
|
62
|
-
import type {
|
|
68
|
+
import type {
|
|
69
|
+
ContextEditEntryDraft,
|
|
70
|
+
ExtensionAPI,
|
|
71
|
+
ExtensionContext,
|
|
72
|
+
SessionBoundaryDraft,
|
|
73
|
+
} from "@earendil-works/pi-coding-agent";
|
|
63
74
|
import { Text } from "@earendil-works/pi-tui";
|
|
64
75
|
import {
|
|
65
76
|
createGoalCommand,
|
|
@@ -105,6 +116,34 @@ interface EntryUsageLike {
|
|
|
105
116
|
usage?: EntryMessageLike["usage"];
|
|
106
117
|
}
|
|
107
118
|
|
|
119
|
+
/** Budget slimming drafts (opt-in via PI_GOAL_SLIM_ON_BUDGET=1, attached once
|
|
120
|
+
* per goal at the budget-limited flip): omit toolResult entries that precede
|
|
121
|
+
* the newest real user message from future provider context via append-only
|
|
122
|
+
* context-edit drafts — superseded turns' work products. Raw history, usage
|
|
123
|
+
* and UI history stay untouched (context_edit contract). Pure: returns the
|
|
124
|
+
* drafts; the settle boundary attaches them. */
|
|
125
|
+
export function contextSlimDrafts(branch: unknown[]): ContextEditEntryDraft[] {
|
|
126
|
+
const drafts: ContextEditEntryDraft[] = [];
|
|
127
|
+
let keepFrom = -1;
|
|
128
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
129
|
+
const entry = branch[i] as { type?: string; message?: { role?: string } } | undefined;
|
|
130
|
+
if (entry?.type === "message" && entry.message?.role === "user") {
|
|
131
|
+
keepFrom = i;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (keepFrom <= 0) return drafts;
|
|
136
|
+
const seen = new Set<string>();
|
|
137
|
+
for (let i = 0; i < keepFrom; i++) {
|
|
138
|
+
const entry = branch[i] as { type?: string; id?: string; message?: { role?: string } } | undefined;
|
|
139
|
+
if (entry?.type !== "message" || entry.message?.role !== "toolResult") continue;
|
|
140
|
+
if (!entry.id || seen.has(entry.id)) continue;
|
|
141
|
+
seen.add(entry.id);
|
|
142
|
+
drafts.push({ type: "context_edit", targetId: entry.id, replacement: null });
|
|
143
|
+
}
|
|
144
|
+
return drafts;
|
|
145
|
+
}
|
|
146
|
+
|
|
108
147
|
export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
109
148
|
// ------------------------------------------------------------------
|
|
110
149
|
// Closure state (omp session fields)
|
|
@@ -126,6 +165,10 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
126
165
|
/** A blocking ctx.ui dialog is open (ui_prompt_start/end). While open, no
|
|
127
166
|
* continuation may fire: the modal would hide a turn starting behind it. */
|
|
128
167
|
let uiPromptOpen = false;
|
|
168
|
+
/** Goal id whose budget-limited flip already armed context slimming. */
|
|
169
|
+
let slimmedBudgetGoalId: string | undefined;
|
|
170
|
+
/** Context-slimming drafts pending attachment at the next settle boundary. */
|
|
171
|
+
let pendingSlim = false;
|
|
129
172
|
|
|
130
173
|
// ------------------------------------------------------------------
|
|
131
174
|
// Usage accounting (mirror of pi getSessionStats().tokens sums)
|
|
@@ -257,6 +300,14 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
257
300
|
return;
|
|
258
301
|
}
|
|
259
302
|
goalState = state;
|
|
303
|
+
if (
|
|
304
|
+
state?.goal.status === "budget-limited" &&
|
|
305
|
+
process.env.PI_GOAL_SLIM_ON_BUDGET === "1" &&
|
|
306
|
+
slimmedBudgetGoalId !== state.goal.id
|
|
307
|
+
) {
|
|
308
|
+
slimmedBudgetGoalId = state.goal.id;
|
|
309
|
+
pendingSlim = true;
|
|
310
|
+
}
|
|
260
311
|
if (!state?.enabled) {
|
|
261
312
|
continuationInFlight = false;
|
|
262
313
|
}
|
|
@@ -416,7 +467,9 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
416
467
|
return renderTemplate(goalModeContextPrompt, { goalContext: content, todoContext });
|
|
417
468
|
}
|
|
418
469
|
|
|
419
|
-
/** omp #scheduleGoalContinuation
|
|
470
|
+
/** omp #scheduleGoalContinuation. Post-settlement fallback path only: the
|
|
471
|
+
* primary continuation decision lives in the agent_before_settle boundary
|
|
472
|
+
* below (0.87.0). */
|
|
420
473
|
function scheduleContinuation(): void {
|
|
421
474
|
if (!goalState?.enabled || goalState.goal.status !== "active") return;
|
|
422
475
|
if (suppressNextContinuation) return;
|
|
@@ -433,6 +486,26 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
433
486
|
);
|
|
434
487
|
}
|
|
435
488
|
|
|
489
|
+
/** omp #scheduleGoalContinuation decision chain, settled into a draft:
|
|
490
|
+
* guards pass → one custom_message draft carrying the continuation
|
|
491
|
+
* prompt (details.goalId keys context pruning). */
|
|
492
|
+
function continuationDraft(ctx: ExtensionContext): SessionBoundaryDraft | undefined {
|
|
493
|
+
if (!goalState?.enabled || goalState.goal.status !== "active") return undefined;
|
|
494
|
+
if (suppressNextContinuation) return undefined;
|
|
495
|
+
if (uiPromptOpen) return undefined; // modal open: never start a turn behind it
|
|
496
|
+
if (ctx.hasPendingMessages()) return undefined;
|
|
497
|
+
const prompt = runtime.buildContinuationPrompt();
|
|
498
|
+
if (!prompt) return undefined;
|
|
499
|
+
continuationInFlight = true;
|
|
500
|
+
return {
|
|
501
|
+
type: "custom_message",
|
|
502
|
+
customType: "goal-continuation",
|
|
503
|
+
content: prompt,
|
|
504
|
+
display: false,
|
|
505
|
+
details: { goalId: goalState.goal.id },
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
436
509
|
// ------------------------------------------------------------------
|
|
437
510
|
// Tool + command registration
|
|
438
511
|
// ------------------------------------------------------------------
|
|
@@ -584,13 +657,25 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
584
657
|
}
|
|
585
658
|
});
|
|
586
659
|
|
|
587
|
-
|
|
660
|
+
// Goal context injection (0.87.0 context_with_system): runs after `context`
|
|
661
|
+
// handlers on the full transcript including system messages; the result is
|
|
662
|
+
// sent verbatim. Per-request injection survives compaction (re-applied every
|
|
663
|
+
// call) and the fresh copy supersedes any steer-injected transcript copies.
|
|
664
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
588
665
|
currentCtx = ctx;
|
|
589
666
|
const content = buildGoalModeMessage();
|
|
590
667
|
if (!content) return undefined;
|
|
591
|
-
|
|
592
|
-
message
|
|
593
|
-
|
|
668
|
+
const messages = event.messages.filter((message) => {
|
|
669
|
+
const msg = message as { role?: string; customType?: string } | undefined;
|
|
670
|
+
return !(msg?.role === "custom" && msg.customType === "goal-mode-context");
|
|
671
|
+
});
|
|
672
|
+
messages.push({
|
|
673
|
+
role: "custom",
|
|
674
|
+
customType: "goal-mode-context",
|
|
675
|
+
content,
|
|
676
|
+
display: false,
|
|
677
|
+
} as (typeof event.messages)[number]);
|
|
678
|
+
return { messages };
|
|
594
679
|
});
|
|
595
680
|
|
|
596
681
|
// Context hygiene (borrowed from mitsuhiko/agent-stuff goal.ts): hidden goal
|
|
@@ -649,7 +734,31 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
649
734
|
return;
|
|
650
735
|
}
|
|
651
736
|
updateStatus();
|
|
652
|
-
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// omp #scheduleGoalContinuation migrated to the 0.87.0 actionable boundary:
|
|
740
|
+
// at settle time decide ONCE whether exactly one more provider request
|
|
741
|
+
// should run, attaching the continuation prompt as a custom_message draft.
|
|
742
|
+
// No timer, no followUp scheduling — pi owns the request. Guards mirror the
|
|
743
|
+
// old scheduleContinuation chain; continuations withheld after settlement
|
|
744
|
+
// (modal closed late, budget set while idle) still recover via the
|
|
745
|
+
// sendMessage fallback in scheduleContinuation().
|
|
746
|
+
pi.on("agent_before_settle", (_event, ctx) => {
|
|
747
|
+
currentCtx = ctx;
|
|
748
|
+
// Context slimming (opt-in): attach pending context_edit drafts first —
|
|
749
|
+
// they persist even when the continuation itself is withheld.
|
|
750
|
+
const drafts: SessionBoundaryDraft[] = [];
|
|
751
|
+
if (pendingSlim) {
|
|
752
|
+
pendingSlim = false;
|
|
753
|
+
drafts.push(...contextSlimDrafts(ctx.sessionManager.getBranch()));
|
|
754
|
+
}
|
|
755
|
+
const continuation = continuationDraft(ctx);
|
|
756
|
+
if (continuation) drafts.push(continuation);
|
|
757
|
+
if (drafts.length === 0) return undefined;
|
|
758
|
+
// Absent `continue` = no opinion on auto-continuation (budget-limited
|
|
759
|
+
// goals settle without one); `continue: true` guarantees exactly one
|
|
760
|
+
// next provider request.
|
|
761
|
+
return continuation !== undefined ? { entries: drafts, continue: true } : { entries: drafts };
|
|
653
762
|
});
|
|
654
763
|
|
|
655
764
|
pi.on("turn_end", (_event, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyeeme/pi-goal",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Goal mode for pi
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"description": "Goal mode for pi — one persistent autonomous objective looped until verified success: goal tool (create/get/complete/resume/drop), token/time budget accounting with budget-limit steering, automatic continuation turns, /goal and /guided-goal commands, and a goal_updated event bus for other extensions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "fyeeme",
|
|
@@ -47,17 +47,17 @@
|
|
|
47
47
|
"typecheck": "tsc"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": ">=0.
|
|
51
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
52
|
-
"@earendil-works/pi-tui": ">=0.
|
|
50
|
+
"@earendil-works/pi-ai": ">=0.99.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": ">=0.99.0",
|
|
52
|
+
"@earendil-works/pi-tui": ">=0.99.0",
|
|
53
53
|
"typebox": ">=1.0.0",
|
|
54
|
-
"@earendil-works/pi-agent-core": "0.
|
|
54
|
+
"@earendil-works/pi-agent-core": ">=0.99.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@earendil-works/pi-agent-core": "0.
|
|
58
|
-
"@earendil-works/pi-ai": "0.
|
|
59
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
60
|
-
"@earendil-works/pi-tui": "0.
|
|
57
|
+
"@earendil-works/pi-agent-core": "1.0.0",
|
|
58
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
60
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
61
61
|
"@types/node": "22.19.19",
|
|
62
62
|
"typescript": "5.9.3",
|
|
63
63
|
"vitest": "3.2.7"
|
package/test/index.test.ts
CHANGED
|
@@ -152,6 +152,19 @@ async function fire(host: FakeHost, event: string, payload: unknown, ctx?: unkno
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** Fire agent_before_settle and return its boundary result. */
|
|
156
|
+
function fireBeforeSettle(
|
|
157
|
+
host: FakeHost,
|
|
158
|
+
ctx?: unknown,
|
|
159
|
+
): { continue?: boolean; entries?: Array<{ type: string; customType?: string; details?: unknown }> } | undefined {
|
|
160
|
+
const list = host.handlers.get("agent_before_settle") ?? [];
|
|
161
|
+
let result: unknown;
|
|
162
|
+
for (const handler of list) {
|
|
163
|
+
result = handler({ type: "agent_before_settle" }, ctx ?? createContext(host));
|
|
164
|
+
}
|
|
165
|
+
return result as { continue?: boolean; entries?: Array<{ type: string; customType?: string; details?: unknown }> } | undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
function fireBus(host: FakeHost, channel: string, data: unknown): void {
|
|
156
169
|
for (const handler of host.busHandlers.get(channel) ?? []) {
|
|
157
170
|
handler(data);
|
|
@@ -268,30 +281,52 @@ describe("pi-goal extension wiring", () => {
|
|
|
268
281
|
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
269
282
|
});
|
|
270
283
|
|
|
271
|
-
it("
|
|
284
|
+
it("context_with_system injects the goal-mode-context message only while a goal is active", async () => {
|
|
272
285
|
const host = fakeHost();
|
|
273
286
|
defaultExport(host.pi);
|
|
274
287
|
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
275
288
|
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
result = await handler
|
|
289
|
+
const handler = host.handlers.get("context_with_system")?.[0]!;
|
|
290
|
+
const transcript = [{ role: "user", content: "hi" }];
|
|
291
|
+
let result = (await handler({ type: "context_with_system", messages: transcript }, createContext(host))) as
|
|
292
|
+
| { messages: Array<{ customType?: string; content?: string }> }
|
|
293
|
+
| undefined;
|
|
279
294
|
expect(result).toBeUndefined();
|
|
280
295
|
|
|
281
296
|
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
282
|
-
result = await handler
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
expect(
|
|
297
|
+
result = (await handler({ type: "context_with_system", messages: transcript }, createContext(host))) as
|
|
298
|
+
| { messages: Array<{ customType?: string; content?: string }> }
|
|
299
|
+
| undefined;
|
|
300
|
+
const contexts = result!.messages.filter((m) => m.customType === "goal-mode-context");
|
|
301
|
+
expect(contexts).toHaveLength(1);
|
|
302
|
+
expect(contexts[0]?.content).toContain("<goal_context>");
|
|
303
|
+
expect(contexts[0]?.content).toContain("Do the thing");
|
|
287
304
|
});
|
|
288
305
|
|
|
289
|
-
it("
|
|
306
|
+
it("context_with_system supersedes steer-injected transcript copies (single fresh context)", async () => {
|
|
307
|
+
const host = fakeHost();
|
|
308
|
+
defaultExport(host.pi);
|
|
309
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
310
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
311
|
+
|
|
312
|
+
const handler = host.handlers.get("context_with_system")?.[0]!;
|
|
313
|
+
const transcript = [
|
|
314
|
+
{ role: "user", content: "hi" },
|
|
315
|
+
{ role: "custom", customType: "goal-mode-context", content: "stale copy", display: false },
|
|
316
|
+
];
|
|
317
|
+
const result = (await handler({ type: "context_with_system", messages: transcript }, createContext(host))) as {
|
|
318
|
+
messages: Array<{ customType?: string; content?: string }>;
|
|
319
|
+
};
|
|
320
|
+
const contexts = result.messages.filter((m) => m.customType === "goal-mode-context");
|
|
321
|
+
expect(contexts).toHaveLength(1);
|
|
322
|
+
expect(contexts[0]?.content).not.toBe("stale copy");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("agent_before_settle continues exactly one request when the goal is still active", async () => {
|
|
290
326
|
const host = fakeHost();
|
|
291
327
|
defaultExport(host.pi);
|
|
292
328
|
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
293
329
|
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
294
|
-
host.sentMessages.length = 0;
|
|
295
330
|
|
|
296
331
|
await fire(host, "agent_start", { type: "agent_start" });
|
|
297
332
|
await fire(host, "tool_execution_end", {
|
|
@@ -303,13 +338,16 @@ describe("pi-goal extension wiring", () => {
|
|
|
303
338
|
});
|
|
304
339
|
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("toolUse")] });
|
|
305
340
|
|
|
306
|
-
const
|
|
307
|
-
expect(
|
|
308
|
-
|
|
309
|
-
|
|
341
|
+
const result = fireBeforeSettle(host);
|
|
342
|
+
expect(result?.continue).toBe(true);
|
|
343
|
+
// The continuation prompt rides as a custom_message draft.
|
|
344
|
+
const draft = result?.entries?.[0];
|
|
345
|
+
expect(draft).toMatchObject({ type: "custom_message", customType: "goal-continuation", display: false });
|
|
310
346
|
// details.goalId keys the context-pruning handler.
|
|
311
347
|
const goalId = (host.entries.find((e) => e.customType === GOAL_STATE_ENTRY_TYPE)?.data as { goal: Goal }).goal.id;
|
|
312
|
-
expect(
|
|
348
|
+
expect(draft?.details).toMatchObject({ goalId });
|
|
349
|
+
// The boundary path sends no followUp message.
|
|
350
|
+
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
|
|
313
351
|
});
|
|
314
352
|
|
|
315
353
|
it("suppresses the next continuation when a continuation turn produced no tool calls", async () => {
|
|
@@ -317,18 +355,16 @@ describe("pi-goal extension wiring", () => {
|
|
|
317
355
|
defaultExport(host.pi);
|
|
318
356
|
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
319
357
|
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
320
|
-
host.sentMessages.length = 0;
|
|
321
358
|
|
|
322
359
|
// Continuation turn: agent replies with no tool calls.
|
|
323
360
|
await fire(host, "agent_start", { type: "agent_start" });
|
|
324
361
|
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
325
|
-
expect(host
|
|
362
|
+
expect(fireBeforeSettle(host)?.continue).toBe(true);
|
|
326
363
|
|
|
327
|
-
// That turn's end marks suppression: the following
|
|
328
|
-
host.sentMessages.length = 0;
|
|
364
|
+
// That turn's end marks suppression: the following settle continues nothing.
|
|
329
365
|
await fire(host, "agent_start", { type: "agent_start" });
|
|
330
366
|
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
331
|
-
expect(host
|
|
367
|
+
expect(fireBeforeSettle(host)?.continue).toBeUndefined();
|
|
332
368
|
|
|
333
369
|
// A real user message re-arms the loop.
|
|
334
370
|
await fire(host, "message_start", { type: "message_start", message: { role: "user" } });
|
|
@@ -340,9 +376,8 @@ describe("pi-goal extension wiring", () => {
|
|
|
340
376
|
result: {},
|
|
341
377
|
isError: false,
|
|
342
378
|
});
|
|
343
|
-
host.sentMessages.length = 0;
|
|
344
379
|
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
345
|
-
expect(host
|
|
380
|
+
expect(fireBeforeSettle(host)?.continue).toBe(true);
|
|
346
381
|
});
|
|
347
382
|
|
|
348
383
|
it("interrupt aborts pause the goal instead of continuing", async () => {
|
|
@@ -355,7 +390,7 @@ describe("pi-goal extension wiring", () => {
|
|
|
355
390
|
await fire(host, "agent_start", { type: "agent_start" });
|
|
356
391
|
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("aborted")] });
|
|
357
392
|
|
|
358
|
-
expect(host
|
|
393
|
+
expect(fireBeforeSettle(host)?.continue).toBeUndefined();
|
|
359
394
|
// omp footer segment: pause icon + usage.
|
|
360
395
|
expect(host.statuses.get("goal")).toBe("⏸ Goal 0");
|
|
361
396
|
const pauseEntry = [...host.entries].reverse().find((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
|
|
@@ -380,7 +415,7 @@ describe("pi-goal extension wiring", () => {
|
|
|
380
415
|
expect(host.entries.some((e) => e.customType === GOAL_CLEARED_ENTRY_TYPE)).toBe(true);
|
|
381
416
|
expect(host.notifications).toContain("Goal mode completed.");
|
|
382
417
|
expect(host.statuses.has("goal")).toBe(false);
|
|
383
|
-
expect(host
|
|
418
|
+
expect(fireBeforeSettle(host)?.continue).toBeUndefined();
|
|
384
419
|
});
|
|
385
420
|
|
|
386
421
|
it("budget-limit steering sends one hidden steer message when usage crosses the budget", async () => {
|
|
@@ -443,6 +478,96 @@ describe("pi-goal extension wiring", () => {
|
|
|
443
478
|
expect(host.sentMessages.filter((m) => m.customType === "goal-budget-limit")).toHaveLength(0);
|
|
444
479
|
});
|
|
445
480
|
|
|
481
|
+
it("budget flip attaches context-slimming drafts when PI_GOAL_SLIM_ON_BUDGET=1", async () => {
|
|
482
|
+
const host = fakeHost();
|
|
483
|
+
defaultExport(host.pi);
|
|
484
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
485
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
486
|
+
await host.commands.goal!.handler("budget 10", createContext(host));
|
|
487
|
+
|
|
488
|
+
const entries: unknown[] = [
|
|
489
|
+
{ type: "message", id: "t-old", message: { role: "toolResult" } },
|
|
490
|
+
{ type: "message", id: "u1", message: { role: "user" } },
|
|
491
|
+
{ type: "message", id: "t-new", message: { role: "toolResult" } },
|
|
492
|
+
];
|
|
493
|
+
const ctx = createContext(host, { entries });
|
|
494
|
+
|
|
495
|
+
vi.stubEnv("PI_GOAL_SLIM_ON_BUDGET", "1");
|
|
496
|
+
await fire(host, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
|
|
497
|
+
entries.push({
|
|
498
|
+
type: "message",
|
|
499
|
+
id: "m1",
|
|
500
|
+
message: { role: "assistant", stopReason: "toolUse", usage: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
501
|
+
});
|
|
502
|
+
await fire(host, "tool_execution_end", {
|
|
503
|
+
type: "tool_execution_end",
|
|
504
|
+
toolCallId: "t1",
|
|
505
|
+
toolName: "read",
|
|
506
|
+
result: {},
|
|
507
|
+
isError: false,
|
|
508
|
+
}, ctx);
|
|
509
|
+
|
|
510
|
+
// Budget flip arms slimming; the boundary attaches the drafts.
|
|
511
|
+
// Budget-limited goals stop auto-continuing (omp semantics), so the
|
|
512
|
+
// boundary persists the edits without a next request.
|
|
513
|
+
const result = fireBeforeSettle(host, ctx);
|
|
514
|
+
expect(result?.continue).toBeUndefined();
|
|
515
|
+
const edits = (result?.entries ?? []).filter((e) => e.type === "context_edit");
|
|
516
|
+
expect(edits).toEqual([{ type: "context_edit", targetId: "t-old", replacement: null }]);
|
|
517
|
+
|
|
518
|
+
// Once per goal: the next settle carries no further slimming drafts.
|
|
519
|
+
entries.push({
|
|
520
|
+
type: "message",
|
|
521
|
+
id: "m2",
|
|
522
|
+
message: { role: "assistant", stopReason: "toolUse", usage: { input: 50, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
523
|
+
});
|
|
524
|
+
await fire(host, "tool_execution_end", {
|
|
525
|
+
type: "tool_execution_end",
|
|
526
|
+
toolCallId: "t2",
|
|
527
|
+
toolName: "read",
|
|
528
|
+
result: {},
|
|
529
|
+
isError: false,
|
|
530
|
+
}, ctx);
|
|
531
|
+
expect(fireBeforeSettle(host, ctx)).toBeUndefined();
|
|
532
|
+
vi.unstubAllEnvs();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("context slimming stays off by default (opt-in via PI_GOAL_SLIM_ON_BUDGET)", async () => {
|
|
536
|
+
const host = fakeHost();
|
|
537
|
+
defaultExport(host.pi);
|
|
538
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
539
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
540
|
+
await host.commands.goal!.handler("budget 10", createContext(host));
|
|
541
|
+
|
|
542
|
+
const entries: unknown[] = [
|
|
543
|
+
{ type: "message", id: "t-old", message: { role: "toolResult" } },
|
|
544
|
+
{ type: "message", id: "u1", message: { role: "user" } },
|
|
545
|
+
{ type: "message", id: "t-new", message: { role: "toolResult" } },
|
|
546
|
+
];
|
|
547
|
+
const ctx = createContext(host, { entries });
|
|
548
|
+
|
|
549
|
+
// Explicitly opt out (order-independent against leaking stubs).
|
|
550
|
+
vi.stubEnv("PI_GOAL_SLIM_ON_BUDGET", "");
|
|
551
|
+
await fire(host, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
|
|
552
|
+
entries.push({
|
|
553
|
+
type: "message",
|
|
554
|
+
id: "m1",
|
|
555
|
+
message: { role: "assistant", stopReason: "toolUse", usage: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
556
|
+
});
|
|
557
|
+
await fire(host, "tool_execution_end", {
|
|
558
|
+
type: "tool_execution_end",
|
|
559
|
+
toolCallId: "t1",
|
|
560
|
+
toolName: "read",
|
|
561
|
+
result: {},
|
|
562
|
+
isError: false,
|
|
563
|
+
}, ctx);
|
|
564
|
+
|
|
565
|
+
// Budget flip steers but arms no slimming drafts; budget-limited goals
|
|
566
|
+
// do not auto-continue, so the boundary settles empty.
|
|
567
|
+
expect(fireBeforeSettle(host, ctx)).toBeUndefined();
|
|
568
|
+
vi.unstubAllEnvs();
|
|
569
|
+
});
|
|
570
|
+
|
|
446
571
|
it("/guided-goal queues a hidden interview kickoff", async () => {
|
|
447
572
|
const host = fakeHost();
|
|
448
573
|
defaultExport(host.pi);
|
|
@@ -503,7 +628,8 @@ describe("pi-goal extension wiring", () => {
|
|
|
503
628
|
|
|
504
629
|
await fire(host, "agent_start", { type: "agent_start" });
|
|
505
630
|
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
506
|
-
|
|
631
|
+
// Modal open at settle: the boundary withholds the continuation.
|
|
632
|
+
expect(fireBeforeSettle(host)?.continue).toBeUndefined();
|
|
507
633
|
|
|
508
634
|
// Dialog closes while idle: the withheld continuation is scheduled now.
|
|
509
635
|
const idleCtx = createContext(host, { idle: true });
|
|
@@ -532,7 +658,8 @@ describe("pi-goal extension wiring", () => {
|
|
|
532
658
|
expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
|
|
533
659
|
|
|
534
660
|
await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
|
|
535
|
-
|
|
661
|
+
// Modal already closed: the boundary itself continues the run.
|
|
662
|
+
expect(fireBeforeSettle(host)?.continue).toBe(true);
|
|
536
663
|
});
|
|
537
664
|
|
|
538
665
|
it("refreshes the status on agent_settled (status integrations per docs)", async () => {
|
|
@@ -667,7 +794,7 @@ describe("pi-goal extension wiring", () => {
|
|
|
667
794
|
const pauseEntry = [...host.entries].reverse().find((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
|
|
668
795
|
expect(pauseEntry?.data).toMatchObject({ enabled: false, goal: { status: "paused" } });
|
|
669
796
|
expect(host.notifications.some((n) => n.includes("rate limits"))).toBe(true);
|
|
670
|
-
expect(host
|
|
797
|
+
expect(fireBeforeSettle(host)?.continue).toBeUndefined();
|
|
671
798
|
});
|
|
672
799
|
|
|
673
800
|
it("pauses with a generic notice on non-usage errors", async () => {
|