@matthewfl/pi-jtodo 0.0.2 → 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 +2 -0
- package/package.json +1 -1
- package/src/config.ts +13 -8
- package/src/index.ts +103 -8
- package/src/poke-scheduler.ts +54 -0
- package/src/watchdog.ts +9 -0
- package/src/widget.ts +15 -4
- package/tests/test-todo.cjs +235 -6
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ 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.
|
|
44
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`.
|
|
45
46
|
|
|
46
47
|
## Display
|
|
@@ -56,6 +57,7 @@ Deliberately **not** ported from jcode: the `/overnight` mission mode (presumes
|
|
|
56
57
|
{
|
|
57
58
|
"enabled": true,
|
|
58
59
|
"autoPoke": true,
|
|
60
|
+
"pokeDelayMs": 20000,
|
|
59
61
|
"completionGateMaxAttempts": 5,
|
|
60
62
|
"maxGateObservations": 256,
|
|
61
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/index.ts
CHANGED
|
@@ -90,6 +90,7 @@ import { normalizeTodoInput } from "./normalize.js";
|
|
|
90
90
|
import { TodoParams, type TodoParamsInput } from "./schema.js";
|
|
91
91
|
import { TodoListComponent } from "./viewer.js";
|
|
92
92
|
import { TODO_WIDGET_ID, makeTodoWidget } from "./widget.js";
|
|
93
|
+
import { createPokeScheduler } from "./poke-scheduler.js";
|
|
93
94
|
import { createWatchdog } from "./watchdog.js";
|
|
94
95
|
|
|
95
96
|
// ============================================================================
|
|
@@ -167,6 +168,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
167
168
|
// re-arms, per the user's "stop the poke for the next while" rule.
|
|
168
169
|
let escPokePaused = false;
|
|
169
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;
|
|
170
180
|
// Whether poking is allowed at all. session_start (when emitted — the
|
|
171
181
|
// SDK harness and some embedders never emit it) re-derives this from
|
|
172
182
|
// its ctx.hasUI; elsewhere starts optimistic, matching the module-level
|
|
@@ -181,6 +191,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
181
191
|
let idleNudgeSent = false;
|
|
182
192
|
|
|
183
193
|
function disarm() {
|
|
194
|
+
cancelPendingPoke();
|
|
184
195
|
autoPokeArmed = false;
|
|
185
196
|
lastPokeTargets = undefined;
|
|
186
197
|
cycle = freshCycleFlags();
|
|
@@ -255,6 +266,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
255
266
|
() => ({
|
|
256
267
|
armed: autoPokeArmed,
|
|
257
268
|
escPaused: escPokePaused,
|
|
269
|
+
pokePendingAt: pokeScheduler.deadlineAt(),
|
|
258
270
|
gateAttempts: cycle.gateAttempts,
|
|
259
271
|
gateMaxAttempts: config.completionGateMaxAttempts,
|
|
260
272
|
pokeTargets: lastPokeTargets,
|
|
@@ -268,6 +280,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
268
280
|
}
|
|
269
281
|
}
|
|
270
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
|
+
|
|
271
332
|
// ------------------------------------------------------------------------
|
|
272
333
|
// Branch-aware state (replaces jcode's per-session JSON files)
|
|
273
334
|
// ------------------------------------------------------------------------
|
|
@@ -481,7 +542,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
481
542
|
* completion-confidence and spike gates (budgeted), then disarm with a
|
|
482
543
|
* done notice.
|
|
483
544
|
*/
|
|
484
|
-
async function runTurnEndCheck(ctx: ExtensionContext): Promise<void> {
|
|
545
|
+
async function runTurnEndCheck(ctx: ExtensionContext, immediatePoke = false): Promise<void> {
|
|
485
546
|
const todos = state.todos;
|
|
486
547
|
if (todos.length === 0) {
|
|
487
548
|
// jcode: a settle with no todos at all silently disarms the poke.
|
|
@@ -492,6 +553,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
492
553
|
|
|
493
554
|
const incomplete = incompleteTodos();
|
|
494
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
|
+
}
|
|
495
562
|
cycle.gateAttempts = 0;
|
|
496
563
|
if (config.maxConsecutivePokesWithoutProgress > 0) {
|
|
497
564
|
const signature = JSON.stringify(
|
|
@@ -509,12 +576,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
509
576
|
return;
|
|
510
577
|
}
|
|
511
578
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
);
|
|
516
|
-
lastPokeTargets = new Set(incomplete.map((t) => t.id));
|
|
517
|
-
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);
|
|
518
583
|
return;
|
|
519
584
|
}
|
|
520
585
|
|
|
@@ -603,6 +668,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
603
668
|
isIdle: () => watchdogCtx?.isIdle() ?? false,
|
|
604
669
|
incompleteCount: () => incompleteTodos().length,
|
|
605
670
|
wasAborted: () => lastRunAborted,
|
|
671
|
+
// The watchdog must not inject through an Esc- or typing-pause either.
|
|
672
|
+
isPaused: () => escPokePaused,
|
|
606
673
|
idleMs: config.watchdogIdleMs,
|
|
607
674
|
maxRePokes: config.watchdogMaxRePokes,
|
|
608
675
|
onFire: () => {
|
|
@@ -798,7 +865,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
798
865
|
ctx.ui.notify("Poke queued. We'll re-check for unfinished todos after this turn.", "info");
|
|
799
866
|
return;
|
|
800
867
|
}
|
|
801
|
-
await runTurnEndCheck(ctx);
|
|
868
|
+
await runTurnEndCheck(ctx, true);
|
|
802
869
|
refreshWidget(ctx);
|
|
803
870
|
return;
|
|
804
871
|
}
|
|
@@ -827,6 +894,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
827
894
|
});
|
|
828
895
|
|
|
829
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();
|
|
830
900
|
// A new run means the user (or machinery) re-engaged: an Esc pause
|
|
831
901
|
// lifts outside the abort-correlation window where the just-triggered
|
|
832
902
|
// teardown is still settling (pi-simple-goal's maybeResumeOnActivity).
|
|
@@ -854,6 +924,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
854
924
|
pi.on("session_start", (_event, ctx) => {
|
|
855
925
|
watchdogCtx = ctx;
|
|
856
926
|
watchdog.notifyActivity();
|
|
927
|
+
cancelPendingPoke(); // a stale delayed poke never crosses sessions
|
|
857
928
|
idleNudgeSent = false;
|
|
858
929
|
pendingObservations.length = 0;
|
|
859
930
|
cycle = freshCycleFlags();
|
|
@@ -875,14 +946,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
875
946
|
const uictx = ctx as { mode?: string; ui: { onTerminalInput?: (cb: (data: string) => unknown) => () => void } };
|
|
876
947
|
if (uictx.mode === "tui" && typeof uictx.ui.onTerminalInput === "function") {
|
|
877
948
|
unsubscribeTerminalInput = uictx.ui.onTerminalInput((data) => {
|
|
949
|
+
watchdog.notifyActivity(); // any keystroke is user activity
|
|
878
950
|
if (data === "\x1b" || data === "\x1b\x1b" || data === "\x1b[27u") {
|
|
879
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);
|
|
880
967
|
}
|
|
881
968
|
return undefined;
|
|
882
969
|
});
|
|
970
|
+
terminalInputActive = true;
|
|
883
971
|
}
|
|
884
972
|
} catch {
|
|
885
973
|
// Non-TUI or older pi: aborts fall back to machinery classification.
|
|
974
|
+
terminalInputActive = false;
|
|
886
975
|
}
|
|
887
976
|
reconstructState(ctx);
|
|
888
977
|
refreshWidget(ctx);
|
|
@@ -917,6 +1006,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
917
1006
|
|
|
918
1007
|
pi.on("session_shutdown", () => {
|
|
919
1008
|
clearInterval(watchdogTimer);
|
|
1009
|
+
cancelPendingPoke();
|
|
1010
|
+
if (unsubscribeTerminalInput) {
|
|
1011
|
+
unsubscribeTerminalInput();
|
|
1012
|
+
unsubscribeTerminalInput = undefined;
|
|
1013
|
+
}
|
|
1014
|
+
terminalInputActive = false;
|
|
920
1015
|
watchdogCtx = undefined;
|
|
921
1016
|
});
|
|
922
1017
|
|
|
@@ -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
|
@@ -32,6 +32,8 @@ export interface WidgetRuntime {
|
|
|
32
32
|
armed: boolean;
|
|
33
33
|
/** An Escape-paused cycle: suppressed until the user re-engages */
|
|
34
34
|
escPaused?: boolean;
|
|
35
|
+
/** Epoch ms when a delayed poke fires; 0 = none pending (pokeDelayMs). */
|
|
36
|
+
pokePendingAt?: number;
|
|
35
37
|
gateAttempts: number;
|
|
36
38
|
gateMaxAttempts: number;
|
|
37
39
|
/** Ids of the todos that caused the most recent poke/gate challenge */
|
|
@@ -107,11 +109,20 @@ function buildLeftColumn(
|
|
|
107
109
|
// user has been seeing.
|
|
108
110
|
let status: string;
|
|
109
111
|
if (!allSettled) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
113
120
|
? "· poke paused"
|
|
114
|
-
:
|
|
121
|
+
: runtime.armed
|
|
122
|
+
? pendingIn > 0
|
|
123
|
+
? `· poke in ${pendingIn}s`
|
|
124
|
+
: "· auto-poke"
|
|
125
|
+
: "· poke off";
|
|
115
126
|
} else if (summary.needs_validation) {
|
|
116
127
|
status =
|
|
117
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
|
|
|
@@ -954,7 +1027,7 @@ async function runScenario(name, script, assertions) {
|
|
|
954
1027
|
});
|
|
955
1028
|
},
|
|
956
1029
|
};
|
|
957
|
-
await run(name, async ({ session }) => {
|
|
1030
|
+
await run(name, async ({ session, rec }) => {
|
|
958
1031
|
await session.setModel(MOCK_MODEL);
|
|
959
1032
|
let abortTimerFired = false;
|
|
960
1033
|
if (script.abortAtCall) {
|
|
@@ -965,6 +1038,17 @@ async function runScenario(name, script, assertions) {
|
|
|
965
1038
|
const abortWhenStarted = () => {
|
|
966
1039
|
if (calls.length >= script.abortAtCall) {
|
|
967
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
|
+
}
|
|
968
1052
|
if (script.compactInsteadOfAbort) session.compact();
|
|
969
1053
|
else session.abort();
|
|
970
1054
|
// Safety net: if the abort somehow never reaches the mock, release
|
|
@@ -982,11 +1066,35 @@ async function runScenario(name, script, assertions) {
|
|
|
982
1066
|
let prompted = false;
|
|
983
1067
|
let aborted = false;
|
|
984
1068
|
let resumed = false;
|
|
1069
|
+
let inputArmed = false;
|
|
1070
|
+
let inputSent = false;
|
|
985
1071
|
while (Date.now() < deadline) {
|
|
986
1072
|
if (!prompted && script.promptAfter && calls.length >= script.promptAfter) {
|
|
987
1073
|
prompted = true;
|
|
988
1074
|
void session.prompt(script.promptText ?? "continue");
|
|
989
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
|
+
}
|
|
990
1098
|
if (!aborted && script.abortAtCall && calls.length >= script.abortAtCall) {
|
|
991
1099
|
aborted = true; // bookkeeping only; the timer above fired the Esc
|
|
992
1100
|
if (!abortTimerFired) session.abort();
|
|
@@ -1016,7 +1124,7 @@ async function runScenario(name, script, assertions) {
|
|
|
1016
1124
|
(e) => e.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === "todo",
|
|
1017
1125
|
);
|
|
1018
1126
|
const lastDetails = todoResults.at(-1)?.message?.details;
|
|
1019
|
-
await assertions({ calls, customs, lastDetails, entries });
|
|
1127
|
+
await assertions({ calls, customs, lastDetails, entries, widgets: rec.widgets });
|
|
1020
1128
|
}, { extensionFactories: [mockFactory] });
|
|
1021
1129
|
}
|
|
1022
1130
|
|
|
@@ -1266,6 +1374,127 @@ async function suiteB() {
|
|
|
1266
1374
|
if (stopReasons.includes("aborted")) ok("B6 compaction abort recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1267
1375
|
else bad("B6 compaction abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1268
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
|
+
});
|
|
1269
1498
|
}
|
|
1270
1499
|
|
|
1271
1500
|
// ============================================================================
|