@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `/tree` cancellation via `session_before_tree` leaving the session stuck in compaction state ([#3688](https://github.com/badlogic/pi-mono/issues/3688))
|
||||||
- Fixed Escape interrupt handling when extensions hide the built-in working loader row ([#3674](https://github.com/badlogic/pi-mono/issues/3674))
|
- Fixed Escape interrupt handling when extensions hide the built-in working loader row ([#3674](https://github.com/badlogic/pi-mono/issues/3674))
|
||||||
- Fixed coding-agent test expectations for current default models and missing-auth guidance.
|
- Fixed coding-agent test expectations for current default models and missing-auth guidance.
|
||||||
|
|
||||||
|
|||||||
@@ -2711,136 +2711,144 @@ export class AgentSession {
|
|||||||
|
|
||||||
// Set up abort controller for summarization
|
// Set up abort controller for summarization
|
||||||
this._branchSummaryAbortController = new AbortController();
|
this._branchSummaryAbortController = new AbortController();
|
||||||
let extensionSummary: { summary: string; details?: unknown } | undefined;
|
|
||||||
let fromExtension = false;
|
|
||||||
|
|
||||||
// Emit session_before_tree event
|
try {
|
||||||
if (this._extensionRunner.hasHandlers("session_before_tree")) {
|
let extensionSummary: { summary: string; details?: unknown } | undefined;
|
||||||
const result = (await this._extensionRunner.emit({
|
let fromExtension = false;
|
||||||
type: "session_before_tree",
|
|
||||||
preparation,
|
|
||||||
signal: this._branchSummaryAbortController.signal,
|
|
||||||
})) as SessionBeforeTreeResult | undefined;
|
|
||||||
|
|
||||||
if (result?.cancel) {
|
// Emit session_before_tree event
|
||||||
return { cancelled: true };
|
if (this._extensionRunner.hasHandlers("session_before_tree")) {
|
||||||
|
const result = (await this._extensionRunner.emit({
|
||||||
|
type: "session_before_tree",
|
||||||
|
preparation,
|
||||||
|
signal: this._branchSummaryAbortController.signal,
|
||||||
|
})) as SessionBeforeTreeResult | undefined;
|
||||||
|
|
||||||
|
if (result?.cancel) {
|
||||||
|
return { cancelled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.summary && options.summarize) {
|
||||||
|
extensionSummary = result.summary;
|
||||||
|
fromExtension = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow extensions to override instructions and label
|
||||||
|
if (result?.customInstructions !== undefined) {
|
||||||
|
customInstructions = result.customInstructions;
|
||||||
|
}
|
||||||
|
if (result?.replaceInstructions !== undefined) {
|
||||||
|
replaceInstructions = result.replaceInstructions;
|
||||||
|
}
|
||||||
|
if (result?.label !== undefined) {
|
||||||
|
label = result.label;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result?.summary && options.summarize) {
|
// Run default summarizer if needed
|
||||||
extensionSummary = result.summary;
|
let summaryText: string | undefined;
|
||||||
fromExtension = true;
|
let summaryDetails: unknown;
|
||||||
|
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
|
||||||
|
const model = this.model!;
|
||||||
|
const { apiKey, headers } = await this._getRequiredRequestAuth(model);
|
||||||
|
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
|
||||||
|
const result = await generateBranchSummary(entriesToSummarize, {
|
||||||
|
model,
|
||||||
|
apiKey,
|
||||||
|
headers,
|
||||||
|
signal: this._branchSummaryAbortController.signal,
|
||||||
|
customInstructions,
|
||||||
|
replaceInstructions,
|
||||||
|
reserveTokens: branchSummarySettings.reserveTokens,
|
||||||
|
});
|
||||||
|
if (result.aborted) {
|
||||||
|
return { cancelled: true, aborted: true };
|
||||||
|
}
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
summaryText = result.summary;
|
||||||
|
summaryDetails = {
|
||||||
|
readFiles: result.readFiles || [],
|
||||||
|
modifiedFiles: result.modifiedFiles || [],
|
||||||
|
};
|
||||||
|
} else if (extensionSummary) {
|
||||||
|
summaryText = extensionSummary.summary;
|
||||||
|
summaryDetails = extensionSummary.details;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow extensions to override instructions and label
|
// Determine the new leaf position based on target type
|
||||||
if (result?.customInstructions !== undefined) {
|
let newLeafId: string | null;
|
||||||
customInstructions = result.customInstructions;
|
let editorText: string | undefined;
|
||||||
}
|
|
||||||
if (result?.replaceInstructions !== undefined) {
|
|
||||||
replaceInstructions = result.replaceInstructions;
|
|
||||||
}
|
|
||||||
if (result?.label !== undefined) {
|
|
||||||
label = result.label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run default summarizer if needed
|
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
||||||
let summaryText: string | undefined;
|
// User message: leaf = parent (null if root), text goes to editor
|
||||||
let summaryDetails: unknown;
|
newLeafId = targetEntry.parentId;
|
||||||
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
|
editorText = this._extractUserMessageText(targetEntry.message.content);
|
||||||
const model = this.model!;
|
} else if (targetEntry.type === "custom_message") {
|
||||||
const { apiKey, headers } = await this._getRequiredRequestAuth(model);
|
// Custom message: leaf = parent (null if root), text goes to editor
|
||||||
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
|
newLeafId = targetEntry.parentId;
|
||||||
const result = await generateBranchSummary(entriesToSummarize, {
|
editorText =
|
||||||
model,
|
typeof targetEntry.content === "string"
|
||||||
apiKey,
|
? targetEntry.content
|
||||||
headers,
|
: targetEntry.content
|
||||||
signal: this._branchSummaryAbortController.signal,
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
customInstructions,
|
.map((c) => c.text)
|
||||||
replaceInstructions,
|
.join("");
|
||||||
reserveTokens: branchSummarySettings.reserveTokens,
|
} else {
|
||||||
|
// Non-user message: leaf = selected node
|
||||||
|
newLeafId = targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch leaf (with or without summary)
|
||||||
|
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
||||||
|
let summaryEntry: BranchSummaryEntry | undefined;
|
||||||
|
if (summaryText) {
|
||||||
|
// Create summary at target position (can be null for root)
|
||||||
|
const summaryId = this.sessionManager.branchWithSummary(
|
||||||
|
newLeafId,
|
||||||
|
summaryText,
|
||||||
|
summaryDetails,
|
||||||
|
fromExtension,
|
||||||
|
);
|
||||||
|
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
||||||
|
|
||||||
|
// Attach label to the summary entry
|
||||||
|
if (label) {
|
||||||
|
this.sessionManager.appendLabelChange(summaryId, label);
|
||||||
|
}
|
||||||
|
} else if (newLeafId === null) {
|
||||||
|
// No summary, navigating to root - reset leaf
|
||||||
|
this.sessionManager.resetLeaf();
|
||||||
|
} else {
|
||||||
|
// No summary, navigating to non-root
|
||||||
|
this.sessionManager.branch(newLeafId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach label to target entry when not summarizing (no summary entry to label)
|
||||||
|
if (label && !summaryText) {
|
||||||
|
this.sessionManager.appendLabelChange(targetId, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update agent state
|
||||||
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
|
this.agent.state.messages = sessionContext.messages;
|
||||||
|
|
||||||
|
// Emit session_tree event
|
||||||
|
await this._extensionRunner.emit({
|
||||||
|
type: "session_tree",
|
||||||
|
newLeafId: this.sessionManager.getLeafId(),
|
||||||
|
oldLeafId,
|
||||||
|
summaryEntry,
|
||||||
|
fromExtension: summaryText ? fromExtension : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit to custom tools
|
||||||
|
|
||||||
|
return { editorText, cancelled: false, summaryEntry };
|
||||||
|
} finally {
|
||||||
this._branchSummaryAbortController = undefined;
|
this._branchSummaryAbortController = undefined;
|
||||||
if (result.aborted) {
|
|
||||||
return { cancelled: true, aborted: true };
|
|
||||||
}
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
summaryText = result.summary;
|
|
||||||
summaryDetails = {
|
|
||||||
readFiles: result.readFiles || [],
|
|
||||||
modifiedFiles: result.modifiedFiles || [],
|
|
||||||
};
|
|
||||||
} else if (extensionSummary) {
|
|
||||||
summaryText = extensionSummary.summary;
|
|
||||||
summaryDetails = extensionSummary.details;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the new leaf position based on target type
|
|
||||||
let newLeafId: string | null;
|
|
||||||
let editorText: string | undefined;
|
|
||||||
|
|
||||||
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
||||||
// User message: leaf = parent (null if root), text goes to editor
|
|
||||||
newLeafId = targetEntry.parentId;
|
|
||||||
editorText = this._extractUserMessageText(targetEntry.message.content);
|
|
||||||
} else if (targetEntry.type === "custom_message") {
|
|
||||||
// Custom message: leaf = parent (null if root), text goes to editor
|
|
||||||
newLeafId = targetEntry.parentId;
|
|
||||||
editorText =
|
|
||||||
typeof targetEntry.content === "string"
|
|
||||||
? targetEntry.content
|
|
||||||
: targetEntry.content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join("");
|
|
||||||
} else {
|
|
||||||
// Non-user message: leaf = selected node
|
|
||||||
newLeafId = targetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch leaf (with or without summary)
|
|
||||||
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
||||||
let summaryEntry: BranchSummaryEntry | undefined;
|
|
||||||
if (summaryText) {
|
|
||||||
// Create summary at target position (can be null for root)
|
|
||||||
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
|
|
||||||
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
|
||||||
|
|
||||||
// Attach label to the summary entry
|
|
||||||
if (label) {
|
|
||||||
this.sessionManager.appendLabelChange(summaryId, label);
|
|
||||||
}
|
|
||||||
} else if (newLeafId === null) {
|
|
||||||
// No summary, navigating to root - reset leaf
|
|
||||||
this.sessionManager.resetLeaf();
|
|
||||||
} else {
|
|
||||||
// No summary, navigating to non-root
|
|
||||||
this.sessionManager.branch(newLeafId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach label to target entry when not summarizing (no summary entry to label)
|
|
||||||
if (label && !summaryText) {
|
|
||||||
this.sessionManager.appendLabelChange(targetId, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update agent state
|
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
|
||||||
this.agent.state.messages = sessionContext.messages;
|
|
||||||
|
|
||||||
// Emit session_tree event
|
|
||||||
await this._extensionRunner.emit({
|
|
||||||
type: "session_tree",
|
|
||||||
newLeafId: this.sessionManager.getLeafId(),
|
|
||||||
oldLeafId,
|
|
||||||
summaryEntry,
|
|
||||||
fromExtension: summaryText ? fromExtension : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit to custom tools
|
|
||||||
|
|
||||||
this._branchSummaryAbortController = undefined;
|
|
||||||
return { editorText, cancelled: false, summaryEntry };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { assistantMsg, userMsg } from "../../utilities.js";
|
||||||
|
import { createHarness, type Harness } from "../harness.js";
|
||||||
|
|
||||||
|
describe("issue #3688 tree cancellation compaction state", () => {
|
||||||
|
const harnesses: Harness[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (harnesses.length > 0) {
|
||||||
|
harnesses.pop()?.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears branch summary state when session_before_tree cancels navigation", async () => {
|
||||||
|
const harness = await createHarness({
|
||||||
|
extensionFactories: [
|
||||||
|
(pi) => {
|
||||||
|
pi.on("session_before_tree", () => ({ cancel: true }));
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
harnesses.push(harness);
|
||||||
|
|
||||||
|
const targetId = harness.sessionManager.appendMessage(userMsg("first"));
|
||||||
|
harness.sessionManager.appendMessage(assistantMsg("reply"));
|
||||||
|
const currentLeafId = harness.sessionManager.appendMessage(userMsg("second"));
|
||||||
|
|
||||||
|
expect(harness.sessionManager.getLeafId()).toBe(currentLeafId);
|
||||||
|
|
||||||
|
const result = await harness.session.navigateTree(targetId, { summarize: false });
|
||||||
|
|
||||||
|
expect(result).toEqual({ cancelled: true });
|
||||||
|
expect(harness.session.isCompacting).toBe(false);
|
||||||
|
expect(harness.sessionManager.getLeafId()).toBe(currentLeafId);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user