@matthewfl/pi-jtodo 0.0.1 → 0.0.2
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 +1 -0
- package/package.json +1 -1
- package/src/constants.ts +6 -0
- package/src/index.ts +102 -15
- package/src/widget.ts +7 -1
- package/tests/test-todo.cjs +254 -9
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
|
+
- **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
45
|
|
|
45
46
|
## Display
|
|
46
47
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-jtodo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
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/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,
|
|
@@ -150,6 +151,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
151
|
// pi-specific: once the user has explicitly silenced the poke (poke off,
|
|
151
152
|
// or Esc-interrupting a run), new open work must NOT re-arm it.
|
|
152
153
|
let pokeExplicitlyOff = false;
|
|
154
|
+
// Whether the most recent completed agent run was Esc-aborted, recorded
|
|
155
|
+
// from the agent_end event itself (the run's own messages). Entry-scanning
|
|
156
|
+
// heuristics misclassify aborts when a queued user message (compaction
|
|
157
|
+
// resume, steer) lands after the aborted assistant message; an
|
|
158
|
+
// Esc-aborted run must never restart the agent via a poke.
|
|
159
|
+
let lastRunAborted = false;
|
|
160
|
+
// Goal-plugin-style Escape correlation (pi-simple-goal): a raw Escape
|
|
161
|
+
// keypress observed via terminal input marks the NEXT aborted run as the
|
|
162
|
+
// user's stop. Aborts with no recent Escape behind them are machinery
|
|
163
|
+
// (compaction, ctx.abort(), transport) and keep the poke cycle armed.
|
|
164
|
+
let lastEscapeAt = 0;
|
|
165
|
+
// Set when an Escape-classified abort pauses the poke. NOT sticky: the
|
|
166
|
+
// next agent run (user re-engaged, or machinery resumed) lifts it and
|
|
167
|
+
// re-arms, per the user's "stop the poke for the next while" rule.
|
|
168
|
+
let escPokePaused = false;
|
|
169
|
+
let unsubscribeTerminalInput: (() => void) | undefined;
|
|
153
170
|
// Whether poking is allowed at all. session_start (when emitted — the
|
|
154
171
|
// SDK harness and some embedders never emit it) re-derives this from
|
|
155
172
|
// its ctx.hasUI; elsewhere starts optimistic, matching the module-level
|
|
@@ -237,6 +254,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
254
|
() => state,
|
|
238
255
|
() => ({
|
|
239
256
|
armed: autoPokeArmed,
|
|
257
|
+
escPaused: escPokePaused,
|
|
240
258
|
gateAttempts: cycle.gateAttempts,
|
|
241
259
|
gateMaxAttempts: config.completionGateMaxAttempts,
|
|
242
260
|
pokeTargets: lastPokeTargets,
|
|
@@ -432,16 +450,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
432
450
|
}
|
|
433
451
|
|
|
434
452
|
/** 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
|
|
453
|
+
function wasLastRunAborted(): boolean {
|
|
454
|
+
return lastRunAborted;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Some provider stacks (pi-robust-provider observed live: errorMessage
|
|
458
|
+
// "This operation was aborted") finalize an Esc-aborted request as a
|
|
459
|
+
// generic error whose text still names the abort. Those are user
|
|
460
|
+
// interrupts, not provider failures, and must never restart the agent.
|
|
461
|
+
function looksInterrupted(stopReason: string | undefined, errorMessage: string | undefined): boolean {
|
|
462
|
+
return stopReason === "error" && typeof errorMessage === "string" && /abort/i.test(errorMessage);
|
|
445
463
|
}
|
|
446
464
|
|
|
447
465
|
function countAssistantTurnsSinceUser(ctx: ExtensionContext): number {
|
|
@@ -584,7 +602,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
602
|
isArmed: () => autoPokeArmed,
|
|
585
603
|
isIdle: () => watchdogCtx?.isIdle() ?? false,
|
|
586
604
|
incompleteCount: () => incompleteTodos().length,
|
|
587
|
-
wasAborted: () =>
|
|
605
|
+
wasAborted: () => lastRunAborted,
|
|
588
606
|
idleMs: config.watchdogIdleMs,
|
|
589
607
|
maxRePokes: config.watchdogMaxRePokes,
|
|
590
608
|
onFire: () => {
|
|
@@ -761,6 +779,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
761
779
|
if (action === "on" || action === "trigger") {
|
|
762
780
|
autoPokeArmed = true;
|
|
763
781
|
pokeExplicitlyOff = false;
|
|
782
|
+
escPokePaused = false;
|
|
764
783
|
cycle = freshCycleFlags();
|
|
765
784
|
settledWithoutProgress = 0;
|
|
766
785
|
lastChallengedSignature = undefined;
|
|
@@ -793,19 +812,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
793
812
|
// Events
|
|
794
813
|
// ------------------------------------------------------------------------
|
|
795
814
|
|
|
815
|
+
pi.on("agent_end", (event) => {
|
|
816
|
+
// Record the most recent run's abort status straight from the run's
|
|
817
|
+
// own messages; agent_settled reads this (it fires after agent_end).
|
|
818
|
+
const messages = event.messages as Array<{ role?: string; stopReason?: string; errorMessage?: string }>;
|
|
819
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
820
|
+
const m = messages[i];
|
|
821
|
+
if (m.role === "assistant") {
|
|
822
|
+
lastRunAborted = m.stopReason === "aborted" || looksInterrupted(m.stopReason, m.errorMessage);
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
watchdog.notifyActivity();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
830
|
+
// A new run means the user (or machinery) re-engaged: an Esc pause
|
|
831
|
+
// lifts outside the abort-correlation window where the just-triggered
|
|
832
|
+
// teardown is still settling (pi-simple-goal's maybeResumeOnActivity).
|
|
833
|
+
if (escPokePaused && Date.now() - lastEscapeAt > ESCAPE_ABORT_WINDOW_MS) {
|
|
834
|
+
escPokePaused = false;
|
|
835
|
+
if (config.enabled && config.autoPoke && !pokeExplicitlyOff && pokeUiAllowed) {
|
|
836
|
+
autoPokeArmed = true;
|
|
837
|
+
cycle = freshCycleFlags();
|
|
838
|
+
}
|
|
839
|
+
refreshWidget(ctx);
|
|
840
|
+
}
|
|
841
|
+
watchdog.notifyActivity();
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
pi.on("session_before_compact", () => {
|
|
845
|
+
watchdog.notifyActivity(); // compaction can run long; never starve the watchdog
|
|
846
|
+
});
|
|
847
|
+
pi.on("session_compact", () => {
|
|
848
|
+
watchdog.notifyActivity();
|
|
849
|
+
});
|
|
850
|
+
pi.on("session_compact_failed", () => {
|
|
851
|
+
watchdog.notifyActivity();
|
|
852
|
+
});
|
|
853
|
+
|
|
796
854
|
pi.on("session_start", (_event, ctx) => {
|
|
797
855
|
watchdogCtx = ctx;
|
|
798
856
|
watchdog.notifyActivity();
|
|
799
857
|
idleNudgeSent = false;
|
|
800
858
|
pendingObservations.length = 0;
|
|
801
859
|
cycle = freshCycleFlags();
|
|
860
|
+
lastRunAborted = false;
|
|
802
861
|
settledWithoutProgress = 0;
|
|
803
862
|
lastSettledSignature = undefined;
|
|
804
863
|
lastChallengedSignature = undefined;
|
|
805
864
|
lastPokeTargets = undefined;
|
|
806
865
|
pokeExplicitlyOff = false;
|
|
866
|
+
escPokePaused = false;
|
|
867
|
+
lastEscapeAt = 0;
|
|
807
868
|
pokeUiAllowed = ctx.hasUI;
|
|
808
869
|
autoPokeArmed = config.autoPoke && ctx.hasUI;
|
|
870
|
+
// TUI only: watch raw terminal input for the Escape key so an aborted
|
|
871
|
+
// run can be correlated with the user's actual keypress (pi-simple-goal
|
|
872
|
+
// pattern). stopReason alone is unreliable: provider stacks can
|
|
873
|
+
// finalize an abort as a generic error naming the abort.
|
|
874
|
+
try {
|
|
875
|
+
const uictx = ctx as { mode?: string; ui: { onTerminalInput?: (cb: (data: string) => unknown) => () => void } };
|
|
876
|
+
if (uictx.mode === "tui" && typeof uictx.ui.onTerminalInput === "function") {
|
|
877
|
+
unsubscribeTerminalInput = uictx.ui.onTerminalInput((data) => {
|
|
878
|
+
if (data === "\x1b" || data === "\x1b\x1b" || data === "\x1b[27u") {
|
|
879
|
+
lastEscapeAt = Date.now();
|
|
880
|
+
}
|
|
881
|
+
return undefined;
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
} catch {
|
|
885
|
+
// Non-TUI or older pi: aborts fall back to machinery classification.
|
|
886
|
+
}
|
|
809
887
|
reconstructState(ctx);
|
|
810
888
|
refreshWidget(ctx);
|
|
811
889
|
});
|
|
@@ -857,10 +935,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
857
935
|
if (!ctx.isIdle()) return;
|
|
858
936
|
// jcode's Esc: an interrupted run disarms auto-poke entirely and
|
|
859
937
|
// injects no follow-up of any kind.
|
|
860
|
-
if (wasLastRunAborted(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
938
|
+
if (wasLastRunAborted()) {
|
|
939
|
+
// Escape correlation (pi-simple-goal pattern): only an aborted run
|
|
940
|
+
// with a RECENT raw Escape keypress behind it is the user's stop —
|
|
941
|
+
// that quiets the settle and pauses the poke until the next run.
|
|
942
|
+
// Any other abort (compaction, ctx.abort(), transport, provider
|
|
943
|
+
// stacks mislabeling the interrupt) is machinery: stay quiet and keep
|
|
944
|
+
// the cycle armed; the watchdog covers a genuinely lost continuation.
|
|
945
|
+
if (lastEscapeAt > 0 && Date.now() - lastEscapeAt <= ESCAPE_ABORT_WINDOW_MS) {
|
|
946
|
+
disarm();
|
|
947
|
+
escPokePaused = true; // not sticky: the next agent run lifts it
|
|
948
|
+
refreshWidget(ctx);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
864
951
|
return;
|
|
865
952
|
}
|
|
866
953
|
if (!autoPokeArmed) {
|
package/src/widget.ts
CHANGED
|
@@ -30,6 +30,8 @@ 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;
|
|
33
35
|
gateAttempts: number;
|
|
34
36
|
gateMaxAttempts: number;
|
|
35
37
|
/** Ids of the todos that caused the most recent poke/gate challenge */
|
|
@@ -105,7 +107,11 @@ function buildLeftColumn(
|
|
|
105
107
|
// user has been seeing.
|
|
106
108
|
let status: string;
|
|
107
109
|
if (!allSettled) {
|
|
108
|
-
status = runtime.armed
|
|
110
|
+
status = runtime.armed
|
|
111
|
+
? "· auto-poke"
|
|
112
|
+
: runtime.escPaused
|
|
113
|
+
? "· poke paused"
|
|
114
|
+
: "· poke off";
|
|
109
115
|
} else if (summary.needs_validation) {
|
|
110
116
|
status =
|
|
111
117
|
runtime.gateAttempts > 0
|
package/tests/test-todo.cjs
CHANGED
|
@@ -590,6 +590,12 @@ async function suiteA() {
|
|
|
590
590
|
const empty = widget.renderTodoWidgetLines({ todos: [], plan: {}, goals: [] }, rtOn, 6, 60, stubTheme);
|
|
591
591
|
const unarmed = widget.renderTodoWidgetLines(state, rtOff, 6, 60, stubTheme);
|
|
592
592
|
const offMarker = unescape(unarmed.at(-1)).includes("poke off");
|
|
593
|
+
// Esc-pause label must be distinct from the sticky /todos poke off state.
|
|
594
|
+
const rtEscPaused = { armed: false, escPaused: true, gateAttempts: 0, gateMaxAttempts: 5 };
|
|
595
|
+
const escPausedLine = widget.renderTodoWidgetLines(state, rtEscPaused, 6, 60, stubTheme);
|
|
596
|
+
const escPausedMarker =
|
|
597
|
+
unescape(escPausedLine.at(-1)).includes("poke paused") &&
|
|
598
|
+
!unescape(escPausedLine.at(-1)).includes("poke off");
|
|
593
599
|
// value-rank: shown item lines are exactly a(in_progress), then pending
|
|
594
600
|
// in declaration order; settled items are pushed into the overflow.
|
|
595
601
|
const shownIds = lines.slice(1, -1).map((l) => (unescape(l).match(/#(\w+)/) || [])[1]);
|
|
@@ -682,10 +688,10 @@ async function suiteA() {
|
|
|
682
688
|
markRow("g-warn")?.includes("🟡") && markRow("g-ok")?.includes("✔") &&
|
|
683
689
|
markRow("g-act")?.includes("🚧") && markRow("g-wait")?.includes("🔲") &&
|
|
684
690
|
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) {
|
|
691
|
+
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
692
|
ok("T13 widget", "intention header + bottom status + table (Todo Goal) correct");
|
|
687
693
|
} 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));
|
|
694
|
+
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
695
|
}
|
|
690
696
|
});
|
|
691
697
|
}
|
|
@@ -699,6 +705,7 @@ function makeMockStreamSimple(calls, script) {
|
|
|
699
705
|
return function streamSimple(model, context, options) {
|
|
700
706
|
callIndex += 1;
|
|
701
707
|
calls.push({ index: callIndex, context });
|
|
708
|
+
if (typeof script.onRequest === "function") script.onRequest(context);
|
|
702
709
|
const step = callIndex;
|
|
703
710
|
const scriptCall = script.call;
|
|
704
711
|
const scriptText = script.text;
|
|
@@ -717,8 +724,22 @@ function makeMockStreamSimple(calls, script) {
|
|
|
717
724
|
stopReason: "pending",
|
|
718
725
|
timestamp: Date.now(),
|
|
719
726
|
};
|
|
720
|
-
|
|
727
|
+
try {
|
|
721
728
|
stream.push({ type: "start", partial: output });
|
|
729
|
+
if (script.gateAt === step) {
|
|
730
|
+
// Hold the stream open so the test can abort mid-flight; honor the
|
|
731
|
+
// abort signal exactly like pi's own providers do (openai-completions.js).
|
|
732
|
+
const signal = options?.signal;
|
|
733
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
734
|
+
await new Promise((resolve, reject) => {
|
|
735
|
+
const onAbort = () => reject(new Error("Request was aborted"));
|
|
736
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
737
|
+
(script.gatePromise || Promise.resolve()).then(
|
|
738
|
+
() => { signal?.removeEventListener("abort", onAbort); resolve(); },
|
|
739
|
+
reject,
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
722
743
|
const toolCall = scriptCall(step);
|
|
723
744
|
if (toolCall) {
|
|
724
745
|
output.content.push(toolCall);
|
|
@@ -737,9 +758,12 @@ function makeMockStreamSimple(calls, script) {
|
|
|
737
758
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
738
759
|
stream.end();
|
|
739
760
|
} catch (err) {
|
|
740
|
-
output.stopReason = "error";
|
|
761
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
762
|
+
// Simulate provider stacks (pi-robust-provider observed live) that
|
|
763
|
+
// finalize an abort as a generic error naming the abort.
|
|
764
|
+
if (script.misclassifiedAbortAt === step) output.stopReason = "error";
|
|
741
765
|
output.errorMessage = err.message;
|
|
742
|
-
stream.push({ type: "error", reason:
|
|
766
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
743
767
|
stream.end();
|
|
744
768
|
}
|
|
745
769
|
})();
|
|
@@ -909,6 +933,14 @@ const SCRIPT_B3 = {
|
|
|
909
933
|
|
|
910
934
|
async function runScenario(name, script, assertions) {
|
|
911
935
|
const calls = [];
|
|
936
|
+
if (script.abortAtCall) {
|
|
937
|
+
// Gate the abortAtCall'th provider call mid-stream so the test can abort
|
|
938
|
+
// the run deterministically while it is in flight.
|
|
939
|
+
script.gateAt = script.abortAtCall;
|
|
940
|
+
script.gatePromise = new Promise((resolve) => {
|
|
941
|
+
script.releaseGate = resolve;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
912
944
|
const mockFactory = {
|
|
913
945
|
name: "mock-provider",
|
|
914
946
|
factory: (api) => {
|
|
@@ -924,18 +956,49 @@ async function runScenario(name, script, assertions) {
|
|
|
924
956
|
};
|
|
925
957
|
await run(name, async ({ session }) => {
|
|
926
958
|
await session.setModel(MOCK_MODEL);
|
|
959
|
+
let abortTimerFired = false;
|
|
960
|
+
if (script.abortAtCall) {
|
|
961
|
+
// prompt() resolves only after every queued follow-up run finishes, so
|
|
962
|
+
// the poll loop below can never reach the gated call. Simulate Esc from
|
|
963
|
+
// a timer: abort once the gated provider call has actually started.
|
|
964
|
+
const startedAt = Date.now();
|
|
965
|
+
const abortWhenStarted = () => {
|
|
966
|
+
if (calls.length >= script.abortAtCall) {
|
|
967
|
+
abortTimerFired = true;
|
|
968
|
+
if (script.compactInsteadOfAbort) session.compact();
|
|
969
|
+
else session.abort();
|
|
970
|
+
// Safety net: if the abort somehow never reaches the mock, release
|
|
971
|
+
// the gate so the scenario fails with diagnostics instead of hanging.
|
|
972
|
+
setTimeout(() => { if (script.releaseGate) script.releaseGate(); }, 3000);
|
|
973
|
+
} else if (Date.now() - startedAt < 12_000) {
|
|
974
|
+
setTimeout(abortWhenStarted, 100);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
setTimeout(abortWhenStarted, 300);
|
|
978
|
+
}
|
|
927
979
|
await session.prompt("begin");
|
|
928
980
|
|
|
929
981
|
const deadline = Date.now() + 20_000;
|
|
930
982
|
let prompted = false;
|
|
983
|
+
let aborted = false;
|
|
984
|
+
let resumed = false;
|
|
931
985
|
while (Date.now() < deadline) {
|
|
932
986
|
if (!prompted && script.promptAfter && calls.length >= script.promptAfter) {
|
|
933
987
|
prompted = true;
|
|
934
988
|
void session.prompt(script.promptText ?? "continue");
|
|
935
989
|
}
|
|
990
|
+
if (!aborted && script.abortAtCall && calls.length >= script.abortAtCall) {
|
|
991
|
+
aborted = true; // bookkeeping only; the timer above fired the Esc
|
|
992
|
+
if (!abortTimerFired) session.abort();
|
|
993
|
+
}
|
|
994
|
+
if (aborted && !resumed) {
|
|
995
|
+
resumed = true;
|
|
996
|
+
await sleep(1500); // let the abort settle (disarm + quiet) run first
|
|
997
|
+
void session.prompt(script.resumeText ?? "resume after the interrupt");
|
|
998
|
+
}
|
|
936
999
|
if (calls.length >= script.calls) {
|
|
937
1000
|
await sleep(800);
|
|
938
|
-
if (calls.length
|
|
1001
|
+
if (calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls)) break; // done
|
|
939
1002
|
}
|
|
940
1003
|
await sleep(100);
|
|
941
1004
|
}
|
|
@@ -943,8 +1006,9 @@ async function runScenario(name, script, assertions) {
|
|
|
943
1006
|
const customs = entries
|
|
944
1007
|
.filter((e) => e.type === "custom_message" && e.customType === "pi-jtodo/followup")
|
|
945
1008
|
.map((e) => (typeof e.content === "string" ? e.content : ""));
|
|
946
|
-
|
|
947
|
-
|
|
1009
|
+
const inRange = calls.length >= script.calls && calls.length <= (script.callsMax ?? script.calls);
|
|
1010
|
+
if (!inRange) {
|
|
1011
|
+
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
1012
|
return;
|
|
949
1013
|
}
|
|
950
1014
|
|
|
@@ -952,7 +1016,7 @@ async function runScenario(name, script, assertions) {
|
|
|
952
1016
|
(e) => e.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === "todo",
|
|
953
1017
|
);
|
|
954
1018
|
const lastDetails = todoResults.at(-1)?.message?.details;
|
|
955
|
-
await assertions({ calls, customs, lastDetails });
|
|
1019
|
+
await assertions({ calls, customs, lastDetails, entries });
|
|
956
1020
|
}, { extensionFactories: [mockFactory] });
|
|
957
1021
|
}
|
|
958
1022
|
|
|
@@ -1021,6 +1085,187 @@ async function suiteB() {
|
|
|
1021
1085
|
if (done) ok("B3 final state", "both waves completed");
|
|
1022
1086
|
else bad("B3 final state", JSON.stringify(lastDetails?.todos));
|
|
1023
1087
|
});
|
|
1088
|
+
|
|
1089
|
+
// B4: Esc (session.abort) on the poke-driven run must disarm and inject
|
|
1090
|
+
// NOTHING afterwards, even though open todos remain. Flow: run A writes an
|
|
1091
|
+
// open todo and ends (settle #1 pokes), the poke-driven run B is aborted
|
|
1092
|
+
// mid-stream (the user's exact complaint), and both the abort settle and
|
|
1093
|
+
// the resumed run's settle stay quiet via the agent_end abort flag.
|
|
1094
|
+
// B4: an abort with NO raw Escape behind it is machinery (ctx.abort(),
|
|
1095
|
+
// extension-initiated, transport). Under the pi-simple-goal Escape model
|
|
1096
|
+
// the poke cycle stays armed: the aborted settle is quiet, but a resumed
|
|
1097
|
+
// run that still leaves todos open pokes normally. The real user-Esc pause
|
|
1098
|
+
// (raw \x1b keypress) is TUI-only and verified live, not in the SDK harness.
|
|
1099
|
+
const SCRIPT_B4 = {
|
|
1100
|
+
call(step) {
|
|
1101
|
+
if (step === 1) {
|
|
1102
|
+
return {
|
|
1103
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1104
|
+
arguments: {
|
|
1105
|
+
todos: [{ content: "machinery abort guard", status: "in_progress", priority: "high", id: "e1", group: "esc", confidence: 96 }],
|
|
1106
|
+
plan: { user_intention: "machinery abort e2e", understands_user_intent: 97 },
|
|
1107
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed" }],
|
|
1108
|
+
},
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
if (step === 5) {
|
|
1112
|
+
return {
|
|
1113
|
+
type: "toolCall", id: "call-done", name: "todo",
|
|
1114
|
+
arguments: {
|
|
1115
|
+
todos: [{ content: "machinery abort guard", status: "completed", priority: "high", id: "e1", group: "esc", confidence: 96, completion_confidence: 96 }],
|
|
1116
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "abort keeps armed", end_to_end_ownership: 96 }],
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
return null;
|
|
1121
|
+
},
|
|
1122
|
+
text(step) {
|
|
1123
|
+
if (step === 2) return "Todos written.";
|
|
1124
|
+
if (step === 3) return "Poked: working on it, mid-stream.";
|
|
1125
|
+
if (step === 4) return "Resumed after the interrupt, still working.";
|
|
1126
|
+
return "All done now.";
|
|
1127
|
+
},
|
|
1128
|
+
abortAtCall: 3, // abort run B, the poke-driven continuation
|
|
1129
|
+
resumeText: "resume after the interrupt",
|
|
1130
|
+
calls: 6,
|
|
1131
|
+
};
|
|
1132
|
+
await runScenario("B4 machinery abort keeps the poke cycle armed", SCRIPT_B4, async ({ customs, entries }) => {
|
|
1133
|
+
const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
|
|
1134
|
+
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");
|
|
1135
|
+
else bad("B4 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1136
|
+
if (customs.length === 2) ok("B4 aborted settle quiet", "the abort settle itself injected nothing");
|
|
1137
|
+
else bad("B4 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1138
|
+
const stopReasons = entries
|
|
1139
|
+
.filter((e) => e.type === "message" && e.message?.role === "assistant")
|
|
1140
|
+
.map((e) => e.message.stopReason);
|
|
1141
|
+
if (stopReasons.includes("aborted")) ok("B4 harness abort recorded", `assistant stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1142
|
+
else bad("B4 harness abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
// B5: pi-robust-provider observed live finalizing an Esc abort as a plain
|
|
1146
|
+
// error whose text names the abort ("This operation was aborted"). The
|
|
1147
|
+
// error-text fallback must classify it as a user interrupt and stay quiet.
|
|
1148
|
+
// B5: pi-robust-provider observed live finalizing an Esc abort as a plain
|
|
1149
|
+
// error whose text names the abort ("This operation was aborted"). Under
|
|
1150
|
+
// the raw-Escape classification model a stopReason mislabel changes
|
|
1151
|
+
// NOTHING: with no Escape keypress observed this is machinery, the settle is
|
|
1152
|
+
// quiet, and the cycle stays armed. The real-Esc correlation is live-only.
|
|
1153
|
+
const SCRIPT_B5 = {
|
|
1154
|
+
call(step) {
|
|
1155
|
+
if (step === 1) {
|
|
1156
|
+
return {
|
|
1157
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1158
|
+
arguments: {
|
|
1159
|
+
todos: [{ content: "misclassified abort guard", status: "in_progress", priority: "high", id: "m1", group: "esc", confidence: 96 }],
|
|
1160
|
+
plan: { user_intention: "misclassified abort e2e", understands_user_intent: 97 },
|
|
1161
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed" }],
|
|
1162
|
+
},
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
if (step === 5) {
|
|
1166
|
+
return {
|
|
1167
|
+
type: "toolCall", id: "call-done", name: "todo",
|
|
1168
|
+
arguments: {
|
|
1169
|
+
todos: [{ content: "misclassified abort guard", status: "completed", priority: "high", id: "m1", group: "esc", confidence: 96, completion_confidence: 96 }],
|
|
1170
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "error-naming-abort keeps armed", end_to_end_ownership: 96 }],
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
return null;
|
|
1175
|
+
},
|
|
1176
|
+
text(step) {
|
|
1177
|
+
if (step === 2) return "Todos written.";
|
|
1178
|
+
if (step === 3) return "Poked: working, mid-stream.";
|
|
1179
|
+
if (step === 4) return "Resumed after the interrupt, still working.";
|
|
1180
|
+
return "All done now.";
|
|
1181
|
+
},
|
|
1182
|
+
abortAtCall: 3,
|
|
1183
|
+
misclassifiedAbortAt: 3, // the abort finalizes as stopReason "error" naming the abort
|
|
1184
|
+
resumeText: "resume after the interrupt",
|
|
1185
|
+
calls: 6,
|
|
1186
|
+
};
|
|
1187
|
+
await runScenario("B5 error-naming-abort (robust-provider) is machinery", SCRIPT_B5, async ({ customs, entries }) => {
|
|
1188
|
+
const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
|
|
1189
|
+
if (pokes.length === 2) ok("B5 poke before and after abort", "misclassified abort did not suppress the cycle");
|
|
1190
|
+
else bad("B5 poke before and after abort", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1191
|
+
if (customs.length === 2) ok("B5 aborted settle quiet", "the error-naming-abort settle injected nothing");
|
|
1192
|
+
else bad("B5 aborted settle quiet", `customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1193
|
+
const interrupted = entries
|
|
1194
|
+
.filter((e) => e.type === "message" && e.message?.role === "assistant")
|
|
1195
|
+
.find((e) => /abort/i.test(e.message?.errorMessage ?? ""));
|
|
1196
|
+
if (interrupted?.message?.stopReason === "error") ok("B5 interrupt finalized as error", "errorMessage names the abort, stopReason is error");
|
|
1197
|
+
else bad("B5 interrupt finalized as error", `stopReason=${interrupted?.message?.stopReason} err=${interrupted?.message?.errorMessage}`);
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
// B6: compaction aborts the in-flight run but guarantees a continuation —
|
|
1201
|
+
// a system interrupt. Its settle must stay quiet WITHOUT disarming and
|
|
1202
|
+
// WITHOUT the sticky Esc off, so the resumed run's settle pokes normally.
|
|
1203
|
+
let b6DoneSent = false;
|
|
1204
|
+
let b6PokesSeen = 0;
|
|
1205
|
+
const SCRIPT_B6 = {
|
|
1206
|
+
onRequest(context) {
|
|
1207
|
+
// Count auto-poke messages already in the request history; robust
|
|
1208
|
+
// to compaction consuming 1 or 2 provider calls. User-role content may
|
|
1209
|
+
// be a string or a block array (same extraction as contextUserTexts).
|
|
1210
|
+
const texts = (context?.messages ?? [])
|
|
1211
|
+
.filter((m) => m?.role === "user")
|
|
1212
|
+
.map((m) =>
|
|
1213
|
+
typeof m?.content === "string"
|
|
1214
|
+
? m.content
|
|
1215
|
+
: (m?.content ?? []).map((c) => c?.text ?? "").join(""),
|
|
1216
|
+
);
|
|
1217
|
+
b6PokesSeen = texts.filter((t) => t.includes("You have 1 incomplete todo.")).length;
|
|
1218
|
+
},
|
|
1219
|
+
call(step) {
|
|
1220
|
+
if (step === 1) {
|
|
1221
|
+
return {
|
|
1222
|
+
type: "toolCall", id: "call-open", name: "todo",
|
|
1223
|
+
arguments: {
|
|
1224
|
+
todos: [{ content: "compaction window guard", status: "in_progress", priority: "high", id: "c1", group: "esc", confidence: 96 }],
|
|
1225
|
+
plan: { user_intention: "compaction window e2e", understands_user_intent: 97 },
|
|
1226
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction" }],
|
|
1227
|
+
},
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
// Only the run driven by poke #2 completes the todo, so poke #2
|
|
1231
|
+
// provably fired (the resumed run before it must leave it open).
|
|
1232
|
+
if (step >= 5 && b6PokesSeen >= 2 && !b6DoneSent) {
|
|
1233
|
+
b6DoneSent = true;
|
|
1234
|
+
return {
|
|
1235
|
+
type: "toolCall", id: "call-done", name: "todo",
|
|
1236
|
+
arguments: {
|
|
1237
|
+
todos: [{ content: "compaction window guard", status: "completed", priority: "high", id: "c1", group: "esc", confidence: 96, completion_confidence: 96 }],
|
|
1238
|
+
goals: [{ group: "esc", closed_feedback_loop: 97, feedback_loop: "armed survives compaction", end_to_end_ownership: 96 }],
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
},
|
|
1244
|
+
text(step) {
|
|
1245
|
+
if (step === 2) return "Todos written. " + "Detailed work log entries accumulate here. ".repeat(2600);
|
|
1246
|
+
if (step === 3) return "Poked: working, mid-stream.";
|
|
1247
|
+
return "Continuing after compaction.";
|
|
1248
|
+
},
|
|
1249
|
+
abortAtCall: 3,
|
|
1250
|
+
compactInsteadOfAbort: true, // session.compact() aborts the gated run (system interrupt)
|
|
1251
|
+
promptAfter: 4, // after the compaction summarization call, resume the agent
|
|
1252
|
+
promptText: "resume the work",
|
|
1253
|
+
calls: 7,
|
|
1254
|
+
callsMax: 9, // compaction may consume 1 or 2 provider calls
|
|
1255
|
+
};
|
|
1256
|
+
await runScenario("B6 compaction abort keeps the poke cycle armed", SCRIPT_B6, async ({ customs, entries }) => {
|
|
1257
|
+
const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
|
|
1258
|
+
if (pokes.length === 2) ok("B6 armed preserved", "poke #1 pre-abort, poke #2 after the resumed run — no sticky-off");
|
|
1259
|
+
else bad("B6 armed preserved", `pokes=${pokes.length} customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
|
|
1260
|
+
const noisy = customs.filter((t) => t.includes("rose too sharply") || t.includes("not high enough") || t.includes("todo quality review"));
|
|
1261
|
+
if (noisy.length === 0) ok("B6 no spurious gates", "clean scores produced no challenges/digest");
|
|
1262
|
+
else bad("B6 no spurious gates", JSON.stringify(noisy));
|
|
1263
|
+
const stopReasons = entries
|
|
1264
|
+
.filter((e) => e.type === "message" && e.message?.role === "assistant")
|
|
1265
|
+
.map((e) => e.message.stopReason);
|
|
1266
|
+
if (stopReasons.includes("aborted")) ok("B6 compaction abort recorded", `stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1267
|
+
else bad("B6 compaction abort recorded", `no aborted stopReason; stopReasons=${JSON.stringify(stopReasons)}`);
|
|
1268
|
+
});
|
|
1024
1269
|
}
|
|
1025
1270
|
|
|
1026
1271
|
// ============================================================================
|