From ad03f8a840094c4c9c9b47e3ff340c442c8e10ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=98=89=E4=BC=9F?= <202383014@uibe.edu.cn> Date: Sun, 11 Oct 2026 21:24:57 +0800 Subject: [PATCH 1/3] fix(context-pivot): reset pending state on session start --- extensions/context-pivot/index.ts | 3 ++ tests/extensions/context-pivot/index.test.ts | 47 ++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/extensions/context-pivot/index.ts b/extensions/context-pivot/index.ts index 5df4b2209..84f0af4fb 100644 --- a/extensions/context-pivot/index.ts +++ b/extensions/context-pivot/index.ts @@ -102,6 +102,9 @@ export default function contextPivot(pi: ExtensionAPI) { }; pi.on("session_start", () => { + generation += 1; + pending = undefined; + compacting = false; setVisibleForContext(); }); diff --git a/tests/extensions/context-pivot/index.test.ts b/tests/extensions/context-pivot/index.test.ts index b65768eb8..c1936b88c 100644 --- a/tests/extensions/context-pivot/index.test.ts +++ b/tests/extensions/context-pivot/index.test.ts @@ -210,3 +210,50 @@ test("reports native no-history failures without a UI", async () => { "Context pivot could not run: this session has no discardable history to compact. Continue in the current session, or use /sessions to choose another session; to begin cleanly, start a new Session in Pi.", ]); }); + +test("a new session can start a pivot while the previous session is pending", async () => { + let tool: ToolDefinition | undefined; + const handlers = new Map unknown>(); + const pi = { + registerTool(candidate: ToolDefinition) { + tool = candidate; + }, + getActiveTools: () => [], + setActiveTools() {}, + on(event: string, handler: (...args: unknown[]) => unknown) { + handlers.set(event, handler); + }, + registerCommand() {}, + } as unknown as ExtensionAPI; + const ctx = { + hasUI: false, + getContextUsage: () => ({ + tokens: MIN_CONTEXT_PIVOT_TOKENS, + contextWindow: 200_000, + percent: 15, + }), + compact() {}, + } as unknown as ExtensionContext; + + contextPivot(pi); + assert.ok(tool); + await tool.execute( + "first-session-pivot", + { brief: "First session pivot." }, + undefined, + undefined, + ctx, + ); + + handlers.get("session_start")?.({}, ctx); + + await assert.doesNotReject( + tool.execute( + "second-session-pivot", + { brief: "Second session pivot." }, + undefined, + undefined, + ctx, + ), + ); +}); From 4b0a50a293f759bcfa9257ded629f07e77f05784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=98=89=E4=BC=9F?= <202383014@uibe.edu.cn> Date: Sun, 11 Oct 2026 21:53:30 +0800 Subject: [PATCH 2/3] test(context-pivot): use tool execution context fixture --- tests/extensions/context-pivot/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/extensions/context-pivot/index.test.ts b/tests/extensions/context-pivot/index.test.ts index c1936b88c..f86ff2a24 100644 --- a/tests/extensions/context-pivot/index.test.ts +++ b/tests/extensions/context-pivot/index.test.ts @@ -242,7 +242,7 @@ test("a new session can start a pivot while the previous session is pending", as { brief: "First session pivot." }, undefined, undefined, - ctx, + toolExecutionContext(ctx), ); handlers.get("session_start")?.({}, ctx); @@ -253,7 +253,7 @@ test("a new session can start a pivot while the previous session is pending", as { brief: "Second session pivot." }, undefined, undefined, - ctx, + toolExecutionContext(ctx), ), ); }); From 65b0e14c05b5f228dc2828e1456744be1f25ff52 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Thu, 15 Oct 2026 15:05:41 +0800 Subject: [PATCH 3/3] fix(context-pivot): isolate replacement Session projections --- extensions/context-pivot/index.ts | 4 +- tests/extensions/context-pivot/index.test.ts | 105 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/extensions/context-pivot/index.ts b/extensions/context-pivot/index.ts index c35264d1f..ce031355d 100644 --- a/extensions/context-pivot/index.ts +++ b/extensions/context-pivot/index.ts @@ -106,6 +106,7 @@ export default function contextPivot(pi: ExtensionAPI) { pending = undefined; compacting = false; setVisibleForContext(ctx); + if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined); }); pi.on("agent_start", (_event, ctx) => { @@ -187,7 +188,8 @@ export default function contextPivot(pi: ExtensionAPI) { pi.sendUserMessage("Continue with the next phase now."); }, onError: (error) => { - if (pivotGeneration === generation) pending = undefined; + if (pivotGeneration !== generation) return; + pending = undefined; clear(); const message = formatContextPivotError(error); if (ctx.hasUI) { diff --git a/tests/extensions/context-pivot/index.test.ts b/tests/extensions/context-pivot/index.test.ts index f8831eb61..41a1367d3 100644 --- a/tests/extensions/context-pivot/index.test.ts +++ b/tests/extensions/context-pivot/index.test.ts @@ -270,3 +270,108 @@ test("a new session can start a pivot while the previous session is pending", as ), ); }); + +for (const hasUI of [true, false]) { + test(`replacement isolates stale pivot callbacks from the ${hasUI ? "UI" : "headless"} Session`, async () => { + let tool: ToolDefinition | undefined; + const handlers = new Map unknown>(); + const callbacks: CompactOptions[] = []; + const notifications: Array<{ message: string; level: string }> = []; + const statuses: Array = []; + const sent: string[] = []; + const errors: string[] = []; + const pi = { + registerTool(candidate: ToolDefinition) { + tool = candidate; + }, + getActiveTools: () => [], + setActiveTools() {}, + on(event: string, handler: (...args: unknown[]) => unknown) { + handlers.set(event, handler); + }, + registerCommand() {}, + sendUserMessage(message: string) { + sent.push(message); + }, + } as unknown as ExtensionAPI; + const ctx = { + hasUI, + getContextUsage: () => ({ tokens: MIN_CONTEXT_PIVOT_TOKENS }), + compact(options: CompactOptions) { + callbacks.push(options); + }, + ui: { + notify(message: string, level: string) { + notifications.push({ message, level }); + }, + setStatus(_key: string, value: string | undefined) { + statuses.push(value); + }, + theme: { fg: (_color: string, text: string) => text }, + }, + } as unknown as ExtensionContext; + contextPivot(pi); + assert.ok(tool); + const pivotTool = tool; + const execute = (brief: string) => + pivotTool.execute( + brief, + { brief }, + undefined, + undefined, + toolExecutionContext(ctx), + ); + const originalError = console.error; + console.error = (message: unknown) => errors.push(String(message)); + try { + await execute("Old Session A"); + if (hasUI) assert.match(statuses.at(-1)!, /pivoting/); + handlers.get("session_start")?.({}, ctx); + if (hasUI) assert.equal(statuses.at(-1), undefined); + await execute("New Session B"); + const currentStatusCount = statuses.length; + const compactionResult = { + summary: "Native compaction result", + firstKeptEntryId: "entry", + tokensBefore: MIN_CONTEXT_PIVOT_TOKENS, + }; + callbacks[0]?.onComplete?.(compactionResult); + callbacks[0]?.onError?.(new Error("stale old provider failure")); + assert.deepEqual(sent, []); + assert.equal(notifications.length, 0); + assert.deepEqual(errors, []); + assert.equal(statuses.length, currentStatusCount); + await assert.rejects(execute("Concurrent pivot"), /already in progress/); + const compacted = handlers.get("session_before_compact")?.({ + branchEntries: [], + preparation: { tokensBefore: MIN_CONTEXT_PIVOT_TOKENS }, + }) as { compaction: { summary: string } }; + assert.equal( + compacted.compaction.summary, + buildPivotSummary("New Session B"), + ); + callbacks[1]?.onComplete?.(compactionResult); + assert.deepEqual(sent, ["Continue with the next phase now."]); + if (hasUI) { + assert.equal(statuses.at(-1), undefined); + assert.equal(notifications.at(-1)?.level, "info"); + } + await execute("Current failure"); + callbacks[2]?.onError?.(new Error("current provider failure")); + if (hasUI) { + assert.equal(statuses.at(-1), undefined); + assert.deepEqual(notifications.at(-1), { + message: "Context pivot failed: current provider failure", + level: "error", + }); + } else { + assert.deepEqual(errors, [ + "Context pivot failed: current provider failure", + ]); + } + await assert.doesNotReject(execute("Retry after current failure")); + } finally { + console.error = originalError; + } + }); +}