-
Notifications
You must be signed in to change notification settings - Fork 91
fix(compaction): allow manual recovery on SDK-owned sessions #1423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6534279
6560c99
ad42967
261662a
b58311c
291fa3a
f1a1386
922b5e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -215,17 +215,23 @@ describe("claude-sdk-oauth lane: senpi compaction stands down", () => { | |
| expect(harness.registration.state.callCount).toBe(0); | ||
| }); | ||
|
|
||
| it("cancels a requested senpi compaction with the lane reason", async () => { | ||
| const harness = createHarness({ provider: "claude-sdk-oauth" }); | ||
|
|
||
| const result = await harness.sessionBeforeCompact(beforeCompactEvent(), harness.ctx); | ||
|
|
||
| expect(result).toMatchObject({ | ||
| cancel: true, | ||
| reason: SDK_NATIVE_LANE_REJECTION_REASON, | ||
| rejectionCause: "external-owner", | ||
| }); | ||
| }); | ||
| it.each(["threshold", "overflow", "pre_prompt"] as const)( | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: They still cancel correctly today, so this is not blocking — but the helper this feeds ( |
||
| "cancels automatic %s compaction with the lane reason", | ||
| async (reason) => { | ||
| const harness = createHarness({ provider: "claude-sdk-oauth" }); | ||
|
|
||
| const result = await harness.sessionBeforeCompact( | ||
| { ...beforeCompactEvent(), reason, willRetry: reason === "overflow" }, | ||
| harness.ctx, | ||
| ); | ||
|
|
||
| expect(result).toMatchObject({ | ||
| cancel: true, | ||
| reason: SDK_NATIVE_LANE_REJECTION_REASON, | ||
| rejectionCause: "external-owner", | ||
| }); | ||
| }, | ||
| ); | ||
|
|
||
| it("leaves context messages untouched while the same load reduces them for other providers", () => { | ||
| const reductionMessages = () => [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { fauxAssistantMessage } from "@earendil-works/pi-ai"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import compactionExtension from "../../src/core/extensions/builtin/compaction/index.ts"; | ||
| import { ModelUsabilityBudgetError } from "../../src/core/extensions/builtin/compaction/model-usability-budget.ts"; | ||
| import { SessionManager } from "../../src/core/session-manager.ts"; | ||
| import { createHarness, type Harness } from "./harness.ts"; | ||
|
|
||
| const harnesses: Harness[] = []; | ||
| afterEach(() => { | ||
| for (const harness of harnesses.splice(0)) harness.cleanup(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| /** | ||
| * `claude-sdk-oauth` is the regression case: it fails without the manual ownership | ||
| * exemption, with `rejectionCause: "external-owner"`. `faux` is a deliberate control | ||
| * for the lane the exemption must NOT change - it reaches the same persisted-summary | ||
| * outcome through the ordinary senpi-owned path, and it fails if the shared | ||
| * `ownsCompaction` predicate ever regresses the non-SDK branch. | ||
| */ | ||
| describe("explicit compaction recovers a rejected model downswitch", () => { | ||
| it.each(["claude-sdk-oauth", "faux"])("persists a usable manual summary on %s", async (provider) => { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BLOCKING: the The |
||
| // Given the real builtin, no compaction-model override, and an oversized live transcript. | ||
| vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: freezing |
||
| const harness = await createHarness({ | ||
| provider, | ||
| models: [ | ||
| { id: "million", contextWindow: 1_000_000, maxTokens: 32_000 }, | ||
| { id: "target", contextWindow: 272_000, maxTokens: 32_000 }, | ||
| ], | ||
| settings: { compaction: { enabled: true, keepRecentTokens: 1 } }, | ||
| extensionFactories: [compactionExtension], | ||
| persistSession: true, | ||
| }); | ||
| harnesses.push(harness); | ||
| const source = harness.getModel(); | ||
| const target = harness.getModel("target"); | ||
| if (!target) throw new Error("missing target fixture"); | ||
| harness.sessionManager.appendMessage({ | ||
| role: "user", | ||
| content: [{ type: "text", text: "historical context ".repeat(30_000) }], | ||
| timestamp: 1, | ||
| }); | ||
| harness.sessionManager.appendMessage({ | ||
| ...fauxAssistantMessage("historical response", { timestamp: 2 }), | ||
| api: source.api, | ||
| provider, | ||
| model: source.id, | ||
| usage: { | ||
| input: 845_096, | ||
| output: 0, | ||
| cacheRead: 0, | ||
| cacheWrite: 0, | ||
| totalTokens: 845_096, | ||
| cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, | ||
| }, | ||
| }); | ||
| const keptEntryId = harness.sessionManager.appendMessage({ | ||
| role: "user", | ||
| content: [{ type: "text", text: "KEEP_AFTER_COMPACTION" }], | ||
| timestamp: 3, | ||
| }); | ||
| harness.agent.state.messages = harness.sessionManager.buildSessionContext().messages; | ||
| await expect(harness.session.setModel(target)).rejects.toMatchObject({ | ||
| name: ModelUsabilityBudgetError.name, | ||
| projection: { usable: false, contextWindow: 272_000 }, | ||
| }); | ||
| expect(harness.session.model?.id).toBe("million"); | ||
| expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toEqual([]); | ||
| const summary = "MANUAL_COMPACTION_RECOVERY_SUMMARY"; | ||
| harness.setResponses([fauxAssistantMessage(summary, { timestamp: 4 })]); | ||
|
|
||
| // When explicit /compact's session entry point runs, the harness is already subscribed | ||
| // to lifecycle events. Await its terminal promise, not elapsed time or polling. | ||
| const outcome = await harness.session.compact().then( | ||
| (result) => ({ status: "compacted" as const, result }), | ||
| (error: unknown) => ({ status: "rejected" as const, error }), | ||
| ); | ||
|
|
||
| // Then cancellation is not recovery: require an actual result and persisted usable history. | ||
| expect(outcome, JSON.stringify(harness.eventsOfType("compaction_end"))).toMatchObject({ status: "compacted" }); | ||
| if (outcome.status !== "compacted") throw outcome.error; | ||
| expect(outcome.result).toMatchObject({ summary, firstKeptEntryId: keptEntryId }); | ||
| expect(harness.eventsOfType("compaction_end")).toEqual([ | ||
| expect.objectContaining({ reason: "manual", accepted: true, aborted: false }), | ||
| ]); | ||
| const sessionFile = harness.sessionManager.getSessionFile(); | ||
| if (!sessionFile) throw new Error("missing persisted session fixture"); | ||
| const reopened = SessionManager.open(sessionFile); | ||
| expect(reopened.getEntries().filter((entry) => entry.type === "compaction")).toEqual([ | ||
| expect.objectContaining({ summary, firstKeptEntryId: keptEntryId }), | ||
| ]); | ||
| expect(reopened.buildSessionContext().messages).toEqual(harness.session.messages); | ||
| expect(harness.session.messages).toEqual([ | ||
| expect.objectContaining({ role: "compactionSummary", summary }), | ||
| expect.objectContaining({ role: "user", content: [{ type: "text", text: "KEEP_AFTER_COMPACTION" }] }), | ||
| ]); | ||
| await harness.session.setModel(target); | ||
| expect(harness.session.model?.id).toBe("target"); | ||
| expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "model_change")).toEqual([ | ||
| expect.objectContaining({ provider, modelId: "target" }), | ||
| ]); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BLOCKING (doc accuracy): this says automatic lanes "remain SDK-owned", which is true, but omits that the manual lane being opened has no breaker and no cap (
circuit-breaker.ts:59,per-turn-cap.ts:23). Document the guard situation for the path you are enabling, not only the ones you left alone.