pi-condense 2.9.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
7
7
  Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
8
8
  publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
9
9
 
10
+ ## [2.9.1] - 2026-08-18
11
+
12
+ - **Orphan sweep: any foreign message is now a barrier ([#11](https://github.com/jjuraszek/pi-condense/issues/11)).** `sweepOrphanToolResults` only reset its open-call set on `assistant` messages, while pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries (`convertToLlm` maps `custom`/`branchSummary`/`compactionSummary`/`bashExecution` to `user`). A foreign message spliced between a toolCall and its toolResult (observed: a pi-cohort control notice) let the real result through alongside pi-ai's synthetic - a duplicate `tool_use_id` the provider rejects permanently (Anthropic 400, branch bricked). Now any message that is neither `assistant` nor `toolResult` clears the open set, so the interleaved result is swept and pi-ai's repairable synthetic stands alone; already-broken branches un-brick on the next render. Deliberate conservative over-sweep (unknown roles, `excludeFromContext` bashExecution) - no role allowlist. Test helper `expectNoOrphanToolResults` carries the identical rule. Spec: `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md` (partially supersedes the 2026-08-12 spec's section C).
13
+
10
14
  ## [2.9.0] - 2026-08-14
11
15
 
12
16
  - **Uncovered chains now compress deterministically instead of stranding forever ([#10](https://github.com/jjuraszek/pi-condense/issues/10)).** Chain compression (Phase 3) required per-batch summary coverage; a closed eligible chain whose span produced no summaries (trivial batches, `skipped-oversized`, fully-deduped spans, capture misses) hit a permanent `no-summary` skip - once the prune frontier passed it, nothing could ever reclaim it. The motivating incident left a 639-call, ~935k-char chain (~62% of a 620k window) live in context indefinitely.
package/PRUNING.md CHANGED
@@ -1096,11 +1096,11 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
1096
1096
 
1097
1097
  ## Orphan Sweep
1098
1098
 
1099
- `pruneMessages` ends with an unconditional structural pass, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): a `toolResult` message whose id was not opened by the immediately preceding assistant turn is removed. It runs after every other phase, over whatever the previous three produced, as a final post-condition rather than a targeted fix for one code path.
1099
+ `pruneMessages` ends with an unconditional structural pass, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): a `toolResult` message whose id is not open is removed, where "open" means: opened by the most recent `assistant` message and not interrupted by any barrier since. It runs after every other phase, over whatever the previous three produced, as a final post-condition rather than a targeted fix for one code path.
1100
1100
 
1101
1101
  **Why it exists.** pi-ai's auto-repair (`insertSyntheticToolResults`) only fills in a missing `toolResult` for an orphaned `toolCall` - it has no equivalent repair for the opposite shape, an orphaned `toolResult` with no matching `toolCall`. Providers reject that shape outright (Anthropic: `unexpected tool_use_id found in tool_result blocks`; Kimi K3: a tool message needs a resolvable preceding tool_call). An orphaned `toolResult` can appear from any combination of id reuse, an unresolved chain range, or a bug in an upstream phase; the sweep is the last line of defense regardless of cause, and it is intentionally not keyed to any provider's specific error string - the fix is structural ("does this id have an open call"), not reactive to how one provider happens to phrase rejection.
1102
1102
 
1103
- **Per-turn, not cumulative, open-call tracking.** The sweep does one forward pass over the message array. Each `assistant` message **replaces** the current "open" id set with its own `toolCall` ids; each `toolResult` message either consumes (removes) a matching id from that set or, if its id is not open, is swept. A cumulative seen-set across the whole array would be wrong here: it would let an id used validly by an early turn license a *later* genuine orphan under the same reused id - exactly the collision scenario this exists to catch. Per-turn tracking means only the immediately preceding assistant turn can vouch for a `toolResult`'s id.
1103
+ **Per-turn, not cumulative, open-call tracking.** The sweep does one forward pass over the message array. Each `assistant` message **replaces** the current "open" id set with its own `toolCall` ids; each `toolResult` message either consumes (removes) a matching id from that set or, if its id is not open, is swept. Any message that is neither `assistant` nor `toolResult` is a **barrier** that clears the open set. This tracks the post-`convertToLlm` flush points of pi-ai's `insertSyntheticToolResults` - `convertToLlm` maps `custom`, `branchSummary`, `compactionSummary`, and `bashExecution` to `role: "user"`, and pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries - **plus** a deliberate conservative over-sweep: `bashExecution` with `excludeFromContext: true` and unknown roles are dropped by `convertToLlm` and strictly need not be barriers, but a role allowlist would have to track an open interface for zero observed benefit. Not an exact mirror, and not an allowlist. Trade-off: a foreign message spliced mid-cycle now costs one tool output - the interleaved real result is swept and pi-ai injects its repairable `{isError: true, "No result provided"}` synthetic - converting an unrepairable provider shape (duplicate `tool_use_id`, permanent Anthropic 400) into a visible-but-recoverable tool failure. See `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md`. A cumulative seen-set across the whole array would be wrong here: it would let an id used validly by an early turn license a *later* genuine orphan under the same reused id - exactly the collision scenario this exists to catch. Per-turn tracking means only the most recent assistant turn, uninterrupted by a barrier, can vouch for a `toolResult`'s id.
1104
1104
 
1105
1105
  **Provider-agnostic and reference-preserving.** The sweep has no knowledge of which provider is in play; it operates purely on message shape. When nothing is swept it returns the **same array reference** it was given, so a clean render is a true no-op and the no-op / prompt-cache-prefix invariant of `doc/specs/2026-08-04-pruner-noop-serialization.md` holds - the sweep never forces a cache bust on a session where nothing is actually orphaned.
1106
1106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -796,6 +796,13 @@ describe("applyChainCompressions - positional", () => {
796
796
  msgs[3] = { ...msgs[3], content: [{ type: "thinking", thinking: "hmm" }, { type: "text", text: "done 1" }] } as any;
797
797
  return applyChainCompressions(msgs, entries as any, summaryFor, true);
798
798
  }],
799
+ ["a context-prune-summary after a completed cycle", () => {
800
+ const msgs = incident();
801
+ msgs.splice(8, 0, { role: "custom", customType: CUSTOM_TYPE_SUMMARY, content: "batch summary", timestamp: 2250 } as any);
802
+ const out = applyChainCompressions(msgs, entries as any, summaryFor, false);
803
+ expect(out.some((m: any) => m.customType === CUSTOM_TYPE_SUMMARY)).toBe(true);
804
+ return out;
805
+ }],
799
806
  ["skips an entry whose start falls strictly inside another entry's range", () => {
800
807
  const wide = { blockId: "b8", startUserTimestamp: 1000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9002 };
801
808
  const nestedInner = { blockId: "b9", startUserTimestamp: 2000, droppedToolCallIds: [], finalAssistantTimestamp: 2200, toolRefs: [], compressedAt: 9003 };
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { sweepOrphanToolResults } from "./orphan-sweep.js";
3
+ import { expectNoOrphanToolResults } from "./test-support.js";
3
4
 
4
5
  const asst = (ts: number, ids: string[]) => ({
5
6
  role: "assistant",
@@ -60,8 +61,47 @@ describe("sweepOrphanToolResults", () => {
60
61
  expect(sweepOrphanToolResults(msgs).messages).toBe(msgs);
61
62
  });
62
63
 
63
- test("non-assistant messages between a call and its result do not close the open set", () => {
64
- const msgs = [asst(1, ["a"]), { role: "custom", customType: "x", timestamp: 2 }, res(3, "a")];
65
- expect(sweepOrphanToolResults(msgs).messages).toBe(msgs);
64
+ // AC1: any non-assistant/non-toolResult role is a barrier - including an
65
+ // unknown role, so a role-allowlist implementation cannot pass.
66
+ const barrierRoles: Array<[string, any]> = [
67
+ ["custom", { role: "custom", customType: "x", timestamp: 2 }],
68
+ ["user", user(2)],
69
+ ["branchSummary", { role: "branchSummary", timestamp: 2 }],
70
+ ["compactionSummary", { role: "compactionSummary", timestamp: 2 }],
71
+ ["bashExecution", { role: "bashExecution", timestamp: 2 }],
72
+ ["unknown future role", { role: "future-role", timestamp: 2 }],
73
+ ];
74
+ for (const [name, barrier] of barrierRoles) {
75
+ test(`a ${name} message between a call and its result is a barrier: the result is swept`, () => {
76
+ const msgs = [asst(1, ["a"]), barrier, res(3, "a")];
77
+ const out = sweepOrphanToolResults(msgs);
78
+ expect(out.sweptIds).toEqual(["a"]);
79
+ expect(out.messages).toHaveLength(2);
80
+ expect(out.messages.some((m: any) => m.role === "toolResult")).toBe(false);
81
+ });
82
+ }
83
+
84
+ // AC2: barrier clears only what is still open; results consumed before it stay.
85
+ test("multi-call turn: barrier sweeps only the not-yet-consumed result", () => {
86
+ const msgs = [asst(1, ["a", "b"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }, res(4, "b")];
87
+ const out = sweepOrphanToolResults(msgs);
88
+ expect(out.sweptIds).toEqual(["b"]);
89
+ expect(out.messages).toHaveLength(3);
90
+ });
91
+
92
+ // AC3: a barrier after a completed cycle is untouched - same array reference.
93
+ test("trailing barrier after a completed cycle is a no-op (same array reference)", () => {
94
+ const msgs = [asst(1, ["a"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }];
95
+ const out = sweepOrphanToolResults(msgs);
96
+ expect(out.messages).toBe(msgs);
97
+ expect(out.sweptIds).toEqual([]);
98
+ });
99
+
100
+ // AC5: the test helper enforces the same barrier rule (it copies the sweep's
101
+ // orphan definition; delegation would make sweep tests tautological).
102
+ test("expectNoOrphanToolResults throws on a mid-cycle barrier orphan and passes on a clean trailing barrier", () => {
103
+ const bad = [asst(1, ["a"]), { role: "custom", customType: "x", timestamp: 2 }, res(3, "a")];
104
+ expect(() => expectNoOrphanToolResults(bad)).toThrow();
105
+ expectNoOrphanToolResults([asst(1, ["a"]), res(2, "a"), { role: "custom", customType: "x", timestamp: 3 }]);
66
106
  });
67
107
  });
@@ -2,7 +2,9 @@
2
2
  * pi-ai repairs orphan tool calls only; providers reject orphan tool results.
3
3
  *
4
4
  * Open-call tracking is PER TURN: an assistant message replaces the open set
5
- * with its own toolCall ids. A cumulative seen-set would let an id used
5
+ * with its own toolCall ids, and any message that is neither assistant nor
6
+ * toolResult is a barrier that clears it (matching where pi-ai flushes
7
+ * synthetic tool results). A cumulative seen-set would let an id used
6
8
  * validly in an early turn license a later genuine orphan - exactly the
7
9
  * id-collision case this exists for.
8
10
  *
@@ -32,7 +34,15 @@ export function sweepOrphanToolResults(messages: any[]): { messages: any[]; swep
32
34
  orphanIndices.add(i);
33
35
  sweptIds.push(msg.toolCallId);
34
36
  }
37
+ continue;
35
38
  }
39
+ // Barrier: any other role converts to a user-boundary at the provider
40
+ // (convertToLlm maps custom/branchSummary/compactionSummary/bashExecution
41
+ // to role "user"; pi-ai flushes synthetic tool results there), so a
42
+ // still-open call can no longer be legally answered after it. Unknown
43
+ // roles and excludeFromContext bashExecutions are a deliberate
44
+ // conservative over-sweep - no allowlist.
45
+ open = new Set();
36
46
  }
37
47
 
38
48
  if (orphanIndices.size === 0) return { messages, sweptIds: [] };
package/src/pruner.ts CHANGED
@@ -47,10 +47,11 @@ export function sizeMessages(messages: any[]): number {
47
47
  * Only runs when `chainCompression.enabled` and chain entries exist.
48
48
  *
49
49
  * Phase 4 — orphan sweep: structural post-condition run unconditionally over
50
- * the final array. Removes any toolResult whose matching toolCall id was not
51
- * opened by the immediately preceding assistant turn (per-turn open set, not
52
- * cumulative see src/orphan-sweep.ts). Reference-preserving when nothing is
53
- * swept, so a clean render still returns the identical input array.
50
+ * the final array. Removes any toolResult whose matching toolCall id is not
51
+ * open: opened by the most recent assistant turn and uninterrupted by a
52
+ * barrier (any non-assistant/non-toolResult message) see
53
+ * src/orphan-sweep.ts. Reference-preserving when nothing is swept, so a
54
+ * clean render still returns the identical input array.
54
55
  *
55
56
  * Return shape:
56
57
  * - `pruned: true` — at least one change happened; the returned
@@ -12,6 +12,8 @@ export function expectNoOrphanToolResults(messages: any[]): void {
12
12
  } else if (m.role === "toolResult") {
13
13
  expect(open.has(m.toolCallId)).toBe(true);
14
14
  open.delete(m.toolCallId);
15
+ } else {
16
+ open = new Set();
15
17
  }
16
18
  }
17
19
  }