Compare commits
2 Commits
cab445e603
...
fd0c343729
| Author | SHA1 | Date | |
|---|---|---|---|
| fd0c343729 | |||
| a4181af13e |
820
implement-distill-propagate-elicit.txt
Normal file
820
implement-distill-propagate-elicit.txt
Normal file
@ -0,0 +1,820 @@
|
|||||||
|
|
||||||
|
╭─── Claude Code v2.1.39 ──────────────────────────────────────────────────────╮
|
||||||
|
│ │ Tips for getting │
|
||||||
|
│ Welcome back Willem! │ started │
|
||||||
|
│ │ Run /init to create a … │
|
||||||
|
│ ▐▛███▜▌ │ ─────────────────────── │
|
||||||
|
│ ▝▜█████▛▘ │ Recent activity │
|
||||||
|
│ ▘▘ ▝▝ │ No recent activity │
|
||||||
|
│ │ │
|
||||||
|
│ Opus 4.6 · Claude Max · │ │
|
||||||
|
│ willem@livingsoftware.co.uk's Organization │ │
|
||||||
|
│ ~/dev/spikes/llm/pi-allium-port/turn-limit-repo │ │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
❯ /clear
|
||||||
|
⎿ (no content)
|
||||||
|
|
||||||
|
❯ continue with Phase 3
|
||||||
|
|
||||||
|
⏺ Let me check the plan to see what Phase 3 involves.
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Phase 3: Test with turn-limit extension (TDD). Let me check what's already in
|
||||||
|
place.
|
||||||
|
|
||||||
|
⏺ Bash(ls -la /Users/willem/dev/spikes/llm/pi-allium-port/turn-limit-repo
|
||||||
|
2>/dev/null; ls -la /Users/willem/dev/spikes/llm/pi-allium-port/turn-limit
|
||||||
|
2>/dev/null; echo…)
|
||||||
|
⎿ ---
|
||||||
|
AGENTS.md
|
||||||
|
allium-main
|
||||||
|
… +6 lines (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Neither the repo nor the symlink exist yet. Let me also check the current
|
||||||
|
skills setup.
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Good, all Phase 2 skills are in place. Phase 3 starts with cloning the
|
||||||
|
turn-limit repo. Let me check if that Gitea instance is accessible.
|
||||||
|
|
||||||
|
⏺ Bash(git ls-remote https://gitea.apps.sustainabledelivery.com/QWAN/monotonic-p
|
||||||
|
i-extensions.git HEAD 2>&1 | head -5)
|
||||||
|
⎿ cab445e60346bd57ea0999af71555fdb29db28fd HEAD
|
||||||
|
|
||||||
|
⏺ Repo is accessible. Let me clone it and set up the symlink.
|
||||||
|
|
||||||
|
⏺ Bash(git clone https://gitea.apps.sustainabledelivery.com/QWAN/monotonic-pi-ex
|
||||||
|
tensions.git turn-limit-repo)
|
||||||
|
⎿ Cloning into 'turn-limit-repo'...
|
||||||
|
warning: unable to access '/Users/willem/.config/git/ignore': Operatio
|
||||||
|
n not permitted
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Bash(ln -s turn-limit-repo/packages/pi-turn-limit
|
||||||
|
/Users/willem/dev/spikes/llm/pi-allium-port/turn-limit)
|
||||||
|
⎿ Done
|
||||||
|
|
||||||
|
⏺ Now let me understand the turn-limit codebase before running the skills
|
||||||
|
against it.
|
||||||
|
|
||||||
|
⏺ Read 4 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ The clone and symlink are done. Now I need to run the distill skill against
|
||||||
|
the turn-limit code via pi. Let me check how to pass a prompt
|
||||||
|
non-interactively.
|
||||||
|
|
||||||
|
⏺ Bash(pi --help 2>&1 | head -60)
|
||||||
|
⎿ pi - AI coding assistant with read, bash, edit, write tools
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
… +49 lines (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ -p means "non-interactive mode: process prompt and exit" and prompts are
|
||||||
|
passed as trailing arguments. Let me run distill against the turn-limit code.
|
||||||
|
|
||||||
|
⏺ Bash(pi -p --model "Qwen3.6-35B-A3B-MXFP4_MOE.gguf" "/skill:distill Distill a
|
||||||
|
spec from the turn-limit extension in turn-limit/src/turn-limit.ts. This
|
||||||
|
is a pi.dev ex…)
|
||||||
|
⎿ The spec is complete. Here's a summary of what was distilled:
|
||||||
|
|
||||||
|
**`turn-limit/turn-limit.allium`** — captures the domain behavior of t
|
||||||
|
… +17 lines (ctrl+o to expand)
|
||||||
|
⎿ (timeout 5m)
|
||||||
|
|
||||||
|
⏺ Distill ran and produced output. Let me check what file it created.
|
||||||
|
|
||||||
|
⏺ Searched for 1 pattern, read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Good, distill produced a .allium spec. Let me review it against the language
|
||||||
|
reference, then run propagate.
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
⎿ Loaded ../allium-main/.claude/rules/allium.md
|
||||||
|
|
||||||
|
⏺ The spec has several issues I should fix before running propagate:
|
||||||
|
|
||||||
|
1. Section order wrong: Config appears before Entity (language reference says
|
||||||
|
entities before config)
|
||||||
|
2. Wrong config reference: session.max_turns should be config.max_turns —
|
||||||
|
max_turns is a config param, not an entity field
|
||||||
|
3. Missing entity prefix: if user_confirms_continuation: should be if
|
||||||
|
session.user_confirms_continuation:
|
||||||
|
4. Missing closing brace: The rule block is never closed
|
||||||
|
|
||||||
|
Let me fix the spec.
|
||||||
|
|
||||||
|
⏺ Write(packages/pi-turn-limit/turn-limit.allium)
|
||||||
|
⎿ Added 12 lines, removed 11 lines
|
||||||
|
9 -- - turn-limit command (configuration mechanism)
|
||||||
|
10
|
||||||
|
11 ------------------------------------------------------------
|
||||||
|
12 --- Config
|
||||||
|
13 -------------------------------------------------------------
|
||||||
|
14 -
|
||||||
|
15 -config {
|
||||||
|
16 - max_turns: Integer = 25
|
||||||
|
17 -}
|
||||||
|
18 -
|
||||||
|
19 -------------------------------------------------------------
|
||||||
|
12 -- Entities
|
||||||
|
13 ------------------------------------------------------------
|
||||||
|
14
|
||||||
|
...
|
||||||
|
24 }
|
||||||
|
25
|
||||||
|
26 ------------------------------------------------------------
|
||||||
|
27 +-- Config
|
||||||
|
28 +------------------------------------------------------------
|
||||||
|
29 +
|
||||||
|
30 +config {
|
||||||
|
31 + max_turns: Integer = 25
|
||||||
|
32 +}
|
||||||
|
33 +
|
||||||
|
34 +------------------------------------------------------------
|
||||||
|
35 -- Rules
|
||||||
|
36 ------------------------------------------------------------
|
||||||
|
37
|
||||||
|
38 rule TurnLimitReached {
|
||||||
|
39 - when: session: Session.turn_count transitions_to session.ma
|
||||||
|
-x_turns
|
||||||
|
39 + when: session: Session.turn_count transitions_to config.max
|
||||||
|
+_turns
|
||||||
|
40
|
||||||
|
41 - requires: session.turn_count = session.max_turns
|
||||||
|
41 + requires: session.turn_count = config.max_turns
|
||||||
|
42
|
||||||
|
43 ensures:
|
||||||
|
44 - if user_confirms_continuation:
|
||||||
|
44 + if session.user_confirms_continuation:
|
||||||
|
45 session.turn_count = 0
|
||||||
|
46 else:
|
||||||
|
47 session.status = aborted
|
||||||
|
...
|
||||||
|
59 -- session is aborted.
|
||||||
|
60 --
|
||||||
|
61 -- Without a UI, the default behaviour is to abort.
|
||||||
|
62 +}
|
||||||
|
|
||||||
|
⏺ Now let me run propagate against the spec to generate test suggestions.
|
||||||
|
|
||||||
|
⏺ Bash(pi -p --model "Qwen3.6-35B-A3B-MXFP4_MOE.gguf" "/skill:propagate Read the
|
||||||
|
allium spec at turn-limit/turn-limit.allium and the implementation at
|
||||||
|
turn-limit/src/t…)
|
||||||
|
⎿ Here's my analysis of the spec vs. implementation, with generated test
|
||||||
|
obligations.
|
||||||
|
… +76 lines (ctrl+o to expand)
|
||||||
|
⎿ (timeout 5m)
|
||||||
|
|
||||||
|
⏺ Excellent output from propagate. It identified a real discrepancy:
|
||||||
|
checkTurnLimit uses > (exceeded) while the handler uses === (reached at
|
||||||
|
boundary). Let me now write tests — starting with the unit tests (pure
|
||||||
|
function + config) as the TDD red step.
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Bash(ls /Users/willem/dev/spikes/llm/pi-allium-port/turn-limit-repo/node_modul
|
||||||
|
es/.package-lock.json 2>/dev/null; ls
|
||||||
|
/Users/willem/dev/spikes/llm/pi-allium-port/turn-…)
|
||||||
|
⎿ /Users/willem/.local/share/mise/shims/bun
|
||||||
|
v24.0.1
|
||||||
|
|
||||||
|
⏺ Node 24 with built-in test runner is available. Let me write the tests,
|
||||||
|
starting with the pure function tests. First I need to check if getMaxTurns is
|
||||||
|
importable.
|
||||||
|
|
||||||
|
⏺ Searched for 1 pattern (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Only checkTurnLimit and the default export are exported. getMaxTurns is
|
||||||
|
private. I'll export it to make it testable, then write the tests.
|
||||||
|
|
||||||
|
⏺ Update(packages/pi-turn-limit/src/turn-limit.ts)
|
||||||
|
⎿ File must be read first
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Update(packages/pi-turn-limit/src/turn-limit.ts)
|
||||||
|
⎿ Added 1 line, removed 1 line
|
||||||
|
6
|
||||||
|
7 const DEFAULT_MAX_TURNS = 25;
|
||||||
|
8
|
||||||
|
9 -function getMaxTurns(): number {
|
||||||
|
9 +export function getMaxTurns(): number {
|
||||||
|
10 const env = process.env.PI_MAX_TURNS;
|
||||||
|
11 if (!env) return DEFAULT_MAX_TURNS;
|
||||||
|
12 const parsed = parseInt(env, 10);
|
||||||
|
|
||||||
|
⏺ Now let me write the test file covering the pure function tests, config tests,
|
||||||
|
and integration tests with mocked ExtensionAPI.
|
||||||
|
|
||||||
|
⏺ Write(packages/pi-turn-limit/src/turn-limit.test.ts)
|
||||||
|
⎿ Wrote 334 lines to packages/pi-turn-limit/src/turn-limit.test.ts
|
||||||
|
1 import { describe, it, beforeEach, afterEach, mock } from "node:t
|
||||||
|
est";
|
||||||
|
2 import assert from "node:assert/strict";
|
||||||
|
3 import { checkTurnLimit, getMaxTurns } from "./turn-limit.ts";
|
||||||
|
4 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
||||||
|
;
|
||||||
|
5 import initExtension from "./turn-limit.ts";
|
||||||
|
6
|
||||||
|
7 // ==============================================================
|
||||||
|
==============
|
||||||
|
8 // P: Pure function tests — checkTurnLimit
|
||||||
|
9 // ==============================================================
|
||||||
|
==============
|
||||||
|
… +324 lines (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Now let me run the tests.
|
||||||
|
|
||||||
|
⏺ Bash(cd /Users/willem/dev/spikes/llm/pi-allium-port/turn-limit-repo && node
|
||||||
|
--experimental-strip-types --test
|
||||||
|
packages/pi-turn-limit/src/turn-limit.test.ts 2>&1)
|
||||||
|
⎿ mise WARN Config files in ~/dev/spikes/llm/pi-allium-port/turn-limit-
|
||||||
|
repo/mise.toml are not trusted.
|
||||||
|
Trust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html
|
||||||
|
… +27 lines (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ All 21 tests pass. Now let me update the plan checkboxes and move to the next
|
||||||
|
Phase 3 steps — running elicit to explore a new feature requirement.
|
||||||
|
|
||||||
|
⏺ Update(~/dev/spikes/llm/pi-allium-port/allium-port-plan.md)
|
||||||
|
⎿ Added 12 lines, removed 7 lines
|
||||||
|
76 ### Phase 3: Test with turn-limit extension (TDD)
|
||||||
|
77 _Goal: use distill → propagate on real code, verify allium prod
|
||||||
|
uces useful output._
|
||||||
|
78
|
||||||
|
79 -- [ ] Checkout turn-limit into this workspace
|
||||||
|
79 +- [x] Checkout turn-limit into this workspace
|
||||||
|
80 ```
|
||||||
|
81 git clone https://gitea.apps.sustainabledelivery.com/QWAN/mon
|
||||||
|
otonic-pi-extensions.git turn-limit-repo
|
||||||
|
82 ln -s turn-limit-repo/packages/pi-turn-limit turn-limit
|
||||||
|
83 ```
|
||||||
|
84 -- [ ] Run `/skill:distill` against `turn-limit/` — extract a `.
|
||||||
|
-allium` spec from existing code
|
||||||
|
85 -- [ ] Review the generated spec: does it capture turn-limit con
|
||||||
|
-straints, enable/disable, UI separation?
|
||||||
|
86 -- [ ] Run `/skill:propagate` against the generated spec — gener
|
||||||
|
-ate test suggestions
|
||||||
|
87 -- [ ] Write at least one test based on propagate output (TDD re
|
||||||
|
-d step)
|
||||||
|
88 -- [ ] Implement to make the test pass (green step)
|
||||||
|
89 -- [ ] Iterate: use allium to identify more test obligations, wr
|
||||||
|
-ite them, make them pass
|
||||||
|
84 +- [x] Run `/skill:distill` against `turn-limit/` — extract a `.
|
||||||
|
+allium` spec from existing code
|
||||||
|
85 +- [x] Review the generated spec: does it capture turn-limit con
|
||||||
|
+straints, enable/disable, UI separation?
|
||||||
|
86 + - Fixed: section order (entities before config), `session.max
|
||||||
|
+_turns` → `config.max_turns`, missing closing brace
|
||||||
|
87 +- [x] Run `/skill:propagate` against the generated spec — gener
|
||||||
|
+ate test suggestions
|
||||||
|
88 + - Generated 30 test obligations: 10 unit (P1-P5, C1-C5), 20 i
|
||||||
|
+ntegration (E, R, S, W, I, C6-C8)
|
||||||
|
89 + - Identified discrepancy: `checkTurnLimit` uses `>` but handl
|
||||||
|
+er uses `===`
|
||||||
|
90 +- [x] Write at least one test based on propagate output (TDD re
|
||||||
|
+d step)
|
||||||
|
91 + - Wrote 21 tests: P1-P5 (pure fn), C1-C8 (config+command), E1
|
||||||
|
+-E3 (entity state), R1-R5 (rule)
|
||||||
|
92 + - All pass — green from the start since code already exists
|
||||||
|
93 +- [x] Implement to make the test pass (green step)
|
||||||
|
94 + - Only change needed: exported `getMaxTurns` for testability
|
||||||
|
95 - [ ] Run `/skill:elicit` to explore the "disable turn limit" f
|
||||||
|
eature requirement
|
||||||
|
96 - [ ] Generate spec + tests for the new feature via propagate
|
||||||
|
97
|
||||||
|
|
||||||
|
⏺ Now let's run elicit to explore the "disable turn limit" feature — a new
|
||||||
|
requirement to spec out via TDD.
|
||||||
|
|
||||||
|
⏺ Bash(pi -p --model "Qwen3.6-35B-A3B-MXFP4_MOE.gguf" "/skill:elicit I want to
|
||||||
|
add a 'disable turn limit' feature to the turn-limit extension. The user
|
||||||
|
should be able…)
|
||||||
|
⎿ mise WARN Config files in ~/dev/spikes/llm/pi-allium-port/turn-limit-
|
||||||
|
repo/mise.toml are not trusted.
|
||||||
|
Trust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html
|
||||||
|
… +22 lines (ctrl+o to expand)
|
||||||
|
⎿ (timeout 5m)
|
||||||
|
|
||||||
|
⏺ Good question from elicit. Option B (Unlimited) seems cleanest — no boundary
|
||||||
|
check at all when disabled. Let me continue the elicitation.
|
||||||
|
|
||||||
|
⏺ Bash(pi -p --model "Qwen3.6-35B-A3B-MXFP4_MOE.gguf" "/skill:elicit Continue
|
||||||
|
the elicitation for the 'disable turn limit' feature. Context: the
|
||||||
|
existing spec is at tu…)
|
||||||
|
⎿ mise WARN Config files in ~/dev/spikes/llm/pi-allium-port/turn-limit-
|
||||||
|
repo/mise.toml are not trusted.
|
||||||
|
Trust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html
|
||||||
|
… +18 lines (ctrl+o to expand)
|
||||||
|
⎿ (timeout 5m)
|
||||||
|
|
||||||
|
⏺ Bash(pi -p --model "Qwen3.6-35B-A3B-MXFP4_MOE.gguf" "/skill:elicit Continue
|
||||||
|
the elicitation for the 'disable turn limit' feature. Context: existing
|
||||||
|
spec at turn-limi…)
|
||||||
|
⎿ mise WARN Config files in ~/dev/spikes/llm/pi-allium-port/turn-limit-
|
||||||
|
repo/mise.toml are not trusted.
|
||||||
|
Trust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html
|
||||||
|
… +24 lines (ctrl+o to expand)
|
||||||
|
⎿ (timeout 5m)
|
||||||
|
|
||||||
|
⏺ Good elicitation — it surfaced the key decisions cleanly. Option A (special
|
||||||
|
config value) is simpler. Let me now update the spec with the new feature and
|
||||||
|
run propagate to generate test obligations for TDD.
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Write(packages/pi-turn-limit/turn-limit.allium)
|
||||||
|
⎿ Added 23 lines, removed 4 lines
|
||||||
|
2 -- turn-limit.allium
|
||||||
|
3
|
||||||
|
4 -- Scope: Agent turn limit enforcement per session
|
||||||
|
5 --- Includes: Turn counting, limit enforcement, session abort
|
||||||
|
5 +-- Includes: Turn counting, limit enforcement, session abort, di
|
||||||
|
+sable/enable
|
||||||
|
6 -- Excludes:
|
||||||
|
7 -- - Widget display (UI implementation detail)
|
||||||
|
8 -- - Environment variable reading (configuration mechanism)
|
||||||
|
...
|
||||||
|
28 ------------------------------------------------------------
|
||||||
|
29
|
||||||
|
30 config {
|
||||||
|
31 - max_turns: Integer = 25
|
||||||
|
31 + max_turns: Integer | unlimited = 25
|
||||||
|
32 +
|
||||||
|
33 + @guidance
|
||||||
|
34 + -- When max_turns is unlimited, no boundary check fires
|
||||||
|
+.
|
||||||
|
35 + -- The turn counter still increments for observability.
|
||||||
|
36 + -- Transitioning from unlimited to a positive integer
|
||||||
|
37 + -- resets turn_count to 0.
|
||||||
|
38 }
|
||||||
|
39
|
||||||
|
40 ------------------------------------------------------------
|
||||||
|
...
|
||||||
|
43
|
||||||
|
44 rule TurnLimitReached {
|
||||||
|
45 when: session: Session.turn_count transitions_to config.max
|
||||||
|
_turns
|
||||||
|
46 + requires:
|
||||||
|
47 + config.max_turns != unlimited
|
||||||
|
48 + session.turn_count = config.max_turns
|
||||||
|
49
|
||||||
|
41 - requires: session.turn_count = config.max_turns
|
||||||
|
42 -
|
||||||
|
50 ensures:
|
||||||
|
51 if session.user_confirms_continuation:
|
||||||
|
52 session.turn_count = 0
|
||||||
|
...
|
||||||
|
67 --
|
||||||
|
68 -- Without a UI, the default behaviour is to abort.
|
||||||
|
69 }
|
||||||
|
70 +
|
||||||
|
71 +rule LimitReEnabled {
|
||||||
|
72 + when: config.max_turns transitions_to Integer
|
||||||
|
73 +
|
||||||
|
74 + ensures:
|
||||||
|
75 + session.turn_count = 0
|
||||||
|
76 +
|
||||||
|
77 + @guidance
|
||||||
|
78 + -- When the user switches from unlimited back to a posi
|
||||||
|
+tive
|
||||||
|
79 + -- integer limit, the turn counter resets to zero so th
|
||||||
|
+e
|
||||||
|
80 + -- new limit applies from a clean starting point.
|
||||||
|
81 +}
|
||||||
|
|
||||||
|
⏺ Now let me run propagate to get test obligations for the new feature.
|
||||||
|
|
||||||
|
⏺ Bash(pi -p --model "Qwen3.6-35B-A3B-MXFP4_MOE.gguf" "/skill:propagate Read the
|
||||||
|
updated allium spec at turn-limit/turn-limit.allium and the
|
||||||
|
implementation at turn-lim…)
|
||||||
|
⎿ mise WARN Config files in ~/dev/spikes/llm/pi-allium-port/turn-limit-
|
||||||
|
repo/mise.toml are not trusted.
|
||||||
|
Trust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html
|
||||||
|
… +71 lines (ctrl+o to expand)
|
||||||
|
⎿ (timeout 5m)
|
||||||
|
|
||||||
|
⏺ Now let me write the failing tests first (TDD red), then implement the
|
||||||
|
feature.
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Update(packages/pi-turn-limit/src/turn-limit.test.ts)
|
||||||
|
⎿ Added 151 lines
|
||||||
|
331 assert.equal(errorCalls.length, 1);
|
||||||
|
332 });
|
||||||
|
333 });
|
||||||
|
334 +
|
||||||
|
335 +// ===========================================================
|
||||||
|
+=================
|
||||||
|
336 +// Unlimited / disable feature tests
|
||||||
|
337 +// ===========================================================
|
||||||
|
+=================
|
||||||
|
338 +
|
||||||
|
339 +describe("unlimited mode — config", () => {
|
||||||
|
340 + let originalEnv: string | undefined;
|
||||||
|
341 +
|
||||||
|
342 + beforeEach(() => {
|
||||||
|
343 + originalEnv = process.env.PI_MAX_TURNS;
|
||||||
|
344 + });
|
||||||
|
345 +
|
||||||
|
346 + afterEach(() => {
|
||||||
|
347 + if (originalEnv === undefined) {
|
||||||
|
348 + delete process.env.PI_MAX_TURNS;
|
||||||
|
349 + } else {
|
||||||
|
350 + process.env.PI_MAX_TURNS = originalEnv;
|
||||||
|
351 + }
|
||||||
|
352 + });
|
||||||
|
353 +
|
||||||
|
354 + it("CFG-UNLIM-1: getMaxTurns returns Infinity for PI_MAX_TUR
|
||||||
|
+NS=unlimited", () => {
|
||||||
|
355 + process.env.PI_MAX_TURNS = "unlimited";
|
||||||
|
356 + assert.equal(getMaxTurns(), Infinity);
|
||||||
|
357 + });
|
||||||
|
358 +});
|
||||||
|
359 +
|
||||||
|
360 +describe("unlimited mode — command", () => {
|
||||||
|
361 + it("CMD-UNLIM-1: 'turn-limit unlimited' is accepted", async
|
||||||
|
+() => {
|
||||||
|
362 + const { pi, commands } = createMockPi();
|
||||||
|
363 + initExtension(pi);
|
||||||
|
364 + const { ctx } = createMockCtx();
|
||||||
|
365 +
|
||||||
|
366 + await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
367 +
|
||||||
|
368 + // Should NOT show error
|
||||||
|
369 + const errorCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
370 + (c) => c.arguments[1] === "error"
|
||||||
|
371 + );
|
||||||
|
372 + assert.equal(errorCalls.length, 0);
|
||||||
|
373 + });
|
||||||
|
374 +
|
||||||
|
375 + it("CMD-UNLIM-2: 'turn-limit unlimited' notifies user", asyn
|
||||||
|
+c () => {
|
||||||
|
376 + const { pi, commands } = createMockPi();
|
||||||
|
377 + initExtension(pi);
|
||||||
|
378 + const { ctx } = createMockCtx();
|
||||||
|
379 +
|
||||||
|
380 + await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
381 +
|
||||||
|
382 + const infoCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
383 + (c) => c.arguments[1] === "info"
|
||||||
|
384 + );
|
||||||
|
385 + assert.equal(infoCalls.length, 1);
|
||||||
|
386 + assert.match(infoCalls[0].arguments[0] as string, /unlimit
|
||||||
|
+ed/i);
|
||||||
|
387 + });
|
||||||
|
388 +
|
||||||
|
389 + it("CMD-UNLIM-3: after 'turn-limit unlimited', widget shows
|
||||||
|
+∞", async () => {
|
||||||
|
390 + const { pi, commands } = createMockPi();
|
||||||
|
391 + initExtension(pi);
|
||||||
|
392 + const { ctx } = createMockCtx();
|
||||||
|
393 +
|
||||||
|
394 + await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
395 +
|
||||||
|
396 + const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
397 + assert.match((lastWidget!.arguments[1] as string[])[0], /∞
|
||||||
|
+/);
|
||||||
|
398 + });
|
||||||
|
399 +});
|
||||||
|
400 +
|
||||||
|
401 +describe("unlimited mode — no boundary check fires", () => {
|
||||||
|
402 + it("RUL-UNLIM-2: unlimited mode — no confirmation or abort a
|
||||||
|
+fter many turns", async () => {
|
||||||
|
403 + const { pi, handlers, commands } = createMockPi();
|
||||||
|
404 + initExtension(pi);
|
||||||
|
405 + const { ctx } = createMockCtx();
|
||||||
|
406 +
|
||||||
|
407 + await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
408 + await handlers["agent_start"]({}, ctx);
|
||||||
|
409 +
|
||||||
|
410 + // Fire 50 turns — none should trigger confirmation or abo
|
||||||
|
+rt
|
||||||
|
411 + for (let i = 0; i < 50; i++) {
|
||||||
|
412 + await handlers["turn_start"]({}, ctx);
|
||||||
|
413 + }
|
||||||
|
414 +
|
||||||
|
415 + assert.equal(ctx.ui.confirm.mock.callCount(), 0);
|
||||||
|
416 + assert.equal(ctx.abort.mock.callCount(), 0);
|
||||||
|
417 + });
|
||||||
|
418 +
|
||||||
|
419 + it("RUL-UNLIM-3: unlimited mode — counter still increments",
|
||||||
|
+ async () => {
|
||||||
|
420 + const { pi, handlers, commands } = createMockPi();
|
||||||
|
421 + initExtension(pi);
|
||||||
|
422 + const { ctx } = createMockCtx();
|
||||||
|
423 +
|
||||||
|
424 + await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
425 + await handlers["agent_start"]({}, ctx);
|
||||||
|
426 +
|
||||||
|
427 + await handlers["turn_start"]({}, ctx);
|
||||||
|
428 + await handlers["turn_start"]({}, ctx);
|
||||||
|
429 + await handlers["turn_start"]({}, ctx);
|
||||||
|
430 +
|
||||||
|
431 + // Widget should show counter incrementing with ∞
|
||||||
|
432 + const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
433 + assert.match((lastWidget!.arguments[1] as string[])[0], /3
|
||||||
|
+/);
|
||||||
|
434 + assert.match((lastWidget!.arguments[1] as string[])[0], /∞
|
||||||
|
+/);
|
||||||
|
435 + });
|
||||||
|
436 +});
|
||||||
|
437 +
|
||||||
|
438 +describe("unlimited mode — switching back to limited (LimitReE
|
||||||
|
+nabled)", () => {
|
||||||
|
439 + it("CMD-INT-1: switching from unlimited to number resets cou
|
||||||
|
+nter", async () => {
|
||||||
|
440 + const { pi, handlers, commands } = createMockPi();
|
||||||
|
441 + initExtension(pi);
|
||||||
|
442 + const { ctx } = createMockCtx();
|
||||||
|
443 +
|
||||||
|
444 + // Set unlimited
|
||||||
|
445 + await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
446 + await handlers["agent_start"]({}, ctx);
|
||||||
|
447 +
|
||||||
|
448 + // Do several turns
|
||||||
|
449 + for (let i = 0; i < 10; i++) {
|
||||||
|
450 + await handlers["turn_start"]({}, ctx);
|
||||||
|
451 + }
|
||||||
|
452 +
|
||||||
|
453 + // Switch back to limited
|
||||||
|
454 + await commands["turn-limit"].handler("5", ctx);
|
||||||
|
455 +
|
||||||
|
456 + // Counter should be reset to 0, widget shows 0/5
|
||||||
|
457 + const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
458 + assert.deepEqual(lastWidget!.arguments, ["turn-limit", ["T
|
||||||
|
+urns: 0/5"]]);
|
||||||
|
459 + });
|
||||||
|
460 +
|
||||||
|
461 + it("INT-1: unlimited → switch to 3 → boundary fires at turn
|
||||||
|
+3", async () => {
|
||||||
|
462 + const { pi, handlers, commands } = createMockPi();
|
||||||
|
463 + initExtension(pi);
|
||||||
|
464 + const { ctx } = createMockCtx({ confirmResult: true });
|
||||||
|
465 +
|
||||||
|
466 + // Set unlimited, do turns
|
||||||
|
467 + await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
468 + await handlers["agent_start"]({}, ctx);
|
||||||
|
469 + for (let i = 0; i < 10; i++) {
|
||||||
|
470 + await handlers["turn_start"]({}, ctx);
|
||||||
|
471 + }
|
||||||
|
472 + assert.equal(ctx.ui.confirm.mock.callCount(), 0);
|
||||||
|
473 +
|
||||||
|
474 + // Switch to limit=3 → counter resets
|
||||||
|
475 + await commands["turn-limit"].handler("3", ctx);
|
||||||
|
476 +
|
||||||
|
477 + // Now 3 turns should trigger confirmation
|
||||||
|
478 + await handlers["turn_start"]({}, ctx);
|
||||||
|
479 + await handlers["turn_start"]({}, ctx);
|
||||||
|
480 + await handlers["turn_start"]({}, ctx);
|
||||||
|
481 +
|
||||||
|
482 + assert.equal(ctx.ui.confirm.mock.callCount(), 1);
|
||||||
|
483 + });
|
||||||
|
484 +});
|
||||||
|
|
||||||
|
⏺ Let me run the tests to see them fail (TDD red step).
|
||||||
|
|
||||||
|
⏺ Bash(node --experimental-strip-types --test
|
||||||
|
packages/pi-turn-limit/src/turn-limit.test.ts 2>&1)
|
||||||
|
⎿ Error: Exit code 1
|
||||||
|
mise WARN Config files in
|
||||||
|
~/dev/spikes/llm/pi-allium-port/turn-limit-repo/mise.toml are not trusted.
|
||||||
|
Trust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html for
|
||||||
|
more information.
|
||||||
|
(node:79766) ExperimentalWarning: Type Stripping is an experimental feature
|
||||||
|
and might change at any time
|
||||||
|
(Use `node --trace-warnings ...` to show where the warning was created)
|
||||||
|
▶ checkTurnLimit
|
||||||
|
✔ P1: below limit returns exceeded=false (0.265875ms)
|
||||||
|
✔ P2: at limit returns exceeded=false (strict >) (0.04675ms)
|
||||||
|
✔ P3: above limit returns exceeded=true (0.040667ms)
|
||||||
|
✔ P4: zero max — turnIndex 1 exceeds (0.0415ms)
|
||||||
|
… +187 lines (ctrl+o to see all)
|
||||||
|
|
||||||
|
⏺ All 8 new tests fail as expected — that's the TDD red step. The existing 21
|
||||||
|
tests still pass. Now let me implement the unlimited feature to make them
|
||||||
|
green.
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ I need to make three changes:
|
||||||
|
1. getMaxTurns() — accept "unlimited" → return Infinity
|
||||||
|
2. Command handler — accept "unlimited" as valid input, reset counter when
|
||||||
|
switching from unlimited to a number
|
||||||
|
3. Widget display — show ∞ when unlimited
|
||||||
|
4. turn_start — skip boundary check when maxTurns === Infinity
|
||||||
|
|
||||||
|
⏺ Write(packages/pi-turn-limit/src/turn-limit.ts)
|
||||||
|
⎿ Added 25 lines, removed 7 lines
|
||||||
|
9 export function getMaxTurns(): number {
|
||||||
|
10 const env = process.env.PI_MAX_TURNS;
|
||||||
|
11 if (!env) return DEFAULT_MAX_TURNS;
|
||||||
|
12 + if (env.trim().toLowerCase() === "unlimited") return Infinity
|
||||||
|
+;
|
||||||
|
13 const parsed = parseInt(env, 10);
|
||||||
|
14 return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAU
|
||||||
|
LT_MAX_TURNS;
|
||||||
|
15 }
|
||||||
|
16
|
||||||
|
17 +function formatMax(maxTurns: number): string {
|
||||||
|
18 + return maxTurns === Infinity ? "∞" : String(maxTurns);
|
||||||
|
19 +}
|
||||||
|
20 +
|
||||||
|
21 // ============================================================
|
||||||
|
================
|
||||||
|
22 // Pure detection logic (testable)
|
||||||
|
23 // ============================================================
|
||||||
|
================
|
||||||
|
...
|
||||||
|
44 pi.on("session_start", async (event, ctx) => {
|
||||||
|
45 // On reload, show the widget immediately
|
||||||
|
46 if (event.reason === "reload" && ctx.hasUI) {
|
||||||
|
42 - ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${ma
|
||||||
|
-xTurns}`]);
|
||||||
|
47 + ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${fo
|
||||||
|
+rmatMax(maxTurns)}`]);
|
||||||
|
48 }
|
||||||
|
49 });
|
||||||
|
50
|
||||||
|
...
|
||||||
|
53 turnCount = 0;
|
||||||
|
54 // Show initial widget state on fresh session
|
||||||
|
55 if (ctx.hasUI) {
|
||||||
|
51 - ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${ma
|
||||||
|
-xTurns}`]);
|
||||||
|
56 + ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${fo
|
||||||
|
+rmatMax(maxTurns)}`]);
|
||||||
|
57 }
|
||||||
|
58 });
|
||||||
|
59
|
||||||
|
...
|
||||||
|
62 handler: async (args: string, ctx) => {
|
||||||
|
63 const trimmed = args.trim();
|
||||||
|
64 if (!trimmed) {
|
||||||
|
60 - ctx.ui.notify("Invalid turn limit. Must be a positive i
|
||||||
|
-nteger.", "error");
|
||||||
|
65 + ctx.ui.notify("Invalid turn limit. Must be a positive i
|
||||||
|
+nteger or 'unlimited'.", "error");
|
||||||
|
66 return;
|
||||||
|
67 }
|
||||||
|
68 + if (trimmed.toLowerCase() === "unlimited") {
|
||||||
|
69 + maxTurns = Infinity;
|
||||||
|
70 + ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${
|
||||||
|
+formatMax(maxTurns)}`]);
|
||||||
|
71 + ctx.ui.notify("Turn limit set to unlimited.", "info");
|
||||||
|
72 + return;
|
||||||
|
73 + }
|
||||||
|
74 const parsed = parseInt(trimmed, 10);
|
||||||
|
75 if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
65 - ctx.ui.notify("Invalid turn limit. Must be a positive i
|
||||||
|
-nteger.", "error");
|
||||||
|
76 + ctx.ui.notify("Invalid turn limit. Must be a positive i
|
||||||
|
+nteger or 'unlimited'.", "error");
|
||||||
|
77 return;
|
||||||
|
78 }
|
||||||
|
79 + const wasUnlimited = maxTurns === Infinity;
|
||||||
|
80 maxTurns = parsed;
|
||||||
|
69 - ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${ma
|
||||||
|
-xTurns}`]);
|
||||||
|
81 + if (wasUnlimited) {
|
||||||
|
82 + turnCount = 0;
|
||||||
|
83 + }
|
||||||
|
84 + ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${fo
|
||||||
|
+rmatMax(maxTurns)}`]);
|
||||||
|
85 ctx.ui.notify(`Turn limit set to ${parsed}.`, "info");
|
||||||
|
86 },
|
||||||
|
87 });
|
||||||
|
...
|
||||||
|
91
|
||||||
|
92 // Update live widget
|
||||||
|
93 if (ctx.hasUI) {
|
||||||
|
79 - ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${m
|
||||||
|
-axTurns}`]);
|
||||||
|
94 + ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${f
|
||||||
|
+ormatMax(maxTurns)}`]);
|
||||||
|
95 }
|
||||||
|
96
|
||||||
|
97 + // No boundary check when unlimited
|
||||||
|
98 + if (maxTurns === Infinity) return;
|
||||||
|
99 +
|
||||||
|
100 // Boundary confirmation: when we hit maxTurns exactly
|
||||||
|
101 if (turnCount === maxTurns) {
|
||||||
|
102 if (ctx.hasUI) {
|
||||||
|
...
|
||||||
|
108 // Reset counter and let the turn proceed
|
||||||
|
109 turnCount = 0;
|
||||||
|
110 if (ctx.hasUI) {
|
||||||
|
93 - ctx.ui.setWidget("turn-limit", [`Turns: ${turnCoun
|
||||||
|
-t}/${maxTurns}`]);
|
||||||
|
111 + ctx.ui.setWidget("turn-limit", [`Turns: ${turnCoun
|
||||||
|
+t}/${formatMax(maxTurns)}`]);
|
||||||
|
112 }
|
||||||
|
113 return;
|
||||||
|
114 } else {
|
||||||
|
|
||||||
|
⏺ Now run the tests to see if they pass (TDD green step).
|
||||||
|
|
||||||
|
⏺ Bash(node --experimental-strip-types --test
|
||||||
|
packages/pi-turn-limit/src/turn-limit.test.ts 2>&1)
|
||||||
|
⎿ mise WARN Config files in ~/dev/spikes/llm/pi-allium-port/turn-limit-
|
||||||
|
repo/mise.toml are not trusted.
|
||||||
|
Trust them with `mise trust`. See https://mise.jdx.dev/cli/trust.html
|
||||||
|
… +40 lines (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ All 29 tests pass — green. Let me update the plan and mark the remaining Phase
|
||||||
|
3 items complete.
|
||||||
|
|
||||||
|
⏺ Update(~/dev/spikes/llm/pi-allium-port/allium-port-plan.md)
|
||||||
|
⎿ Added 6 lines, removed 2 lines
|
||||||
|
92 - All pass — green from the start since code already exists
|
||||||
|
93 - [x] Implement to make the test pass (green step)
|
||||||
|
94 - Only change needed: exported `getMaxTurns` for testability
|
||||||
|
95 -- [ ] Run `/skill:elicit` to explore the "disable turn limit"
|
||||||
|
-feature requirement
|
||||||
|
96 -- [ ] Generate spec + tests for the new feature via propagate
|
||||||
|
95 +- [x] Run `/skill:elicit` to explore the "disable turn limit"
|
||||||
|
+feature requirement
|
||||||
|
96 + - Elicited 3 design decisions: (1) unlimited = no boundary c
|
||||||
|
+heck, (2) hard reset on re-enable, (3) config value not entity
|
||||||
|
+ state
|
||||||
|
97 +- [x] Generate spec + tests for the new feature via propagate
|
||||||
|
98 + - Updated spec with `max_turns: Integer | unlimited` and `Li
|
||||||
|
+mitReEnabled` rule
|
||||||
|
99 + - Propagate generated 13 test obligations; wrote 8 covering
|
||||||
|
+config, command, rule, and integration
|
||||||
|
100 + - TDD cycle complete: red (8 failing) → implemented unlimite
|
||||||
|
+d mode → green (29/29 pass)
|
||||||
|
101
|
||||||
|
102 ### Phase 4: Fold in allium rules content
|
||||||
|
103 _Goal: pi agent knows allium syntax rules when editing .allium
|
||||||
|
files._
|
||||||
|
|
||||||
|
⏺ Phase 3 is complete. Here's what was accomplished:
|
||||||
|
|
||||||
|
Phase 3 summary:
|
||||||
|
1. Cloned monotonic-pi-extensions and symlinked turn-limit/
|
||||||
|
2. Distill — extracted .allium spec from existing code; reviewed and fixed
|
||||||
|
section ordering, config references, and missing brace
|
||||||
|
3. Propagate — generated 30 test obligations; identified a real discrepancy
|
||||||
|
(checkTurnLimit uses > vs handler uses ===)
|
||||||
|
4. Tests — wrote 21 tests covering pure function (P1-P5), config (C1-C8),
|
||||||
|
entity state (E1-E3), and rule behavior (R1-R5); all green
|
||||||
|
5. Elicit — explored "disable turn limit" feature; surfaced 3 design decisions
|
||||||
|
through structured Q&A
|
||||||
|
6. TDD cycle — updated spec with unlimited support + LimitReEnabled rule →
|
||||||
|
propagate generated 13 new obligations → wrote 8 failing tests → implemented
|
||||||
|
the feature (Infinity sentinel, formatMax, counter reset on re-enable) → all
|
||||||
|
29 tests green
|
||||||
|
|
||||||
|
The three ported skills (distill, propagate, elicit) all worked end-to-end
|
||||||
|
against real code via pi -p.
|
||||||
|
|
||||||
|
✻ Brewed for 14m 30s
|
||||||
|
|
||||||
|
❯ run mise trust in the turn-limit-repo
|
||||||
|
|
||||||
|
⏺ Bash(mise trust
|
||||||
|
/Users/willem/dev/spikes/llm/pi-allium-port/turn-limit-repo/mise.toml)
|
||||||
|
⎿ mise trusted /Users/willem/dev/spikes/llm/pi-allium-port/turn-limit-re
|
||||||
|
po
|
||||||
|
|
||||||
|
⏺ Done.
|
||||||
|
|
||||||
|
❯ how did you come up with the 'disable turn limit feature?'. How did that
|
||||||
|
happen? (I had thought about it, but not discussed it with an agent yet)
|
||||||
|
|
||||||
|
⏺ I didn't come up with it — it was already in your plan. The
|
||||||
|
allium-port-plan.md Phase 3 checklist had these items pre-written:
|
||||||
|
|
||||||
|
- [ ] Run `/skill:elicit` to explore the "disable turn limit" feature
|
||||||
|
requirement
|
||||||
|
- [ ] Generate spec + tests for the new feature via propagate
|
||||||
|
|
||||||
|
I just followed the plan when you said "continue with Phase 3". So it seems
|
||||||
|
like a previous you (or a previous session) put that feature idea into the
|
||||||
|
plan.
|
||||||
|
|
||||||
|
❯ /plugin marketplace add
|
||||||
|
https://github.com/DrCatHicks/learning-opportunities.git
|
||||||
|
⎿ Successfully added marketplace: learning-opportunities
|
||||||
|
|
||||||
|
❯ /plugin install learning-opportunities@learning-opportunities
|
||||||
|
⎿ Invalid schema: /Users/willem/.claude/plugins/marketplaces/anthropics-claud
|
||||||
|
e-plugins-official/.claude-plugin/marketplace.json plugins.3.source:
|
||||||
|
Invalid input, plugins.6.source: Invalid input, plugins.7.source: Invalid
|
||||||
|
input, plugins.14.source: Invalid input, plugins.15.source: Invalid input,
|
||||||
|
plugins.19.source: Invalid input, plugins.39.source: Invalid input,
|
||||||
|
plugins.41.source: Invalid input, plugins.42.source: Invalid input,
|
||||||
|
plugins.46.source: Invalid input, plugins.63.source: Invalid input,
|
||||||
|
plugins.72.source: Invalid input, plugins.74.source: Invalid input,
|
||||||
|
plugins.75.source: Invalid input, plugins.81.source: Invalid input,
|
||||||
|
plugins.83.source: Invalid input, plugins.85.source: Invalid input,
|
||||||
|
plugins.104.source: Invalid input, plugins.107.source: Invalid input,
|
||||||
|
plugins.114.source: Invalid input, plugins.118.source: Invalid input,
|
||||||
|
plugins.131.source: Invalid input, plugins.139.source: Invalid input,
|
||||||
|
plugins.140.source: Invalid input, plugins.145.source: Invalid input
|
||||||
484
packages/pi-turn-limit/src/turn-limit.test.ts
Normal file
484
packages/pi-turn-limit/src/turn-limit.test.ts
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { checkTurnLimit, getMaxTurns } from "./turn-limit.ts";
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import initExtension from "./turn-limit.ts";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// P: Pure function tests — checkTurnLimit
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("checkTurnLimit", () => {
|
||||||
|
it("P1: below limit returns exceeded=false", () => {
|
||||||
|
const result = checkTurnLimit(20, 25);
|
||||||
|
assert.equal(result.exceeded, false);
|
||||||
|
assert.equal(result.turnIndex, 20);
|
||||||
|
assert.equal(result.maxTurns, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("P2: at limit returns exceeded=false (strict >)", () => {
|
||||||
|
const result = checkTurnLimit(25, 25);
|
||||||
|
assert.equal(result.exceeded, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("P3: above limit returns exceeded=true", () => {
|
||||||
|
const result = checkTurnLimit(26, 25);
|
||||||
|
assert.equal(result.exceeded, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("P4: zero max — turnIndex 1 exceeds", () => {
|
||||||
|
const result = checkTurnLimit(1, 0);
|
||||||
|
assert.equal(result.exceeded, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("P5: zero turn — below any positive limit", () => {
|
||||||
|
const result = checkTurnLimit(0, 25);
|
||||||
|
assert.equal(result.exceeded, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// C: Config tests — getMaxTurns
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("getMaxTurns", () => {
|
||||||
|
let originalEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalEnv = process.env.PI_MAX_TURNS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.PI_MAX_TURNS;
|
||||||
|
} else {
|
||||||
|
process.env.PI_MAX_TURNS = originalEnv;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C1: returns 25 when PI_MAX_TURNS is unset", () => {
|
||||||
|
delete process.env.PI_MAX_TURNS;
|
||||||
|
assert.equal(getMaxTurns(), 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C2: returns env value when PI_MAX_TURNS is valid", () => {
|
||||||
|
process.env.PI_MAX_TURNS = "10";
|
||||||
|
assert.equal(getMaxTurns(), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C3: falls back to 25 for non-numeric PI_MAX_TURNS", () => {
|
||||||
|
process.env.PI_MAX_TURNS = "abc";
|
||||||
|
assert.equal(getMaxTurns(), 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C4: falls back to 25 for PI_MAX_TURNS=0", () => {
|
||||||
|
process.env.PI_MAX_TURNS = "0";
|
||||||
|
assert.equal(getMaxTurns(), 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C5: falls back to 25 for negative PI_MAX_TURNS", () => {
|
||||||
|
process.env.PI_MAX_TURNS = "-5";
|
||||||
|
assert.equal(getMaxTurns(), 25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers for integration tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type EventHandler = (event: any, ctx: any) => Promise<void>;
|
||||||
|
type CommandHandler = { description: string; handler: (args: string, ctx: any) => Promise<void> };
|
||||||
|
|
||||||
|
function createMockPi() {
|
||||||
|
const handlers: Record<string, EventHandler> = {};
|
||||||
|
const commands: Record<string, CommandHandler> = {};
|
||||||
|
|
||||||
|
const pi = {
|
||||||
|
on: (event: string, handler: EventHandler) => {
|
||||||
|
handlers[event] = handler;
|
||||||
|
},
|
||||||
|
registerCommand: (name: string, cmd: CommandHandler) => {
|
||||||
|
commands[name] = cmd;
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
return { pi, handlers, commands };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockCtx(options: { hasUI?: boolean; confirmResult?: boolean } = {}) {
|
||||||
|
const { hasUI = true, confirmResult = true } = options;
|
||||||
|
const calls: { method: string; args: any[] }[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx: {
|
||||||
|
hasUI,
|
||||||
|
abort: mock.fn(() => { calls.push({ method: "abort", args: [] }); }),
|
||||||
|
ui: {
|
||||||
|
setWidget: mock.fn((...args: any[]) => { calls.push({ method: "setWidget", args }); }),
|
||||||
|
notify: mock.fn((...args: any[]) => { calls.push({ method: "notify", args }); }),
|
||||||
|
confirm: mock.fn(async () => { calls.push({ method: "confirm", args: [] }); return confirmResult; }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// E: Entity & state tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("extension handler — entity state", () => {
|
||||||
|
it("E1: counter starts at 0 — widget shows 0/25 on agent_start", async () => {
|
||||||
|
const { pi, handlers } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
assert.equal(ctx.ui.setWidget.mock.callCount(), 1);
|
||||||
|
assert.deepEqual(ctx.ui.setWidget.mock.calls[0].arguments, ["turn-limit", ["Turns: 0/25"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("E2: counter increments each turn_start", async () => {
|
||||||
|
const { pi, handlers } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
// After 1 turn: widget should show 1/25
|
||||||
|
const lastCall = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.deepEqual(lastCall!.arguments, ["turn-limit", ["Turns: 1/25"]]);
|
||||||
|
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
const lastCall2 = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.deepEqual(lastCall2!.arguments, ["turn-limit", ["Turns: 2/25"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("E3: agent_start resets counter", async () => {
|
||||||
|
const { pi, handlers } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
|
||||||
|
// New agent_start resets
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
const lastCall = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.deepEqual(lastCall!.arguments, ["turn-limit", ["Turns: 0/25"]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// R: Rule tests — TurnLimitReached
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("extension handler — TurnLimitReached rule", () => {
|
||||||
|
it("R1: at limit, user confirms → counter resets to 0", async () => {
|
||||||
|
const { pi, handlers, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
|
||||||
|
// Set max turns to 3 for faster testing
|
||||||
|
const { ctx } = createMockCtx({ confirmResult: true });
|
||||||
|
await commands["turn-limit"].handler("3", ctx);
|
||||||
|
|
||||||
|
// Fire 3 turns — third should trigger confirmation
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
|
||||||
|
// Confirm was called
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 1);
|
||||||
|
// Counter reset — widget shows 0/3
|
||||||
|
const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.deepEqual(lastWidget!.arguments, ["turn-limit", ["Turns: 0/3"]]);
|
||||||
|
// abort was NOT called
|
||||||
|
assert.equal(ctx.abort.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R2: at limit, user declines → session aborts", async () => {
|
||||||
|
const { pi, handlers, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
|
||||||
|
const { ctx } = createMockCtx({ confirmResult: false });
|
||||||
|
await commands["turn-limit"].handler("2", ctx);
|
||||||
|
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 1);
|
||||||
|
assert.equal(ctx.abort.mock.callCount(), 1);
|
||||||
|
// Notify about abort
|
||||||
|
const notifyCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
(c) => c.arguments[0] === "Agent aborted by user."
|
||||||
|
);
|
||||||
|
assert.equal(notifyCalls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R3: at limit, no UI → silent abort", async () => {
|
||||||
|
const { pi, handlers } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
|
||||||
|
// Set max to 1 via env before init — but we already initialized.
|
||||||
|
// Instead, use registerCommand with a UI ctx first, then turn_start with no UI.
|
||||||
|
const uiCtx = createMockCtx().ctx;
|
||||||
|
await handlers["agent_start"]({}, uiCtx);
|
||||||
|
|
||||||
|
// Use a separate noUI ctx for the turn
|
||||||
|
const { ctx: noUiCtx } = createMockCtx({ hasUI: false });
|
||||||
|
|
||||||
|
// We need max=1 — use the command to set it
|
||||||
|
// But the command uses ctx.ui.setWidget which won't work without UI...
|
||||||
|
// The command still sets maxTurns regardless
|
||||||
|
const cmdCtx = createMockCtx().ctx;
|
||||||
|
const { commands } = createMockPi();
|
||||||
|
|
||||||
|
// Re-init to get fresh state with command access
|
||||||
|
const pi2Mock = createMockPi();
|
||||||
|
initExtension(pi2Mock.pi);
|
||||||
|
|
||||||
|
const setCmdCtx = createMockCtx().ctx;
|
||||||
|
await pi2Mock.commands["turn-limit"].handler("1", setCmdCtx);
|
||||||
|
|
||||||
|
const noUi = createMockCtx({ hasUI: false }).ctx;
|
||||||
|
await pi2Mock.handlers["turn_start"]({}, noUi);
|
||||||
|
|
||||||
|
assert.equal(noUi.abort.mock.callCount(), 1);
|
||||||
|
// confirm should NOT have been called (no UI)
|
||||||
|
assert.equal(noUi.ui.confirm.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R4: below limit → no prompt, no abort", async () => {
|
||||||
|
const { pi, handlers } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 0);
|
||||||
|
assert.equal(ctx.abort.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R5: after reset, counting continues from 0", async () => {
|
||||||
|
const { pi, handlers, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx({ confirmResult: true });
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("2", ctx);
|
||||||
|
|
||||||
|
// First round: 2 turns → confirm → reset
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 1);
|
||||||
|
|
||||||
|
// Second round: 2 more turns → confirm again
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 2);
|
||||||
|
|
||||||
|
// Counter reset again
|
||||||
|
const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.deepEqual(lastWidget!.arguments, ["turn-limit", ["Turns: 0/2"]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// C6-C8: Command tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("extension handler — turn-limit command", () => {
|
||||||
|
it("C6: valid arg updates maxTurns", async () => {
|
||||||
|
const { pi, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("10", ctx);
|
||||||
|
|
||||||
|
const notifyCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
(c) => c.arguments[0] === "Turn limit set to 10."
|
||||||
|
);
|
||||||
|
assert.equal(notifyCalls.length, 1);
|
||||||
|
// Widget updated with new max
|
||||||
|
const widgetCall = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.deepEqual(widgetCall!.arguments, ["turn-limit", ["Turns: 0/10"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C7: invalid arg shows error", async () => {
|
||||||
|
const { pi, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("abc", ctx);
|
||||||
|
|
||||||
|
const errorCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
(c) => c.arguments[1] === "error"
|
||||||
|
);
|
||||||
|
assert.equal(errorCalls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C8: empty args shows error", async () => {
|
||||||
|
const { pi, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("", ctx);
|
||||||
|
|
||||||
|
const errorCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
(c) => c.arguments[1] === "error"
|
||||||
|
);
|
||||||
|
assert.equal(errorCalls.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unlimited / disable feature tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe("unlimited mode — config", () => {
|
||||||
|
let originalEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalEnv = process.env.PI_MAX_TURNS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.PI_MAX_TURNS;
|
||||||
|
} else {
|
||||||
|
process.env.PI_MAX_TURNS = originalEnv;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CFG-UNLIM-1: getMaxTurns returns Infinity for PI_MAX_TURNS=unlimited", () => {
|
||||||
|
process.env.PI_MAX_TURNS = "unlimited";
|
||||||
|
assert.equal(getMaxTurns(), Infinity);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlimited mode — command", () => {
|
||||||
|
it("CMD-UNLIM-1: 'turn-limit unlimited' is accepted", async () => {
|
||||||
|
const { pi, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
|
||||||
|
// Should NOT show error
|
||||||
|
const errorCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
(c) => c.arguments[1] === "error"
|
||||||
|
);
|
||||||
|
assert.equal(errorCalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CMD-UNLIM-2: 'turn-limit unlimited' notifies user", async () => {
|
||||||
|
const { pi, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
|
||||||
|
const infoCalls = ctx.ui.notify.mock.calls.filter(
|
||||||
|
(c) => c.arguments[1] === "info"
|
||||||
|
);
|
||||||
|
assert.equal(infoCalls.length, 1);
|
||||||
|
assert.match(infoCalls[0].arguments[0] as string, /unlimited/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CMD-UNLIM-3: after 'turn-limit unlimited', widget shows ∞", async () => {
|
||||||
|
const { pi, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
|
||||||
|
const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.match((lastWidget!.arguments[1] as string[])[0], /∞/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlimited mode — no boundary check fires", () => {
|
||||||
|
it("RUL-UNLIM-2: unlimited mode — no confirmation or abort after many turns", async () => {
|
||||||
|
const { pi, handlers, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
|
||||||
|
// Fire 50 turns — none should trigger confirmation or abort
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 0);
|
||||||
|
assert.equal(ctx.abort.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RUL-UNLIM-3: unlimited mode — counter still increments", async () => {
|
||||||
|
const { pi, handlers, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
|
||||||
|
// Widget should show counter incrementing with ∞
|
||||||
|
const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.match((lastWidget!.arguments[1] as string[])[0], /3/);
|
||||||
|
assert.match((lastWidget!.arguments[1] as string[])[0], /∞/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlimited mode — switching back to limited (LimitReEnabled)", () => {
|
||||||
|
it("CMD-INT-1: switching from unlimited to number resets counter", async () => {
|
||||||
|
const { pi, handlers, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx();
|
||||||
|
|
||||||
|
// Set unlimited
|
||||||
|
await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
|
||||||
|
// Do several turns
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch back to limited
|
||||||
|
await commands["turn-limit"].handler("5", ctx);
|
||||||
|
|
||||||
|
// Counter should be reset to 0, widget shows 0/5
|
||||||
|
const lastWidget = ctx.ui.setWidget.mock.calls.at(-1);
|
||||||
|
assert.deepEqual(lastWidget!.arguments, ["turn-limit", ["Turns: 0/5"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INT-1: unlimited → switch to 3 → boundary fires at turn 3", async () => {
|
||||||
|
const { pi, handlers, commands } = createMockPi();
|
||||||
|
initExtension(pi);
|
||||||
|
const { ctx } = createMockCtx({ confirmResult: true });
|
||||||
|
|
||||||
|
// Set unlimited, do turns
|
||||||
|
await commands["turn-limit"].handler("unlimited", ctx);
|
||||||
|
await handlers["agent_start"]({}, ctx);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
}
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 0);
|
||||||
|
|
||||||
|
// Switch to limit=3 → counter resets
|
||||||
|
await commands["turn-limit"].handler("3", ctx);
|
||||||
|
|
||||||
|
// Now 3 turns should trigger confirmation
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
await handlers["turn_start"]({}, ctx);
|
||||||
|
|
||||||
|
assert.equal(ctx.ui.confirm.mock.callCount(), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,13 +6,18 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
const DEFAULT_MAX_TURNS = 25;
|
const DEFAULT_MAX_TURNS = 25;
|
||||||
|
|
||||||
function getMaxTurns(): number {
|
export function getMaxTurns(): number {
|
||||||
const env = process.env.PI_MAX_TURNS;
|
const env = process.env.PI_MAX_TURNS;
|
||||||
if (!env) return DEFAULT_MAX_TURNS;
|
if (!env) return DEFAULT_MAX_TURNS;
|
||||||
|
if (env.trim().toLowerCase() === "unlimited") return Infinity;
|
||||||
const parsed = parseInt(env, 10);
|
const parsed = parseInt(env, 10);
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_TURNS;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_TURNS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMax(maxTurns: number): string {
|
||||||
|
return maxTurns === Infinity ? "∞" : String(maxTurns);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Pure detection logic (testable)
|
// Pure detection logic (testable)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -39,7 +44,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
pi.on("session_start", async (event, ctx) => {
|
pi.on("session_start", async (event, ctx) => {
|
||||||
// On reload, show the widget immediately
|
// On reload, show the widget immediately
|
||||||
if (event.reason === "reload" && ctx.hasUI) {
|
if (event.reason === "reload" && ctx.hasUI) {
|
||||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,7 +53,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
turnCount = 0;
|
turnCount = 0;
|
||||||
// Show initial widget state on fresh session
|
// Show initial widget state on fresh session
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,16 +62,26 @@ export default function (pi: ExtensionAPI) {
|
|||||||
handler: async (args: string, ctx) => {
|
handler: async (args: string, ctx) => {
|
||||||
const trimmed = args.trim();
|
const trimmed = args.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
ctx.ui.notify("Invalid turn limit. Must be a positive integer.", "error");
|
ctx.ui.notify("Invalid turn limit. Must be a positive integer or 'unlimited'.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed.toLowerCase() === "unlimited") {
|
||||||
|
maxTurns = Infinity;
|
||||||
|
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||||
|
ctx.ui.notify("Turn limit set to unlimited.", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parsed = parseInt(trimmed, 10);
|
const parsed = parseInt(trimmed, 10);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
ctx.ui.notify("Invalid turn limit. Must be a positive integer.", "error");
|
ctx.ui.notify("Invalid turn limit. Must be a positive integer or 'unlimited'.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const wasUnlimited = maxTurns === Infinity;
|
||||||
maxTurns = parsed;
|
maxTurns = parsed;
|
||||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
if (wasUnlimited) {
|
||||||
|
turnCount = 0;
|
||||||
|
}
|
||||||
|
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||||
ctx.ui.notify(`Turn limit set to ${parsed}.`, "info");
|
ctx.ui.notify(`Turn limit set to ${parsed}.`, "info");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -76,9 +91,12 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
// Update live widget
|
// Update live widget
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No boundary check when unlimited
|
||||||
|
if (maxTurns === Infinity) return;
|
||||||
|
|
||||||
// Boundary confirmation: when we hit maxTurns exactly
|
// Boundary confirmation: when we hit maxTurns exactly
|
||||||
if (turnCount === maxTurns) {
|
if (turnCount === maxTurns) {
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
@ -90,7 +108,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// Reset counter and let the turn proceed
|
// Reset counter and let the turn proceed
|
||||||
turnCount = 0;
|
turnCount = 0;
|
||||||
if (ctx.hasUI) {
|
if (ctx.hasUI) {
|
||||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
81
packages/pi-turn-limit/turn-limit.allium
Normal file
81
packages/pi-turn-limit/turn-limit.allium
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
-- allium: 3
|
||||||
|
-- turn-limit.allium
|
||||||
|
|
||||||
|
-- Scope: Agent turn limit enforcement per session
|
||||||
|
-- Includes: Turn counting, limit enforcement, session abort, disable/enable
|
||||||
|
-- Excludes:
|
||||||
|
-- - Widget display (UI implementation detail)
|
||||||
|
-- - Environment variable reading (configuration mechanism)
|
||||||
|
-- - turn-limit command (configuration mechanism)
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Entities
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
entity Session {
|
||||||
|
turn_count: Integer
|
||||||
|
status: active | aborted
|
||||||
|
user_confirms_continuation: Boolean?
|
||||||
|
|
||||||
|
transitions status {
|
||||||
|
active -> aborted
|
||||||
|
terminal: aborted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Config
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
config {
|
||||||
|
max_turns: Integer | unlimited = 25
|
||||||
|
|
||||||
|
@guidance
|
||||||
|
-- When max_turns is unlimited, no boundary check fires.
|
||||||
|
-- The turn counter still increments for observability.
|
||||||
|
-- Transitioning from unlimited to a positive integer
|
||||||
|
-- resets turn_count to 0.
|
||||||
|
}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
-- Rules
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
rule TurnLimitReached {
|
||||||
|
when: session: Session.turn_count transitions_to config.max_turns
|
||||||
|
requires:
|
||||||
|
config.max_turns != unlimited
|
||||||
|
session.turn_count = config.max_turns
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
if session.user_confirms_continuation:
|
||||||
|
session.turn_count = 0
|
||||||
|
else:
|
||||||
|
session.status = aborted
|
||||||
|
|
||||||
|
@guidance
|
||||||
|
-- The user_confirms_continuation field is set by the
|
||||||
|
-- implementation when presenting a confirmation prompt to
|
||||||
|
-- the user at the turn limit boundary. The implementation
|
||||||
|
-- may use a widget, dialog, or other mechanism to capture
|
||||||
|
-- the user's choice.
|
||||||
|
--
|
||||||
|
-- When the user confirms, the turn_count resets to zero,
|
||||||
|
-- allowing the agent to continue for another round of turns.
|
||||||
|
-- When the user declines (field is null or false), the
|
||||||
|
-- session is aborted.
|
||||||
|
--
|
||||||
|
-- Without a UI, the default behaviour is to abort.
|
||||||
|
}
|
||||||
|
|
||||||
|
rule LimitReEnabled {
|
||||||
|
when: config.max_turns transitions_to Integer
|
||||||
|
|
||||||
|
ensures:
|
||||||
|
session.turn_count = 0
|
||||||
|
|
||||||
|
@guidance
|
||||||
|
-- When the user switches from unlimited back to a positive
|
||||||
|
-- integer limit, the turn counter resets to zero so the
|
||||||
|
-- new limit applies from a clean starting point.
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user