Compare commits
No commits in common. "fd0c34372974e466da34ab25055426eff1c8e795" and "cab445e60346bd57ea0999af71555fdb29db28fd" have entirely different histories.
fd0c343729
...
cab445e603
@ -1,820 +0,0 @@
|
||||
|
||||
╭─── 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
|
||||
@ -1,484 +0,0 @@
|
||||
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,18 +6,13 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const DEFAULT_MAX_TURNS = 25;
|
||||
|
||||
export function getMaxTurns(): number {
|
||||
function getMaxTurns(): number {
|
||||
const env = process.env.PI_MAX_TURNS;
|
||||
if (!env) return DEFAULT_MAX_TURNS;
|
||||
if (env.trim().toLowerCase() === "unlimited") return Infinity;
|
||||
const parsed = parseInt(env, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_TURNS;
|
||||
}
|
||||
|
||||
function formatMax(maxTurns: number): string {
|
||||
return maxTurns === Infinity ? "∞" : String(maxTurns);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pure detection logic (testable)
|
||||
// ============================================================================
|
||||
@ -44,7 +39,7 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
// On reload, show the widget immediately
|
||||
if (event.reason === "reload" && ctx.hasUI) {
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -53,7 +48,7 @@ export default function (pi: ExtensionAPI) {
|
||||
turnCount = 0;
|
||||
// Show initial widget state on fresh session
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -62,26 +57,16 @@ export default function (pi: ExtensionAPI) {
|
||||
handler: async (args: string, ctx) => {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) {
|
||||
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");
|
||||
ctx.ui.notify("Invalid turn limit. Must be a positive integer.", "error");
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
ctx.ui.notify("Invalid turn limit. Must be a positive integer or 'unlimited'.", "error");
|
||||
ctx.ui.notify("Invalid turn limit. Must be a positive integer.", "error");
|
||||
return;
|
||||
}
|
||||
const wasUnlimited = maxTurns === Infinity;
|
||||
maxTurns = parsed;
|
||||
if (wasUnlimited) {
|
||||
turnCount = 0;
|
||||
}
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
||||
ctx.ui.notify(`Turn limit set to ${parsed}.`, "info");
|
||||
},
|
||||
});
|
||||
@ -91,12 +76,9 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
// Update live widget
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
||||
}
|
||||
|
||||
// No boundary check when unlimited
|
||||
if (maxTurns === Infinity) return;
|
||||
|
||||
// Boundary confirmation: when we hit maxTurns exactly
|
||||
if (turnCount === maxTurns) {
|
||||
if (ctx.hasUI) {
|
||||
@ -108,7 +90,7 @@ export default function (pi: ExtensionAPI) {
|
||||
// Reset counter and let the turn proceed
|
||||
turnCount = 0;
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${formatMax(maxTurns)}`]);
|
||||
ctx.ui.setWidget("turn-limit", [`Turns: ${turnCount}/${maxTurns}`]);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
-- 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