@matthewfl/pi-jtodo 0.0.1 → 0.0.3
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/README.md +3 -0
- package/package.json +1 -1
- package/src/config.ts +13 -8
- package/src/constants.ts +6 -0
- package/src/index.ts +205 -23
- package/src/poke-scheduler.ts +54 -0
- package/src/watchdog.ts +9 -0
- package/src/widget.ts +18 -1
- package/tests/test-todo.cjs +488 -14
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ Deliberately **not** ported from jcode: the `/overnight` mission mode (presumes
|
|
|
41
41
|
- **Write-time gate**: a write that closes a group (or the implicit ungrouped list) without an honest `end_to_end_ownership` claim is **rejected whole** — stored state is returned unchanged with an actionable message naming the failing groups. A first plan write with severely low intent gets an immediate in-band continuation.
|
|
42
42
|
- **Turn-end gates** (when the agent settles): incomplete todos → auto-poke ("You have N incomplete todos. Continue working, or update the todo tool."). Fully settled → the deferred quality digest (intent/feedback-loop weak points), then the completion-confidence and confidence-spike gates — each naming the flagged `#id`s — with an attempt budget, an unchanged-signature early stop, and a done notice when validation passes.
|
|
43
43
|
- **Follow-ups are attributed**: pokes, digests, and gate challenges travel as custom messages (LLM-visible as user-role text, transcript-visible as `pi-jtodo/followup`), so reload/resume never re-renders them as user prompts.
|
|
44
|
+
- **The poke waits `pokeDelayMs` before firing** (default 20s, pi-simple-goal's restart-countdown pattern): when the agent stops with open todos the settle schedules the poke instead of sending it, and the widget shows `· poke in Ns` counting down. **If the user starts typing during the window, the poke is canceled and poking pauses** until the next agent run, so a user who is about to take over is never fought by the machine. The completion/spike gates, the deferred digest, the watchdog re-fire, and manual `/todos poke on|trigger` stay immediate — gates respond to the agent's own claims, manual pokes are explicit.
|
|
45
|
+
- **Escape pauses, it does not silence** (pi-simple-goal pattern): a raw `\x1b` keypress within 5s of an aborted run marks it as the user's stop — the settle is quiet and the poke pauses until the user re-engages; the next agent run lifts the pause and re-arms. Aborts with no recent Escape behind them are machinery (compaction, `ctx.abort()`, transport, provider stacks mislabeling the interrupt as an error): their settle is quiet but the cycle stays armed and the starvation watchdog covers a lost continuation. The only sticky off is `/todos poke off`.
|
|
44
46
|
|
|
45
47
|
## Display
|
|
46
48
|
|
|
@@ -55,6 +57,7 @@ Deliberately **not** ported from jcode: the `/overnight` mission mode (presumes
|
|
|
55
57
|
{
|
|
56
58
|
"enabled": true,
|
|
57
59
|
"autoPoke": true,
|
|
60
|
+
"pokeDelayMs": 20000,
|
|
58
61
|
"completionGateMaxAttempts": 5,
|
|
59
62
|
"maxGateObservations": 256,
|
|
60
63
|
"maxConsecutivePokesWithoutProgress": 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-jtodo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Quality-gated todo tool for Pi: confidence scores with tool-owned histories, plan and per-goal assessments, write-time ownership rejection, turn-end completion/spike gates, deferred quality digests, auto-poke continuation, starvation watchdog, and a live todo strip.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Optional per-user configuration: ~/.pi/agent/state/pi-jtodo/config.json
|
|
3
3
|
* Missing or unreadable config falls back to defaults. Gate thresholds are
|
|
4
4
|
* calibration constants (see constants.ts) and deliberately NOT configurable.
|
|
5
|
+
* PI_JTODO_STATE_DIR overrides the state directory (embedding/tests).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import * as fs from "node:fs";
|
|
@@ -13,6 +14,13 @@ export interface TodoConfig {
|
|
|
13
14
|
enabled: boolean;
|
|
14
15
|
/** Arm the auto-poke cycle when each session starts (TUI/RPC only). */
|
|
15
16
|
autoPoke: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Delay before the settle poke fires when the agent stops with open
|
|
19
|
+
* todos (pi-simple-goal restartDelayMs pattern). While the poke waits,
|
|
20
|
+
* any terminal input (the user starts typing) cancels it and pauses
|
|
21
|
+
* poking until the next agent run. 0 = immediate (pure jcode).
|
|
22
|
+
*/
|
|
23
|
+
pokeDelayMs: number;
|
|
16
24
|
/** Turn-end re-validation budget before the gate stalls and disarms. */
|
|
17
25
|
completionGateMaxAttempts: number;
|
|
18
26
|
/** Cap on the turn-scoped observation log; oldest dropped first. */
|
|
@@ -38,6 +46,7 @@ export interface TodoConfig {
|
|
|
38
46
|
export const DEFAULT_CONFIG: TodoConfig = {
|
|
39
47
|
enabled: true,
|
|
40
48
|
autoPoke: true,
|
|
49
|
+
pokeDelayMs: 20_000,
|
|
41
50
|
completionGateMaxAttempts: TODO_COMPLETION_GATE_MAX_ATTEMPTS,
|
|
42
51
|
maxGateObservations: MAX_GATE_OBSERVATIONS,
|
|
43
52
|
maxConsecutivePokesWithoutProgress: 0,
|
|
@@ -52,14 +61,10 @@ export const DEFAULT_CONFIG: TodoConfig = {
|
|
|
52
61
|
|
|
53
62
|
export function loadConfig(): TodoConfig {
|
|
54
63
|
try {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
".pi",
|
|
58
|
-
|
|
59
|
-
"state",
|
|
60
|
-
"pi-jtodo",
|
|
61
|
-
"config.json",
|
|
62
|
-
);
|
|
64
|
+
const stateDir =
|
|
65
|
+
process.env.PI_JTODO_STATE_DIR ??
|
|
66
|
+
path.join(os.homedir(), ".pi", "agent", "state", "pi-jtodo");
|
|
67
|
+
const p = path.join(stateDir, "config.json");
|
|
63
68
|
const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
64
69
|
if (typeof parsed === "object" && parsed !== null) {
|
|
65
70
|
return { ...DEFAULT_CONFIG, ...parsed };
|
package/src/constants.ts
CHANGED
|
@@ -26,6 +26,12 @@ export const TODO_COMPLETION_GATE_MAX_ATTEMPTS = 5;
|
|
|
26
26
|
/** Upper bound on retained turn-scoped observations; oldest dropped first. */
|
|
27
27
|
export const MAX_GATE_OBSERVATIONS = 256;
|
|
28
28
|
|
|
29
|
+
// Window in which a raw Escape keypress is considered the cause of an aborted
|
|
30
|
+
// run (user stopped it on purpose). Outside this window an abort is machinery
|
|
31
|
+
// (compaction, ctx.abort(), transport) and the poke cycle stays armed.
|
|
32
|
+
// Same value and rationale as pi-simple-goal's ESCAPE_ABORT_WINDOW_MS.
|
|
33
|
+
export const ESCAPE_ABORT_WINDOW_MS = 5_000;
|
|
34
|
+
|
|
29
35
|
/** customType for all synthetic gate follow-ups (custom session messages). */
|
|
30
36
|
export const FOLLOWUP_CUSTOM_TYPE = "pi-jtodo/followup";
|
|
31
37
|
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
import { Text } from "@earendil-works/pi-tui";
|
|
33
33
|
import {
|
|
34
34
|
CYCLE_CUSTOM_TYPE,
|
|
35
|
+
ESCAPE_ABORT_WINDOW_MS,
|
|
35
36
|
FOLLOWUP_CUSTOM_TYPE,
|
|
36
37
|
NOTICE_COMPLETION_CHALLENGED,
|
|
37
38
|
NOTICE_DIGEST_QUEUED,
|
|
@@ -89,6 +90,7 @@ import { normalizeTodoInput } from "./normalize.js";
|
|
|
89
90
|
import { TodoParams, type TodoParamsInput } from "./schema.js";
|
|
90
91
|
import { TodoListComponent } from "./viewer.js";
|
|
91
92
|
import { TODO_WIDGET_ID, makeTodoWidget } from "./widget.js";
|
|
93
|
+
import { createPokeScheduler } from "./poke-scheduler.js";
|
|
92
94
|
import { createWatchdog } from "./watchdog.js";
|
|
93
95
|
|
|
94
96
|
// ============================================================================
|
|
@@ -150,6 +152,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
152
|
// pi-specific: once the user has explicitly silenced the poke (poke off,
|
|
151
153
|
// or Esc-interrupting a run), new open work must NOT re-arm it.
|
|
152
154
|
let pokeExplicitlyOff = false;
|
|
155
|
+
// Whether the most recent completed agent run was Esc-aborted, recorded
|
|
156
|
+
// from the agent_end event itself (the run's own messages). Entry-scanning
|
|
157
|
+
// heuristics misclassify aborts when a queued user message (compaction
|
|
158
|
+
// resume, steer) lands after the aborted assistant message; an
|
|
159
|
+
// Esc-aborted run must never restart the agent via a poke.
|
|
160
|
+
let lastRunAborted = false;
|
|
161
|
+
// Goal-plugin-style Escape correlation (pi-simple-goal): a raw Escape
|
|
162
|
+
// keypress observed via terminal input marks the NEXT aborted run as the
|
|
163
|
+
// user's stop. Aborts with no recent Escape behind them are machinery
|
|
164
|
+
// (compaction, ctx.abort(), transport) and keep the poke cycle armed.
|
|
165
|
+
let lastEscapeAt = 0;
|
|
166
|
+
// Set when an Escape-classified abort pauses the poke. NOT sticky: the
|
|
167
|
+
// next agent run (user re-engaged, or machinery resumed) lifts it and
|
|
168
|
+
// re-arms, per the user's "stop the poke for the next while" rule.
|
|
169
|
+
let escPokePaused = false;
|
|
170
|
+
let unsubscribeTerminalInput: (() => void) | undefined;
|
|
171
|
+
// pi-simple-goal restartDelayMs pattern (see sendSettlePoke): the settle
|
|
172
|
+
// poke can wait pokeDelayMs before firing; any terminal input during the
|
|
173
|
+
// window cancels it and pauses poking until the next agent run.
|
|
174
|
+
// 0 = immediate (pure jcode).
|
|
175
|
+
const pokeScheduler = createPokeScheduler(config.pokeDelayMs);
|
|
176
|
+
let pokeTickTimer: NodeJS.Timeout | undefined;
|
|
177
|
+
// Whether the raw-input listener is subscribed (TUI only). A delayed
|
|
178
|
+
// poke is only safe when typing can cancel it; otherwise fire immediately.
|
|
179
|
+
let terminalInputActive = false;
|
|
153
180
|
// Whether poking is allowed at all. session_start (when emitted — the
|
|
154
181
|
// SDK harness and some embedders never emit it) re-derives this from
|
|
155
182
|
// its ctx.hasUI; elsewhere starts optimistic, matching the module-level
|
|
@@ -164,6 +191,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
191
|
let idleNudgeSent = false;
|
|
165
192
|
|
|
166
193
|
function disarm() {
|
|
194
|
+
cancelPendingPoke();
|
|
167
195
|
autoPokeArmed = false;
|
|
168
196
|
lastPokeTargets = undefined;
|
|
169
197
|
cycle = freshCycleFlags();
|
|
@@ -237,6 +265,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
265
|
() => state,
|
|
238
266
|
() => ({
|
|
239
267
|
armed: autoPokeArmed,
|
|
268
|
+
escPaused: escPokePaused,
|
|
269
|
+
pokePendingAt: pokeScheduler.deadlineAt(),
|
|
240
270
|
gateAttempts: cycle.gateAttempts,
|
|
241
271
|
gateMaxAttempts: config.completionGateMaxAttempts,
|
|
242
272
|
pokeTargets: lastPokeTargets,
|
|
@@ -250,6 +280,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
250
280
|
}
|
|
251
281
|
}
|
|
252
282
|
|
|
283
|
+
/** Cancel a pending delayed poke (if any) and stop its countdown tick. */
|
|
284
|
+
function cancelPendingPoke(): void {
|
|
285
|
+
pokeScheduler.cancel();
|
|
286
|
+
if (pokeTickTimer !== undefined) {
|
|
287
|
+
clearInterval(pokeTickTimer);
|
|
288
|
+
pokeTickTimer = undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Send the settle poke, honoring pokeDelayMs (pi-simple-goal's restart
|
|
294
|
+
* countdown pattern): schedule it so the user can take over during the
|
|
295
|
+
* window — any typing cancels the poke and pauses the cycle — or fire
|
|
296
|
+
* immediately when no delay is configured, the raw-input listener is not
|
|
297
|
+
* subscribed (nothing could cancel it), or this is a manual trigger.
|
|
298
|
+
*/
|
|
299
|
+
function sendSettlePoke(ctx: ExtensionContext, immediate: boolean): void {
|
|
300
|
+
const fire = (): void => {
|
|
301
|
+
cancelPendingPoke();
|
|
302
|
+
const todos = incompleteTodos();
|
|
303
|
+
if (!config.enabled || !autoPokeArmed || escPokePaused || todos.length === 0) {
|
|
304
|
+
return; // state changed while the poke waited
|
|
305
|
+
}
|
|
306
|
+
if (watchdogCtx && !watchdogCtx.isIdle()) {
|
|
307
|
+
return; // something already restarted the agent
|
|
308
|
+
}
|
|
309
|
+
watchdog.notifyActivity();
|
|
310
|
+
lastPokeTargets = new Set(todos.map((t) => t.id));
|
|
311
|
+
notify(
|
|
312
|
+
ctx,
|
|
313
|
+
`👉 ${todos.length} incomplete todo${todos.length === 1 ? "" : "s"}. We poked it for you. ${NOTICE_POKE_OFF_HINT}`,
|
|
314
|
+
);
|
|
315
|
+
void sendGateFollowUp(buildAutoPokeMessage(todos.length));
|
|
316
|
+
refreshWidget(ctx);
|
|
317
|
+
};
|
|
318
|
+
if (!immediate && config.pokeDelayMs > 0 && terminalInputActive) {
|
|
319
|
+
pokeScheduler.schedule(fire);
|
|
320
|
+
if (pokeTickTimer === undefined) {
|
|
321
|
+
pokeTickTimer = setInterval(() => {
|
|
322
|
+
if (watchdogCtx) refreshWidget(watchdogCtx);
|
|
323
|
+
}, 1000);
|
|
324
|
+
if (typeof pokeTickTimer.unref === "function") pokeTickTimer.unref();
|
|
325
|
+
}
|
|
326
|
+
refreshWidget(ctx); // surface "poke in Ns" immediately
|
|
327
|
+
} else {
|
|
328
|
+
fire();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
253
332
|
// ------------------------------------------------------------------------
|
|
254
333
|
// Branch-aware state (replaces jcode's per-session JSON files)
|
|
255
334
|
// ------------------------------------------------------------------------
|
|
@@ -432,16 +511,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
432
511
|
}
|
|
433
512
|
|
|
434
513
|
/** True when the last assistant message on this branch was aborted (Esc). */
|
|
435
|
-
function wasLastRunAborted(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return
|
|
514
|
+
function wasLastRunAborted(): boolean {
|
|
515
|
+
return lastRunAborted;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Some provider stacks (pi-robust-provider observed live: errorMessage
|
|
519
|
+
// "This operation was aborted") finalize an Esc-aborted request as a
|
|
520
|
+
// generic error whose text still names the abort. Those are user
|
|
521
|
+
// interrupts, not provider failures, and must never restart the agent.
|
|
522
|
+
function looksInterrupted(stopReason: string | undefined, errorMessage: string | undefined): boolean {
|
|
523
|
+
return stopReason === "error" && typeof errorMessage === "string" && /abort/i.test(errorMessage);
|
|
445
524
|
}
|
|
446
525
|
|
|
447
526
|
function countAssistantTurnsSinceUser(ctx: ExtensionContext): number {
|
|
@@ -463,7 +542,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
463
542
|
* completion-confidence and spike gates (budgeted), then disarm with a
|
|
464
543
|
* done notice.
|
|
465
544
|
*/
|
|
466
|
-
async function runTurnEndCheck(ctx: ExtensionContext): Promise<void> {
|
|
545
|
+
async function runTurnEndCheck(ctx: ExtensionContext, immediatePoke = false): Promise<void> {
|
|
467
546
|
const todos = state.todos;
|
|
468
547
|
if (todos.length === 0) {
|
|
469
548
|
// jcode: a settle with no todos at all silently disarms the poke.
|
|
@@ -474,6 +553,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
474
553
|
|
|
475
554
|
const incomplete = incompleteTodos();
|
|
476
555
|
if (incomplete.length > 0) {
|
|
556
|
+
if (escPokePaused) {
|
|
557
|
+
// The user paused poking (Escape, or typing during the poke
|
|
558
|
+
// window): quiet until the next agent run lifts the pause.
|
|
559
|
+
refreshWidget(ctx);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
477
562
|
cycle.gateAttempts = 0;
|
|
478
563
|
if (config.maxConsecutivePokesWithoutProgress > 0) {
|
|
479
564
|
const signature = JSON.stringify(
|
|
@@ -491,12 +576,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
491
576
|
return;
|
|
492
577
|
}
|
|
493
578
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
);
|
|
498
|
-
lastPokeTargets = new Set(incomplete.map((t) => t.id));
|
|
499
|
-
await sendGateFollowUp(buildAutoPokeMessage(incomplete.length));
|
|
579
|
+
// The poke itself may be delayed (pokeDelayMs): sendSettlePoke
|
|
580
|
+
// schedules it so the user can take over by typing, or fires
|
|
581
|
+
// immediately for manual /todos poke triggers.
|
|
582
|
+
sendSettlePoke(ctx, immediatePoke);
|
|
500
583
|
return;
|
|
501
584
|
}
|
|
502
585
|
|
|
@@ -584,7 +667,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
667
|
isArmed: () => autoPokeArmed,
|
|
585
668
|
isIdle: () => watchdogCtx?.isIdle() ?? false,
|
|
586
669
|
incompleteCount: () => incompleteTodos().length,
|
|
587
|
-
wasAborted: () =>
|
|
670
|
+
wasAborted: () => lastRunAborted,
|
|
671
|
+
// The watchdog must not inject through an Esc- or typing-pause either.
|
|
672
|
+
isPaused: () => escPokePaused,
|
|
588
673
|
idleMs: config.watchdogIdleMs,
|
|
589
674
|
maxRePokes: config.watchdogMaxRePokes,
|
|
590
675
|
onFire: () => {
|
|
@@ -761,6 +846,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
761
846
|
if (action === "on" || action === "trigger") {
|
|
762
847
|
autoPokeArmed = true;
|
|
763
848
|
pokeExplicitlyOff = false;
|
|
849
|
+
escPokePaused = false;
|
|
764
850
|
cycle = freshCycleFlags();
|
|
765
851
|
settledWithoutProgress = 0;
|
|
766
852
|
lastChallengedSignature = undefined;
|
|
@@ -779,7 +865,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
779
865
|
ctx.ui.notify("Poke queued. We'll re-check for unfinished todos after this turn.", "info");
|
|
780
866
|
return;
|
|
781
867
|
}
|
|
782
|
-
await runTurnEndCheck(ctx);
|
|
868
|
+
await runTurnEndCheck(ctx, true);
|
|
783
869
|
refreshWidget(ctx);
|
|
784
870
|
return;
|
|
785
871
|
}
|
|
@@ -793,19 +879,100 @@ export default function (pi: ExtensionAPI) {
|
|
|
793
879
|
// Events
|
|
794
880
|
// ------------------------------------------------------------------------
|
|
795
881
|
|
|
882
|
+
pi.on("agent_end", (event) => {
|
|
883
|
+
// Record the most recent run's abort status straight from the run's
|
|
884
|
+
// own messages; agent_settled reads this (it fires after agent_end).
|
|
885
|
+
const messages = event.messages as Array<{ role?: string; stopReason?: string; errorMessage?: string }>;
|
|
886
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
887
|
+
const m = messages[i];
|
|
888
|
+
if (m.role === "assistant") {
|
|
889
|
+
lastRunAborted = m.stopReason === "aborted" || looksInterrupted(m.stopReason, m.errorMessage);
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
watchdog.notifyActivity();
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
897
|
+
// A pending delayed poke is moot once a new run starts: this run's
|
|
898
|
+
// settle will schedule a fresh one if todos remain open.
|
|
899
|
+
cancelPendingPoke();
|
|
900
|
+
// A new run means the user (or machinery) re-engaged: an Esc pause
|
|
901
|
+
// lifts outside the abort-correlation window where the just-triggered
|
|
902
|
+
// teardown is still settling (pi-simple-goal's maybeResumeOnActivity).
|
|
903
|
+
if (escPokePaused && Date.now() - lastEscapeAt > ESCAPE_ABORT_WINDOW_MS) {
|
|
904
|
+
escPokePaused = false;
|
|
905
|
+
if (config.enabled && config.autoPoke && !pokeExplicitlyOff && pokeUiAllowed) {
|
|
906
|
+
autoPokeArmed = true;
|
|
907
|
+
cycle = freshCycleFlags();
|
|
908
|
+
}
|
|
909
|
+
refreshWidget(ctx);
|
|
910
|
+
}
|
|
911
|
+
watchdog.notifyActivity();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
pi.on("session_before_compact", () => {
|
|
915
|
+
watchdog.notifyActivity(); // compaction can run long; never starve the watchdog
|
|
916
|
+
});
|
|
917
|
+
pi.on("session_compact", () => {
|
|
918
|
+
watchdog.notifyActivity();
|
|
919
|
+
});
|
|
920
|
+
pi.on("session_compact_failed", () => {
|
|
921
|
+
watchdog.notifyActivity();
|
|
922
|
+
});
|
|
923
|
+
|
|
796
924
|
pi.on("session_start", (_event, ctx) => {
|
|
797
925
|
watchdogCtx = ctx;
|
|
798
926
|
watchdog.notifyActivity();
|
|
927
|
+
cancelPendingPoke(); // a stale delayed poke never crosses sessions
|
|
799
928
|
idleNudgeSent = false;
|
|
800
929
|
pendingObservations.length = 0;
|
|
801
930
|
cycle = freshCycleFlags();
|
|
931
|
+
lastRunAborted = false;
|
|
802
932
|
settledWithoutProgress = 0;
|
|
803
933
|
lastSettledSignature = undefined;
|
|
804
934
|
lastChallengedSignature = undefined;
|
|
805
935
|
lastPokeTargets = undefined;
|
|
806
936
|
pokeExplicitlyOff = false;
|
|
937
|
+
escPokePaused = false;
|
|
938
|
+
lastEscapeAt = 0;
|
|
807
939
|
pokeUiAllowed = ctx.hasUI;
|
|
808
940
|
autoPokeArmed = config.autoPoke && ctx.hasUI;
|
|
941
|
+
// TUI only: watch raw terminal input for the Escape key so an aborted
|
|
942
|
+
// run can be correlated with the user's actual keypress (pi-simple-goal
|
|
943
|
+
// pattern). stopReason alone is unreliable: provider stacks can
|
|
944
|
+
// finalize an abort as a generic error naming the abort.
|
|
945
|
+
try {
|
|
946
|
+
const uictx = ctx as { mode?: string; ui: { onTerminalInput?: (cb: (data: string) => unknown) => () => void } };
|
|
947
|
+
if (uictx.mode === "tui" && typeof uictx.ui.onTerminalInput === "function") {
|
|
948
|
+
unsubscribeTerminalInput = uictx.ui.onTerminalInput((data) => {
|
|
949
|
+
watchdog.notifyActivity(); // any keystroke is user activity
|
|
950
|
+
if (data === "\x1b" || data === "\x1b\x1b" || data === "\x1b[27u") {
|
|
951
|
+
lastEscapeAt = Date.now();
|
|
952
|
+
// Escape = manual control (goal-plugin pattern): cancel any
|
|
953
|
+
// pending delayed poke and hold poking until the next run.
|
|
954
|
+
cancelPendingPoke();
|
|
955
|
+
if (!escPokePaused) {
|
|
956
|
+
escPokePaused = true;
|
|
957
|
+
refreshWidget(ctx);
|
|
958
|
+
}
|
|
959
|
+
return undefined;
|
|
960
|
+
}
|
|
961
|
+
if (pokeScheduler.isPending()) {
|
|
962
|
+
// Typing during the poke window cancels the poke and pauses
|
|
963
|
+
// the cycle (pi-simple-goal restart-countdown behavior).
|
|
964
|
+
cancelPendingPoke();
|
|
965
|
+
escPokePaused = true;
|
|
966
|
+
refreshWidget(ctx);
|
|
967
|
+
}
|
|
968
|
+
return undefined;
|
|
969
|
+
});
|
|
970
|
+
terminalInputActive = true;
|
|
971
|
+
}
|
|
972
|
+
} catch {
|
|
973
|
+
// Non-TUI or older pi: aborts fall back to machinery classification.
|
|
974
|
+
terminalInputActive = false;
|
|
975
|
+
}
|
|
809
976
|
reconstructState(ctx);
|
|
810
977
|
refreshWidget(ctx);
|
|
811
978
|
});
|
|
@@ -839,6 +1006,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
839
1006
|
|
|
840
1007
|
pi.on("session_shutdown", () => {
|
|
841
1008
|
clearInterval(watchdogTimer);
|
|
1009
|
+
cancelPendingPoke();
|
|
1010
|
+
if (unsubscribeTerminalInput) {
|
|
1011
|
+
unsubscribeTerminalInput();
|
|
1012
|
+
unsubscribeTerminalInput = undefined;
|
|
1013
|
+
}
|
|
1014
|
+
terminalInputActive = false;
|
|
842
1015
|
watchdogCtx = undefined;
|
|
843
1016
|
});
|
|
844
1017
|
|
|
@@ -857,10 +1030,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
857
1030
|
if (!ctx.isIdle()) return;
|
|
858
1031
|
// jcode's Esc: an interrupted run disarms auto-poke entirely and
|
|
859
1032
|
// injects no follow-up of any kind.
|
|
860
|
-
if (wasLastRunAborted(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1033
|
+
if (wasLastRunAborted()) {
|
|
1034
|
+
// Escape correlation (pi-simple-goal pattern): only an aborted run
|
|
1035
|
+
// with a RECENT raw Escape keypress behind it is the user's stop —
|
|
1036
|
+
// that quiets the settle and pauses the poke until the next run.
|
|
1037
|
+
// Any other abort (compaction, ctx.abort(), transport, provider
|
|
1038
|
+
// stacks mislabeling the interrupt) is machinery: stay quiet and keep
|
|
1039
|
+
// the cycle armed; the watchdog covers a genuinely lost continuation.
|
|
1040
|
+
if (lastEscapeAt > 0 && Date.now() - lastEscapeAt <= ESCAPE_ABORT_WINDOW_MS) {
|
|
1041
|
+
disarm();
|
|
1042
|
+
escPokePaused = true; // not sticky: the next agent run lifts it
|
|
1043
|
+
refreshWidget(ctx);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
864
1046
|
return;
|
|
865
1047
|
}
|
|
866
1048
|
if (!autoPokeArmed) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure delayed-poke scheduler (pi-simple-goal restartDelayMs pattern).
|
|
3
|
+
*
|
|
4
|
+
* The settle poke can be delayed (config.pokeDelayMs): while a poke is
|
|
5
|
+
* pending, any terminal input cancels it and pauses poking until the next
|
|
6
|
+
* agent run (see index.ts). This module owns only the timer lifecycle so it
|
|
7
|
+
* can be unit-tested without pi. A delayMs of 0 fires synchronously, which
|
|
8
|
+
* preserves the immediate (pure jcode) behavior with no branching at the
|
|
9
|
+
* call sites.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PokeScheduler {
|
|
13
|
+
/** Schedule a delayed poke, replacing any pending one. */
|
|
14
|
+
schedule(fire: () => void): void;
|
|
15
|
+
/** Cancel the pending poke (if any). */
|
|
16
|
+
cancel(): void;
|
|
17
|
+
/** Whether a poke is currently scheduled and waiting. */
|
|
18
|
+
isPending(): boolean;
|
|
19
|
+
/** Epoch ms when the pending poke fires; 0 when none pending. */
|
|
20
|
+
deadlineAt(): number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createPokeScheduler(delayMs: number): PokeScheduler {
|
|
24
|
+
let timer: NodeJS.Timeout | undefined;
|
|
25
|
+
let deadline = 0;
|
|
26
|
+
|
|
27
|
+
const clear = (): void => {
|
|
28
|
+
if (timer !== undefined) {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
timer = undefined;
|
|
31
|
+
}
|
|
32
|
+
deadline = 0;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
schedule(fire: () => void): void {
|
|
37
|
+
if (delayMs <= 0) {
|
|
38
|
+
fire();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
clear();
|
|
42
|
+
deadline = Date.now() + delayMs;
|
|
43
|
+
timer = setTimeout(() => {
|
|
44
|
+
timer = undefined;
|
|
45
|
+
deadline = 0;
|
|
46
|
+
fire();
|
|
47
|
+
}, delayMs);
|
|
48
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
49
|
+
},
|
|
50
|
+
cancel: clear,
|
|
51
|
+
isPending: (): boolean => timer !== undefined,
|
|
52
|
+
deadlineAt: (): number => deadline,
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/watchdog.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface WatchdogDeps {
|
|
|
19
19
|
isIdle: () => boolean;
|
|
20
20
|
incompleteCount: () => number;
|
|
21
21
|
wasAborted: () => boolean;
|
|
22
|
+
/** Esc- or typing-paused cycle: the watchdog must not inject either. */
|
|
23
|
+
isPaused: () => boolean;
|
|
22
24
|
idleMs: number;
|
|
23
25
|
maxRePokes: number;
|
|
24
26
|
/** Re-send the auto-poke follow-up (fire-and-forget is fine). */
|
|
@@ -49,6 +51,13 @@ export function createWatchdog(deps: WatchdogDeps): Watchdog {
|
|
|
49
51
|
lastActivity = now;
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
54
|
+
if (deps.isPaused()) {
|
|
55
|
+
// A paused cycle (Escape, or typing during the poke window) means
|
|
56
|
+
// the user took control; the watchdog stays quiet and the window
|
|
57
|
+
// restarts so unpausing (next agent run) gets a fresh idle period.
|
|
58
|
+
lastActivity = now;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
52
61
|
if (deps.incompleteCount() === 0) {
|
|
53
62
|
lastActivity = now;
|
|
54
63
|
rePokes = 0;
|
package/src/widget.ts
CHANGED
|
@@ -30,6 +30,10 @@ export interface TodoWidgetComponent {
|
|
|
30
30
|
/** Machine state the header tail should surface. */
|
|
31
31
|
export interface WidgetRuntime {
|
|
32
32
|
armed: boolean;
|
|
33
|
+
/** An Escape-paused cycle: suppressed until the user re-engages */
|
|
34
|
+
escPaused?: boolean;
|
|
35
|
+
/** Epoch ms when a delayed poke fires; 0 = none pending (pokeDelayMs). */
|
|
36
|
+
pokePendingAt?: number;
|
|
33
37
|
gateAttempts: number;
|
|
34
38
|
gateMaxAttempts: number;
|
|
35
39
|
/** Ids of the todos that caused the most recent poke/gate challenge */
|
|
@@ -105,7 +109,20 @@ function buildLeftColumn(
|
|
|
105
109
|
// user has been seeing.
|
|
106
110
|
let status: string;
|
|
107
111
|
if (!allSettled) {
|
|
108
|
-
|
|
112
|
+
const pendingIn =
|
|
113
|
+
runtime.pokePendingAt && runtime.pokePendingAt > 0
|
|
114
|
+
? Math.max(1, Math.ceil((runtime.pokePendingAt - Date.now()) / 1000))
|
|
115
|
+
: 0;
|
|
116
|
+
// A pause (Escape, or typing during the poke window) outranks the armed
|
|
117
|
+
// state: the cycle stays armed underneath but is held, so the user must
|
|
118
|
+
// see "poke paused", not a misleading "auto-poke".
|
|
119
|
+
status = runtime.escPaused
|
|
120
|
+
? "· poke paused"
|
|
121
|
+
: runtime.armed
|
|
122
|
+
? pendingIn > 0
|
|
123
|
+
? `· poke in ${pendingIn}s`
|
|
124
|
+
: "· auto-poke"
|
|
125
|
+
: "· poke off";
|
|
109
126
|
} else if (summary.needs_validation) {
|
|
110
127
|
status =
|
|
111
128
|
runtime.gateAttempts > 0
|
package/tests/test-todo.cjs
CHANGED
|
@@ -17,6 +17,13 @@ const os = require("node:os");
|
|
|
17
17
|
const path = require("node:path");
|
|
18
18
|
const fs = require("node:fs");
|
|
19
19
|
|
|
20
|
+
// Point the extension's config at a scratch state dir BEFORE its module
|
|
21
|
+
// loads: the e2e suite runs with a fast pokeDelayMs (400ms) so delayed-poke
|
|
22
|
+
// scenarios exercise the real scheduler without waiting the real 20s.
|
|
23
|
+
const TEST_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pi-jtodo-cfg-"));
|
|
24
|
+
fs.writeFileSync(path.join(TEST_STATE_DIR, "config.json"), JSON.stringify({ pokeDelayMs: 400 }));
|
|
25
|
+
process.env.PI_JTODO_STATE_DIR = TEST_STATE_DIR;
|
|
26
|
+
|
|
20
27
|
let PI_PKG;
|
|
21
28
|
try {
|
|
22
29
|
PI_PKG = path.dirname(require.resolve("@earendil-works/pi-coding-agent/package.json"));
|
|
@@ -39,6 +46,31 @@ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
|
39
46
|
|
|
40
47
|
async function freshSession(extraLoaderOptions) {
|
|
41
48
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-jtodo-test-"));
|
|
49
|
+
// Recording UI context: bindExtensions() fires session_start (arming +
|
|
50
|
+
// branch replay + raw-input listener), widgets/notices are recorded, and
|
|
51
|
+
// tests can inject synthetic keystrokes (the Esc/typing paths are
|
|
52
|
+
// TUI-only otherwise — this closes the harness blind spot).
|
|
53
|
+
const rec = { inputHandler: undefined, widgets: [], notifies: [] };
|
|
54
|
+
const uiContext = {
|
|
55
|
+
select: async () => undefined,
|
|
56
|
+
confirm: async () => false,
|
|
57
|
+
input: async () => undefined,
|
|
58
|
+
editor: async () => undefined,
|
|
59
|
+
notify: (message, type) => { rec.notifies.push({ message, type }); },
|
|
60
|
+
onTerminalInput: (handler) => {
|
|
61
|
+
rec.inputHandler = handler;
|
|
62
|
+
return () => { if (rec.inputHandler === handler) rec.inputHandler = undefined; };
|
|
63
|
+
},
|
|
64
|
+
setStatus: () => {},
|
|
65
|
+
setWorkingMessage: () => {},
|
|
66
|
+
setWorkingVisible: () => {},
|
|
67
|
+
setWorkingIndicator: () => {},
|
|
68
|
+
setWidget: (key, content) => {
|
|
69
|
+
const i = rec.widgets.findIndex((w) => w.key === key);
|
|
70
|
+
if (i >= 0) rec.widgets.splice(i, 1);
|
|
71
|
+
if (content !== undefined) rec.widgets.push({ key, content });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
42
74
|
const loader = new pi.DefaultResourceLoader({
|
|
43
75
|
cwd,
|
|
44
76
|
agentDir: pi.getAgentDir(),
|
|
@@ -53,7 +85,10 @@ async function freshSession(extraLoaderOptions) {
|
|
|
53
85
|
resourceLoader: loader,
|
|
54
86
|
tools: ["todo"],
|
|
55
87
|
});
|
|
88
|
+
await session.bindExtensions({ uiContext, mode: "tui" });
|
|
56
89
|
return {
|
|
90
|
+
rec,
|
|
91
|
+
sendKeys: (data) => { if (rec.inputHandler) rec.inputHandler(data); },
|
|
57
92
|
session,
|
|
58
93
|
exec: (params, signal) =>
|
|
59
94
|
session
|
|
@@ -389,7 +424,7 @@ async function suiteA() {
|
|
|
389
424
|
alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
|
|
390
425
|
});
|
|
391
426
|
const { createWatchdog } = await jiti.import(path.join(EXT_DIR, "watchdog.ts"));
|
|
392
|
-
let t = 0, armed = true, idle = true, open = 3, aborted = false;
|
|
427
|
+
let t = 0, armed = true, idle = true, open = 3, aborted = false, paused = false;
|
|
393
428
|
const fires = [], starves = [];
|
|
394
429
|
const wd = createWatchdog({
|
|
395
430
|
now: () => t,
|
|
@@ -397,6 +432,7 @@ async function suiteA() {
|
|
|
397
432
|
isIdle: () => idle,
|
|
398
433
|
incompleteCount: () => open,
|
|
399
434
|
wasAborted: () => aborted,
|
|
435
|
+
isPaused: () => paused,
|
|
400
436
|
idleMs: 90_000,
|
|
401
437
|
maxRePokes: 3,
|
|
402
438
|
onFire: (n) => fires.push(n),
|
|
@@ -428,10 +464,47 @@ async function suiteA() {
|
|
|
428
464
|
// unarmed never fires
|
|
429
465
|
armed = false; aborted = false; t += 400_000; wd.tick();
|
|
430
466
|
const unarmedQuiet = fires.length === 4;
|
|
431
|
-
|
|
432
|
-
|
|
467
|
+
// paused cycle (Esc or typing took over): watchdog stays quiet and the
|
|
468
|
+
// idle window restarts each paused tick, so unpausing gets a fresh window
|
|
469
|
+
armed = true; paused = true; t += 400_000; wd.tick();
|
|
470
|
+
const pausedQuiet = fires.length === 4;
|
|
471
|
+
paused = false; t += 50_000; wd.tick(); // window restarted by the paused tick
|
|
472
|
+
const pausedWindowRestarted = fires.length === 4;
|
|
473
|
+
t += 91_000; wd.tick(); // fresh full window after unpausing -> 5th fire
|
|
474
|
+
const resumedAfterPause = fires.length === 5 && fires.at(-1) === 1;
|
|
475
|
+
if (fires[0] === 1 && fires[1] === 2 && postponed && capped && resetWorks && settledQuiet && abortWins && unarmedQuiet && pausedQuiet && pausedWindowRestarted && resumedAfterPause) {
|
|
476
|
+
ok("T14 watchdog", "fire/postpone/cap/reset/settled-quiet/abort/unarmed/paused-quiet all correct");
|
|
433
477
|
} else {
|
|
434
|
-
bad("T14 watchdog", JSON.stringify({ fires, starves, postponed, capped, resetWorks, settledQuiet, abortWins, unarmedQuiet }));
|
|
478
|
+
bad("T14 watchdog", JSON.stringify({ fires, starves, postponed, capped, resetWorks, settledQuiet, abortWins, unarmedQuiet, pausedQuiet, pausedWindowRestarted, resumedAfterPause }));
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// T20: delayed-poke scheduler (pure timer module, real clock).
|
|
483
|
+
await run("T20 poke scheduler", async () => {
|
|
484
|
+
const { createJiti } = require(`${PI_PKG}/node_modules/jiti/lib/jiti.cjs`);
|
|
485
|
+
const jiti = createJiti(__filename, {
|
|
486
|
+
alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
|
|
487
|
+
});
|
|
488
|
+
const { createPokeScheduler } = await jiti.import(path.join(EXT_DIR, "poke-scheduler.ts"));
|
|
489
|
+
const fired = [];
|
|
490
|
+
const s = createPokeScheduler(80);
|
|
491
|
+
const idleStart = !s.isPending() && s.deadlineAt() === 0;
|
|
492
|
+
s.schedule(() => fired.push("a"));
|
|
493
|
+
const pending = s.isPending() && s.deadlineAt() > Date.now();
|
|
494
|
+
s.cancel();
|
|
495
|
+
const cancelWorks = !s.isPending() && fired.length === 0 && s.deadlineAt() === 0;
|
|
496
|
+
s.schedule(() => fired.push("a"));
|
|
497
|
+
s.schedule(() => fired.push("b")); // replaces the pending poke
|
|
498
|
+
await sleep(200);
|
|
499
|
+
const firedOnceLatest = fired.join(",") === "b" && !s.isPending();
|
|
500
|
+
const zeroDelay = createPokeScheduler(0);
|
|
501
|
+
const syncFired = [];
|
|
502
|
+
zeroDelay.schedule(() => syncFired.push("now"));
|
|
503
|
+
const immediate = syncFired.join(",") === "now" && !zeroDelay.isPending();
|
|
504
|
+
if (idleStart && pending && cancelWorks && firedOnceLatest && immediate) {
|
|
505
|
+
ok("T20 poke scheduler", "schedule/cancel/deadline/replacement/zero-delay all correct");
|
|
506
|
+
} else {
|
|
507
|
+
bad("T20 poke scheduler", JSON.stringify({ idleStart, pending, cancelWorks, firedOnceLatest, immediate, fired }));
|
|
435
508
|
}
|
|
436
509
|
});
|
|
437
510
|
|
|
@@ -590,6 +663,12 @@ async function suiteA() {
|
|
|
590
663
|
const empty = widget.renderTodoWidgetLines({ todos: [], plan: {}, goals: [] }, rtOn, 6, 60, stubTheme);
|
|
591
664
|
const unarmed = widget.renderTodoWidgetLines(state, rtOff, 6, 60, stubTheme);
|
|
592
665
|
const offMarker = unescape(unarmed.at(-1)).includes("poke off");
|
|
666
|
+
// Esc-pause label must be distinct from the sticky /todos poke off state.
|
|
667
|
+
const rtEscPaused = { armed: false, escPaused: true, gateAttempts: 0, gateMaxAttempts: 5 };
|
|
668
|
+
const escPausedLine = widget.renderTodoWidgetLines(state, rtEscPaused, 6, 60, stubTheme);
|
|
669
|
+
const escPausedMarker =
|
|
670
|
+
unescape(escPausedLine.at(-1)).includes("poke paused") &&
|
|
671
|
+
!unescape(escPausedLine.at(-1)).includes("poke off");
|
|
593
672
|
// value-rank: shown item lines are exactly a(in_progress), then pending
|
|
594
673
|
// in declaration order; settled items are pushed into the overflow.
|
|
595
674
|
const shownIds = lines.slice(1, -1).map((l) => (unescape(l).match(/#(\w+)/) || [])[1]);
|
|
@@ -682,10 +761,10 @@ async function suiteA() {
|
|
|
682
761
|
markRow("g-warn")?.includes("🟡") && markRow("g-ok")?.includes("✔") &&
|
|
683
762
|
markRow("g-act")?.includes("🚧") && markRow("g-wait")?.includes("🔲") &&
|
|
684
763
|
markRow("g-nogoal")?.includes("–");
|
|
685
|
-
if (firstIsHeader && bottomStatus && inProgressOnTop && capped && openBreakdown && withinWidth && armedMarker && ranked && lateLeads && indented && empty.length === 0 && !unescape(unarmed.at(-1)).includes("auto-poke") && offMarker && confTails && gateShown && doneShown && intentShown && multiClean && icons && finger && tableHeader && activeFirst && verifyCells && builderCells && tableFooter && tableDropped && aligned && allAligned) {
|
|
764
|
+
if (firstIsHeader && bottomStatus && inProgressOnTop && capped && openBreakdown && withinWidth && armedMarker && ranked && lateLeads && indented && empty.length === 0 && !unescape(unarmed.at(-1)).includes("auto-poke") && offMarker && escPausedMarker && confTails && gateShown && doneShown && intentShown && multiClean && icons && finger && tableHeader && activeFirst && verifyCells && builderCells && tableFooter && tableDropped && aligned && allAligned) {
|
|
686
765
|
ok("T13 widget", "intention header + bottom status + table (Todo Goal) correct");
|
|
687
766
|
} else {
|
|
688
|
-
bad("T13 widget", JSON.stringify({ firstIsHeader, bottomStatus, inProgressOnTop, capped, withinWidth, offMarker, confTails, gateShown, doneShown, intentShown, multiClean, icons, finger, tableHeader, activeFirst, verifyCells, builderCells, tableFooter, tableDropped, aligned, allAligned, intentLines, mixedTbl }, null, 1));
|
|
767
|
+
bad("T13 widget", JSON.stringify({ firstIsHeader, bottomStatus, inProgressOnTop, capped, withinWidth, offMarker, escPausedMarker, confTails, gateShown, doneShown, intentShown, multiClean, icons, finger, tableHeader, activeFirst, verifyCells, builderCells, tableFooter, tableDropped, aligned, allAligned, intentLines, mixedTbl }, null, 1));
|
|
689
768
|
}
|
|
690
769
|
});
|
|
691
770
|
}
|
|
@@ -699,6 +778,7 @@ function makeMockStreamSimple(calls, script) {
|
|
|
699
778
|
return function streamSimple(model, context, options) {
|
|
700
779
|
callIndex += 1;
|
|
701
780
|
calls.push({ index: callIndex, context });
|
|
781
|
+
if (typeof script.onRequest === "function") script.onRequest(context);
|
|
702
782
|
const step = callIndex;
|
|
703
783
|
const scriptCall = script.call;
|
|
704
784
|
const scriptText = script.text;
|
|
@@ -717,8 +797,22 @@ function makeMockStreamSimple(calls, script) {
|
|
|
717
797
|
stopReason: "pending",
|
|
718
798
|
timestamp: Date.now(),
|
|
719
799
|
};
|
|
720
|
-
|
|
800
|
+
try {
|
|
721
801
|
stream.push({ type: "start", partial: output });
|
|
802
|
+
if (script.gateAt === step) {
|
|
803
|
+
// Hold the stream open so the test can abort mid-flight; honor the
|
|
804
|
+
// abort signal exactly like pi's own providers do (openai-completions.js).
|
|
805
|
+
const signal = options?.signal;
|
|
806
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
807
|
+
await new Promise((resolve, reject) => {
|
|
808
|
+
const onAbort = () => reject(new Error("Request was aborted"));
|
|
809
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
810
|
+
(script.gatePromise || Promise.resolve()).then(
|
|
811
|
+
() => { signal?.removeEventListener("abort", onAbort); resolve(); },
|
|
812
|
+
reject,
|
|
813
|
+
);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
722
816
|
const toolCall = scriptCall(step);
|
|
723
817
|
if (toolCall) {
|
|
724
818
|
output.content.push(toolCall);
|
|
@@ -737,9 +831,12 @@ function makeMockStreamSimple(calls, script) {
|
|
|
737
831
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
738
832
|
stream.end();
|
|
739
833
|
} catch (err) {
|
|
740
|
-
output.stopReason = "error";
|
|
834
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
835
|
+
// Simulate provider stacks (pi-robust-provider observed live) that
|
|
836
|
+
// finalize an abort as a generic error naming the abort.
|
|
837
|
+
if (script.misclassifiedAbortAt === step) output.stopReason = "error";
|
|
741
838
|
output.errorMessage = err.message;
|
|
742
|
-
stream.push({ type: "error", reason:
|
|
839
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
743
840
|
stream.end();
|
|
744
841
|
}
|
|
745
842
|
})();
|
|
@@ -909,6 +1006,14 @@ const SCRIPT_B3 = {
|
|
|
909
1006
|
|
|
910
1007
|
async function runScenario(name, script, assertions) {
|
|
911
1008
|
const calls = [];
|
|
1009
|
+
if (script.abortAtCall) {
|
|
1010
|
+
// Gate the abortAtCall'th provider call mid-stream so the test can abort
|
|
1011
|
+
// the run deterministically while it is in flight.
|
|
1012
|
+
script.gateAt = script.abortAtCall;
|
|
1013
|
+
script.gatePromise = new Promise((resolve) => {
|
|
1014
|
+
script.releaseGate = resolve;
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
912
1017
|
const mockFactory = {
|
|
913
1018
|
name: "mock-provider",
|
|
914
1019
|
factory: (api) => {
|
|
@@ -922,20 +1027,86 @@ async function runScenario(name, script, assertions) {
|
|
|
922
1027
|
});
|
|
923
1028
|
},
|
|
924
1029
|
};
|
|
925
|
-
await run(name, async ({ session }) => {
|
|
1030
|
+
await run(name, async ({ session, rec }) => {
|
|
926
1031
|
await session.setModel(MOCK_MODEL);
|
|
1032
|
+
let abortTimerFired = false;
|
|
1033
|
+
if (script.abortAtCall) {
|
|
1034
|
+
// prompt() resolves only after every queued follow-up run finishes, so
|
|
1035
|
+
// the poll loop below can never reach the gated call. Simulate Esc from
|
|
1036
|
+
// a timer: abort once the gated provider call has actually started.
|
|
1037
|
+
const startedAt = Date.now();
|
|
1038
|
+
const abortWhenStarted = () => {
|
|
1039
|
+
if (calls.length >= script.abortAtCall) {
|
|
1040
|
+
abortTimerFired = true;
|
|
1041
|
+
// Raw Escape first (TUI Esc = user stop): this is exactly what the
|
|
1042
|
+
// real terminal delivers to the listener before pi aborts the run.
|
|
1043
|
+
if (script.escBeforeAbort && rec.inputHandler) {
|
|
1044
|
+
rec.inputHandler("\x1b");
|
|
1045
|
+
try {
|
|
1046
|
+
const w = rec.widgets.find((x) => x.key === "pi-jtodo");
|
|
1047
|
+
const th = { fg: (_c, s) => String(s), bold: (s) => String(s) };
|
|
1048
|
+
const component = typeof w?.content === "function" ? w.content(null, th) : w?.content;
|
|
1049
|
+
script.escPaint = (component?.render?.(120) ?? []).map(String);
|
|
1050
|
+
} catch { script.escPaint = []; }
|
|
1051
|
+
}
|
|
1052
|
+
if (script.compactInsteadOfAbort) session.compact();
|
|
1053
|
+
else session.abort();
|
|
1054
|
+
// Safety net: if the abort somehow never reaches the mock, release
|
|
1055
|
+
// the gate so the scenario fails with diagnostics instead of hanging.
|
|
1056
|
+
setTimeout(() => { if (script.releaseGate) script.releaseGate(); }, 3000);
|
|
1057
|
+
} else if (Date.now() - startedAt < 12_000) {
|
|
1058
|
+
setTimeout(abortWhenStarted, 100);
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
setTimeout(abortWhenStarted, 300);
|
|
1062
|
+
}
|
|
927
1063
|
await session.prompt("begin");
|
|
928
1064
|
|
|
929
1065
|
const deadline = Date.now() + 20_000;
|
|
930
1066
|
let prompted = false;
|
|
1067
|
+
let aborted = false;
|
|
1068
|
+
let resumed = false;
|
|
1069
|
+
let inputArmed = false;
|
|
1070
|
+
let inputSent = false;
|
|
931
1071
|
while (Date.now() < deadline) {
|
|
932
1072
|
if (!prompted && script.promptAfter && calls.length >= script.promptAfter) {
|
|
933
1073
|
prompted = true;
|
|
934
1074
|
void session.prompt(script.promptText ?? "continue");
|
|
935
1075
|
}
|
|
1076
|
+
if (!inputArmed && script.inputAfter && calls.length >= script.inputAfter.call) {
|
|
1077
|
+
inputArmed = true;
|
|
1078
|
+
// Synthetic keystroke lands while the session is idle with a poke
|
|
1079
|
+
// pending (the typing/Esc paths are TUI-only otherwise).
|
|
1080
|
+
setTimeout(() => {
|
|
1081
|
+
inputSent = true;
|
|
1082
|
+
if (rec.inputHandler) rec.inputHandler(script.inputAfter.data);
|
|
1083
|
+
// Snapshot the paint synchronously: the widget is a live thunk
|
|
1084
|
+
// factory, so building + rendering it later would show the final
|
|
1085
|
+
// state instead of the paused moment.
|
|
1086
|
+
try {
|
|
1087
|
+
const w = rec.widgets.find((x) => x.key === "pi-jtodo");
|
|
1088
|
+
const th = { fg: (_c, s) => String(s), bold: (s) => String(s) };
|
|
1089
|
+
const component = typeof w?.content === "function" ? w.content(null, th) : w?.content;
|
|
1090
|
+
script.inputAfter.paint = (component?.render?.(120) ?? []).map(String);
|
|
1091
|
+
} catch { script.inputAfter.paint = []; }
|
|
1092
|
+
}, script.inputAfter.atMs);
|
|
1093
|
+
}
|
|
1094
|
+
if (inputSent && script.promptAfterInput && !prompted) {
|
|
1095
|
+
prompted = true;
|
|
1096
|
+
void session.prompt(script.promptText ?? "resume");
|
|
1097
|
+
}
|
|
1098
|
+
if (!aborted && script.abortAtCall && calls.length >= script.abortAtCall) {
|
|
1099
|
+
aborted = true; // bookkeeping only; the timer above fired the Esc
|
|
1100
|
+
if (!abortTimerFired) session.abort();
|
|
1101
|
+
}
|
|
1102
|
+
if (aborted && !resumed) {
|
|
1103
|
+
resumed = true;
|
|
1104
|
+
await sleep(1500); // let the abort settle (disarm + quiet) run first
|
|
1105
|
+
void session.prompt(script.resumeText ?? "resume after the interrupt");
|
|
1106
|
+
}
|
|
936
1107
|
if (calls.length >= script.calls) {
|
|
937
1108
|
await sleep(800);
|
|
938
|
-
if (calls.length
|
|
1109
|
+
if (calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls)) break; // done
|
|
939
1110
|
}
|
|
940
1111
|
await sleep(100);
|
|
941
1112
|
}
|
|
@@ -943,8 +1114,9 @@ async function runScenario(name, script, assertions) {
|
|
|
943
1114
|
const customs = entries
|
|
944
1115
|
.filter((e) => e.type === "custom_message" && e.customType === "pi-jtodo/followup")
|
|
945
1116
|
.map((e) => (typeof e.content === "string" ? e.content : ""));
|
|
946
|
-
|
|
947
|
-
|
|
1117
|
+
const inRange = calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls);
|
|
1118
|
+
if (!inRange) {
|
|
1119
|
+
bad(`${name} flow`, `expected ${script.calls}${script.callsMax ? `..${script.callsMax}` : ""} provider calls, got ${calls.length}; customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
948
1120
|
return;
|
|
949
1121
|
}
|
|
950
1122
|
|
|
@@ -952,7 +1124,7 @@ async function runScenario(name, script, assertions) {
|
|
|
952
1124
|
(e) => e.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === "todo",
|
|
953
1125
|
);
|
|
954
1126
|
const lastDetails = todoResults.at(-1)?.message?.details;
|
|
955
|
-
await assertions({ calls, customs, lastDetails });
|
|
1127
|
+
await assertions({ calls, customs, lastDetails, entries, widgets: rec.widgets });
|
|
956
1128
|
}, { extensionFactories: [mockFactory] });
|
|
957
1129
|
}
|
|
958
1130
|
|
|
@@ -1021,6 +1193,308 @@ async function suiteB() {
|
|
|
1021
1193
|
if (done) ok("B3 final state", "both waves completed");
|
|
1022
1194
|
else bad("B3 final state", JSON.stringify(lastDetails?.todos));
|
|
1023
1195
|
});
|
|
1196
|
+
|
|
1197
|
+
// B4: Esc (session.abort) on the poke-driven run must disarm and inject
|
|
1198
|
+
// NOTHING afterwards, even though open todos remain. Flow: run A writes an
|
|
1199
|
+
// open todo and ends (settle #1 pokes), the poke-driven run B is aborted
|
|
1200
|
+
// mid-stream (the user's exact complaint), and both the abort settle and
|
|
1201
|
+
// the resumed run's settle stay quiet via the agent_end abort flag.
|
|
1202
|
+
// B4: an abort with NO raw Escape behind it is machinery (ctx.abort(),
|
|
1203
|
+
// extension-initiated, transport). Under the pi-simple-goal Escape model
|
|
1204
|
+
// the poke cycle stays armed: the aborted settle is quiet, but a resumed
|
|
1205
|
+
// run that still leaves todos open pokes normally. The real user-Esc pause
|
|
1206
|
+
// (raw \x1b keypress) is TUI-only and verified live, not in the SDK harness.
|
|
1207
|
+
const SCRIPT_B4 = {
|
|
1208
|
+
call(step) {
|
|
1209
|
+
if (step === 1) {
|
|
1210
|
+
return {
|
|
1211
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1212
|
+
arguments: {
|
|
1213
|
+
todos: [{ content: "machinery abort guard", status: "in_progress", priority: "high", id: "e1", group: "esc", confidence: 96 }],
|
|
1214
|
+
plan: { user_intention: "machinery abort e2e", understands_user_intent: 97 },
|
|
1215
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed" }],
|
|
1216
|
+
},
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
if (step === 5) {
|
|
1220
|
+
return {
|
|
1221
|
+
type: "toolCall", id: "call-done", name: "todo",
|
|
1222
|
+
arguments: {
|
|
1223
|
+
todos: [{ content: "machinery abort guard", status: "completed", priority: "high", id: "e1", group: "esc", confidence: 96, completion_confidence: 96 }],
|
|
1224
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed", end_to_end_ownership: 96 }],
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
return null;
|
|
1229
|
+
},
|
|
1230
|
+
text(step) {
|
|
1231
|
+
if (step === 2) return "Todos written.";
|
|
1232
|
+
if (step === 3) return "Poked: working on it, mid-stream.";
|
|
1233
|
+
if (step === 4) return "Resumed after the interrupt, still working.";
|
|
1234
|
+
return "All done now.";
|
|
1235
|
+
},
|
|
1236
|
+
abortAtCall: 3, // abort run B, the poke-driven continuation
|
|
1237
|
+
resumeText: "resume after the interrupt",
|
|
1238
|
+
calls: 6,
|
|
1239
|
+
};
|
|
1240
|
+
await runScenario("B4 machinery abort keeps the poke cycle armed", SCRIPT_B4, async ({ customs, entries }) => {
|
|
1241
|
+
const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
|
|
1242
|
+
if (pokes.length === 2) ok("B4 poke before and after abort", "poke #1 at the write settle, poke #2 after the resumed run — cycle stayed armed");
|
|
1243
|
+
else bad("B4 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1244
|
+
if (customs.length === 2) ok("B4 aborted settle quiet", "the abort settle itself injected nothing");
|
|
1245
|
+
else bad("B4 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1246
|
+
const stopReasons = entries
|
|
1247
|
+
.filter((e) => e.type === "message" && e.message?.role === "assistant")
|
|
1248
|
+
.map((e) => e.message.stopReason);
|
|
1249
|
+
if (stopReasons.includes("aborted")) ok("B4 harness abort recorded", `assistant stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1250
|
+
else bad("B4 harness abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// B5: pi-robust-provider observed live finalizing an Esc abort as a plain
|
|
1254
|
+
// error whose text names the abort ("This operation was aborted"). The
|
|
1255
|
+
// error-text fallback must classify it as a user interrupt and stay quiet.
|
|
1256
|
+
// B5: pi-robust-provider observed live finalizing an Esc abort as a plain
|
|
1257
|
+
// error whose text names the abort ("This operation was aborted"). Under
|
|
1258
|
+
// the raw-Escape classification model a stopReason mislabel changes
|
|
1259
|
+
// NOTHING: with no Escape keypress observed this is machinery, the settle is
|
|
1260
|
+
// quiet, and the cycle stays armed. The real-Esc correlation is live-only.
|
|
1261
|
+
const SCRIPT_B5 = {
|
|
1262
|
+
call(step) {
|
|
1263
|
+
if (step === 1) {
|
|
1264
|
+
return {
|
|
1265
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1266
|
+
arguments: {
|
|
1267
|
+
todos: [{ content: "misclassified abort guard", status: "in_progress", priority: "high", id: "m1", group: "esc", confidence: 96 }],
|
|
1268
|
+
plan: { user_intention: "misclassified abort e2e", understands_user_intent: 97 },
|
|
1269
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed" }],
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
if (step === 5) {
|
|
1274
|
+
return {
|
|
1275
|
+
type: "toolCall", id: "call-done", name: "todo",
|
|
1276
|
+
arguments: {
|
|
1277
|
+
todos: [{ content: "misclassified abort guard", status: "completed", priority: "high", id: "m1", group: "esc", confidence: 96, completion_confidence: 96 }],
|
|
1278
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed", end_to_end_ownership: 96 }],
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
return null;
|
|
1283
|
+
},
|
|
1284
|
+
text(step) {
|
|
1285
|
+
if (step === 2) return "Todos written.";
|
|
1286
|
+
if (step === 3) return "Poked: working, mid-stream.";
|
|
1287
|
+
if (step === 4) return "Resumed after the interrupt, still working.";
|
|
1288
|
+
return "All done now.";
|
|
1289
|
+
},
|
|
1290
|
+
abortAtCall: 3,
|
|
1291
|
+
misclassifiedAbortAt: 3, // the abort finalizes as stopReason "error" naming the abort
|
|
1292
|
+
resumeText: "resume after the interrupt",
|
|
1293
|
+
calls: 6,
|
|
1294
|
+
};
|
|
1295
|
+
await runScenario("B5 error-naming-abort (robust-provider) is machinery", SCRIPT_B5, async ({ customs, entries }) => {
|
|
1296
|
+
const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
|
|
1297
|
+
if (pokes.length === 2) ok("B5 poke before and after abort", "misclassified abort did not suppress the cycle");
|
|
1298
|
+
else bad("B5 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1299
|
+
if (customs.length === 2) ok("B5 aborted settle quiet", "the error-naming-abort settle injected nothing");
|
|
1300
|
+
else bad("B5 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1301
|
+
const interrupted = entries
|
|
1302
|
+
.filter((e) => e.type === "message" && e.message?.role === "assistant")
|
|
1303
|
+
.find((e) => /abort/i.test(e.message?.errorMessage ?? ""));
|
|
1304
|
+
if (interrupted?.message?.stopReason === "error") ok("B5 interrupt finalized as error", "errorMessage names the abort, stopReason is error");
|
|
1305
|
+
else bad("B5 interrupt finalized as error", `stopReason=${interrupted?.message?.stopReason} err=${interrupted?.message?.errorMessage}`);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// B6: compaction aborts the in-flight run but guarantees a continuation —
|
|
1309
|
+
// a system interrupt. Its settle must stay quiet WITHOUT disarming and
|
|
1310
|
+
// WITHOUT the sticky Esc off, so the resumed run's settle pokes normally.
|
|
1311
|
+
let b6DoneSent = false;
|
|
1312
|
+
let b6PokesSeen = 0;
|
|
1313
|
+
const SCRIPT_B6 = {
|
|
1314
|
+
onRequest(context) {
|
|
1315
|
+
// Count auto-poke messages already in the request history; robust
|
|
1316
|
+
// to compaction consuming 1 or 2 provider calls. User-role content may
|
|
1317
|
+
// be a string or a block array (same extraction as contextUserTexts).
|
|
1318
|
+
const texts = (context?.messages ?? [])
|
|
1319
|
+
.filter((m) => m?.role === "user")
|
|
1320
|
+
.map((m) =>
|
|
1321
|
+
typeof m?.content === "string"
|
|
1322
|
+
? m.content
|
|
1323
|
+
: (m?.content ?? []).map((c) => c?.text ?? "").join(""),
|
|
1324
|
+
);
|
|
1325
|
+
b6PokesSeen = texts.filter((t) => t.includes("You have 1 incomplete todo.")).length;
|
|
1326
|
+
},
|
|
1327
|
+
call(step) {
|
|
1328
|
+
if (step === 1) {
|
|
1329
|
+
return {
|
|
1330
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1331
|
+
arguments: {
|
|
1332
|
+
todos: [{ content: "compaction window guard", status: "in_progress", priority: "high", id: "c1", group: "esc", confidence: 96 }],
|
|
1333
|
+
plan: { user_intention: "compaction window e2e", understands_user_intent: 97 },
|
|
1334
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction" }],
|
|
1335
|
+
},
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
// Only the run driven by poke #2 completes the todo, so poke #2
|
|
1339
|
+
// provably fired (the resumed run before it must leave it open).
|
|
1340
|
+
if (step >= 5 && b6PokesSeen >= 2 && !b6DoneSent) {
|
|
1341
|
+
b6DoneSent = true;
|
|
1342
|
+
return {
|
|
1343
|
+
type: "toolCall", id: "call-done", name: "todo",
|
|
1344
|
+
arguments: {
|
|
1345
|
+
todos: [{ content: "compaction window guard", status: "completed", priority: "high", id: "c1", group: "esc", confidence: 96, completion_confidence: 96 }],
|
|
1346
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction", end_to_end_ownership: 96 }],
|
|
1347
|
+
},
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
return null;
|
|
1351
|
+
},
|
|
1352
|
+
text(step) {
|
|
1353
|
+
if (step === 2) return "Todos written. " + "Detailed work log entries accumulate here. ".repeat(2600);
|
|
1354
|
+
if (step === 3) return "Poked: working, mid-stream.";
|
|
1355
|
+
return "Continuing after compaction.";
|
|
1356
|
+
},
|
|
1357
|
+
abortAtCall: 3,
|
|
1358
|
+
compactInsteadOfAbort: true, // session.compact() aborts the gated run (system interrupt)
|
|
1359
|
+
promptAfter: 4, // after the compaction summarization call, resume the agent
|
|
1360
|
+
promptText: "resume the work",
|
|
1361
|
+
calls: 7,
|
|
1362
|
+
callsMax: 9, // compaction may consume 1 or 2 provider calls
|
|
1363
|
+
};
|
|
1364
|
+
await runScenario("B6 compaction abort keeps the poke cycle armed", SCRIPT_B6, async ({ customs, entries }) => {
|
|
1365
|
+
const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
|
|
1366
|
+
if (pokes.length === 2) ok("B6 armed preserved", "poke #1 pre-abort, poke #2 after the resumed run — no sticky-off");
|
|
1367
|
+
else bad("B6 armed preserved", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1368
|
+
const noisy = customs.filter((t) => t.includes("rose too sharply") || t.includes("not high enough") || t.includes("todo quality review"));
|
|
1369
|
+
if (noisy.length === 0) ok("B6 no spurious gates", "clean scores produced no challenges/digest");
|
|
1370
|
+
else bad("B6 no spurious gates", JSON.stringify(noisy));
|
|
1371
|
+
const stopReasons = entries
|
|
1372
|
+
.filter((e) => e.type === "message" && e.message?.role === "assistant")
|
|
1373
|
+
.map((e) => e.message.stopReason);
|
|
1374
|
+
if (stopReasons.includes("aborted")) ok("B6 compaction abort recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1375
|
+
else bad("B6 compaction abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// B7: typing during the pokeDelayMs window cancels the pending poke and
|
|
1379
|
+
// pauses the cycle; the pause lifts at the next agent run (goal-plugin
|
|
1380
|
+
// pattern) and the resumed run finishes cleanly.
|
|
1381
|
+
const SCRIPT_B7 = {
|
|
1382
|
+
call(step) {
|
|
1383
|
+
if (step === 1) {
|
|
1384
|
+
return {
|
|
1385
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1386
|
+
arguments: {
|
|
1387
|
+
todos: [{ content: "typing-cancel guard", status: "in_progress", priority: "high", id: "t1", group: "tc", confidence: 96 }],
|
|
1388
|
+
plan: { user_intention: "typing cancel e2e", understands_user_intent: 97 },
|
|
1389
|
+
goals: [{ group: "tc", closed_feedback_loop: 97, feedback_loop: "typing cancels the pending poke" }],
|
|
1390
|
+
},
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
if (step === 3) {
|
|
1394
|
+
return {
|
|
1395
|
+
type: "toolCall", id: "call-done", name: "todo",
|
|
1396
|
+
arguments: {
|
|
1397
|
+
todos: [{ content: "typing-cancel guard", status: "completed", priority: "high", id: "t1", group: "tc", confidence: 96, completion_confidence: 96 }],
|
|
1398
|
+
goals: [{ group: "tc", closed_feedback_loop: 97, feedback_loop: "typing cancels the pending poke", end_to_end_ownership: 96 }],
|
|
1399
|
+
},
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
return null;
|
|
1403
|
+
},
|
|
1404
|
+
text(step) {
|
|
1405
|
+
if (step === 2) return "Todos written.";
|
|
1406
|
+
return "Done after the resume.";
|
|
1407
|
+
},
|
|
1408
|
+
inputAfter: { call: 2, atMs: 150, data: "x" },
|
|
1409
|
+
promptAfterInput: true,
|
|
1410
|
+
promptText: "resume working on the todos",
|
|
1411
|
+
calls: 4,
|
|
1412
|
+
};
|
|
1413
|
+
await runScenario("B7 typing cancels the pending poke", SCRIPT_B7, async ({ customs, widgets, lastDetails }) => {
|
|
1414
|
+
if (customs.length === 0) ok("B7 typing cancel + pause", "the user keystroke canceled the pending poke; the resume run lifted the pause and finished quietly");
|
|
1415
|
+
else bad("B7 typing cancel + pause", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1416
|
+
const pausedPaint = SCRIPT_B7.inputAfter.paint?.some((l) => l.includes("poke paused"));
|
|
1417
|
+
if (pausedPaint) ok("B7 widget pause paint", "the widget bottom line rendered 'poke paused' while held");
|
|
1418
|
+
else bad("B7 widget pause paint", JSON.stringify(SCRIPT_B7.inputAfter.paint ?? null, (k, v) => (typeof v === "string" ? v.replace(/\x1b\[[0-9;]*m/g, "") : v)));
|
|
1419
|
+
const done = lastDetails?.todos?.[0]?.status === "completed";
|
|
1420
|
+
if (done) ok("B7 resume completed the todo", "the resumed run closed the item and the cycle ended clean");
|
|
1421
|
+
else bad("B7 resume completed the todo", JSON.stringify(lastDetails?.todos));
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
// B8: Escape during the poke window cancels the pending poke and holds the
|
|
1425
|
+
// pause for the Esc window; a resume inside that window stays quiet.
|
|
1426
|
+
const SCRIPT_B8 = {
|
|
1427
|
+
call(step) {
|
|
1428
|
+
if (step === 1) {
|
|
1429
|
+
return {
|
|
1430
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1431
|
+
arguments: {
|
|
1432
|
+
todos: [{ content: "esc-window guard", status: "in_progress", priority: "high", id: "e1", group: "ew", confidence: 96 }],
|
|
1433
|
+
plan: { user_intention: "esc window e2e", understands_user_intent: 97 },
|
|
1434
|
+
goals: [{ group: "ew", closed_feedback_loop: 97, feedback_loop: "esc holds the pause inside the window" }],
|
|
1435
|
+
},
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
return null;
|
|
1439
|
+
},
|
|
1440
|
+
text(step) {
|
|
1441
|
+
if (step === 2) return "Todos written.";
|
|
1442
|
+
return "Resumed inside the Esc window, still working.";
|
|
1443
|
+
},
|
|
1444
|
+
inputAfter: { call: 2, atMs: 150, data: "\x1b" },
|
|
1445
|
+
promptAfterInput: true,
|
|
1446
|
+
promptText: "resume while the Esc window is open",
|
|
1447
|
+
calls: 3,
|
|
1448
|
+
};
|
|
1449
|
+
await runScenario("B8 Esc during poke window holds the pause", SCRIPT_B8, async ({ customs, widgets }) => {
|
|
1450
|
+
if (customs.length === 0) ok("B8 esc cancel + held pause", "Escape canceled the pending poke and the in-window resume settle stayed quiet");
|
|
1451
|
+
else bad("B8 esc cancel + held pause", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1452
|
+
const pausedPaint = SCRIPT_B8.inputAfter.paint?.some((l) => l.includes("poke paused"));
|
|
1453
|
+
if (pausedPaint) ok("B8 widget pause paint", "'poke paused' rendered during the held pause");
|
|
1454
|
+
else bad("B8 widget pause paint", JSON.stringify(SCRIPT_B8.inputAfter.paint ?? null, (k, v) => (typeof v === "string" ? v.replace(/\x1b\[[0-9;]*m/g, "") : v)));
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
// B9: a raw Escape observed right before an abort classifies the run as the
|
|
1458
|
+
// user's stop — the settle disarms and pauses instead of re-poking. This is
|
|
1459
|
+
// the regression pin for the missing-constant crash the old suite missed
|
|
1460
|
+
// (the Esc path only runs when terminal input has been seen).
|
|
1461
|
+
const SCRIPT_B9 = {
|
|
1462
|
+
call(step) {
|
|
1463
|
+
if (step === 1) {
|
|
1464
|
+
return {
|
|
1465
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1466
|
+
arguments: {
|
|
1467
|
+
todos: [{ content: "user-esc guard", status: "in_progress", priority: "high", id: "u1", group: "ue", confidence: 96 }],
|
|
1468
|
+
plan: { user_intention: "user esc e2e", understands_user_intent: 97 },
|
|
1469
|
+
goals: [{ group: "ue", closed_feedback_loop: 97, feedback_loop: "user Esc pauses the poke" }],
|
|
1470
|
+
},
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
return null;
|
|
1474
|
+
},
|
|
1475
|
+
text(step) {
|
|
1476
|
+
if (step === 2) return "Todos written.";
|
|
1477
|
+
if (step === 3) return "Poked: working on it, mid-stream.";
|
|
1478
|
+
return "Resumed inside the Esc window.";
|
|
1479
|
+
},
|
|
1480
|
+
abortAtCall: 3,
|
|
1481
|
+
escBeforeAbort: true,
|
|
1482
|
+
resumeText: "resume after the user interrupt",
|
|
1483
|
+
calls: 4,
|
|
1484
|
+
};
|
|
1485
|
+
await runScenario("B9 user Esc abort pauses the poke", SCRIPT_B9, async ({ customs, entries, widgets }) => {
|
|
1486
|
+
const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
|
|
1487
|
+
if (pokes.length === 1) ok("B9 user-Esc settle quiet", "poke #1 at the write settle; the Esc-aborted settle and the in-window resume settle injected nothing");
|
|
1488
|
+
else bad("B9 user-Esc settle quiet", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1489
|
+
const stopReasons = entries
|
|
1490
|
+
.filter((e) => e.type === "message" && e.message?.role === "assistant")
|
|
1491
|
+
.map((e) => e.message.stopReason);
|
|
1492
|
+
if (stopReasons.includes("aborted")) ok("B9 aborted run recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1493
|
+
else bad("B9 aborted run recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1494
|
+
const pausedPaint = SCRIPT_B9.escPaint?.some((l) => l.includes("poke paused"));
|
|
1495
|
+
if (pausedPaint) ok("B9 widget pause paint", "'poke paused' rendered right after the user Esc");
|
|
1496
|
+
else bad("B9 widget pause paint", JSON.stringify(SCRIPT_B9.escPaint ?? null, (k, v) => (typeof v === "string" ? v.replace(/\x1b\[[0-9;]*m/g, "") : v)));
|
|
1497
|
+
});
|
|
1024
1498
|
}
|
|
1025
1499
|
|
|
1026
1500
|
// ============================================================================
|