fix(coding-agent): preserve startup extension UI

closes #5943
This commit is contained in:
Armin Ronacher
2026-06-21 23:17:13 +02:00
parent 542683b29a
commit 2417adb46a
5 changed files with 463 additions and 25 deletions
+1
View File
@@ -4,6 +4,7 @@
### Fixed
- Fixed transient extension UI and session-start messages emitted during session replacement or reload so they remain visible, and kept reload input blocked until reload completes ([#5943](https://github.com/earendil-works/pi/issues/5943)).
- Fixed the plan-mode example to preserve active custom tools, skip the action prompt when no plan is found, and queue refinement/execution follow-ups correctly from `agent_end` ([#5940](https://github.com/earendil-works/pi/issues/5940)).
- Fixed `pi update` to install the exact version returned by the Pi update check, make `--force` reinstall that checked version, fail instead of falling back to an unversioned reinstall when no version is available, and report both the old and updated versions.
@@ -2443,7 +2443,7 @@ export class AgentSession {
});
}
async reload(): Promise<void> {
async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {
const previousFlagValues = this._extensionRunner.getFlagValues();
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
await this.settingsManager.reload();
@@ -2462,6 +2462,7 @@ export class AgentSession {
this._extensionShutdownHandler ||
this._extensionErrorListener;
if (hasBindings) {
await options?.beforeSessionStart?.();
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
await this.extendResourcesFromExtensions("reload");
}
@@ -395,7 +395,7 @@ export class InteractiveMode {
this.resetExtensionUI();
});
this.runtimeHost.setRebindSession(async () => {
await this.rebindCurrentSession();
await this.rebindCurrentSession({ renderBeforeBind: true });
});
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
@@ -1543,12 +1543,7 @@ export class InteractiveMode {
}
this.statusContainer.clear();
try {
const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) {
this.renderCurrentSessionState();
this.ui.requestRender();
}
return result;
return await this.runtimeHost.newSession(options);
} catch (error: unknown) {
return this.handleFatalRuntimeError("Failed to create session", error);
}
@@ -1557,7 +1552,6 @@ export class InteractiveMode {
try {
const result = await this.runtimeHost.fork(entryId, options);
if (!result.cancelled) {
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session");
}
@@ -1631,12 +1625,18 @@ export class InteractiveMode {
}
}
private async rebindCurrentSession(): Promise<void> {
private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.applyRuntimeSettings();
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
if (options.renderBeforeBind) {
this.renderCurrentSessionState();
this.subscribeToAgent();
await this.bindCurrentSessionExtensions();
} else {
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
}
await this.updateAvailableProviderCount();
this.updateEditorBorderColor();
this.updateTerminalTitle();
@@ -4376,7 +4376,6 @@ export class InteractiveMode {
return;
}
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
done();
this.showStatus("Forked to new session");
@@ -4409,7 +4408,6 @@ export class InteractiveMode {
return;
}
this.renderCurrentSessionState();
this.editor.setText("");
this.showStatus("Cloned to new session");
} catch (error: unknown) {
@@ -4601,7 +4599,6 @@ export class InteractiveMode {
if (result.cancelled) {
return result;
}
this.renderCurrentSessionState();
this.showStatus("Resumed session");
return result;
} catch (error: unknown) {
@@ -4619,7 +4616,6 @@ export class InteractiveMode {
if (result.cancelled) {
return result;
}
this.renderCurrentSessionState();
this.showStatus("Resumed session in current cwd");
return result;
}
@@ -5072,8 +5068,20 @@ export class InteractiveMode {
this.ui.requestRender();
};
let chatRestoredBeforeSessionStart = false;
let reloadBoxDismissed = false;
const restoreChatBeforeSessionStart = () => {
if (chatRestoredBeforeSessionStart) {
return;
}
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
this.rebuildChatFromMessages();
chatRestoredBeforeSessionStart = true;
};
try {
await this.session.reload();
await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart });
restoreChatBeforeSessionStart();
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
@@ -5081,7 +5089,6 @@ export class InteractiveMode {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
await this.themeController.applyFromSettings();
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
@@ -5096,8 +5103,6 @@ export class InteractiveMode {
this.setupAutocompleteProvider();
const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner);
this.rebuildChatFromMessages();
dismissReloadBox(this.editor as Component);
this.showLoadedResources({
force: false,
showDiagnosticsWhenQuiet: true,
@@ -5112,8 +5117,12 @@ export class InteractiveMode {
? "Reloaded keybindings, extensions, skills, prompts, themes; saved project trust"
: "Reloaded keybindings, extensions, skills, prompts, themes",
);
dismissReloadBox(this.editor as Component);
reloadBoxDismissed = true;
} catch (error) {
dismissReloadBox(previousEditor as Component);
if (!reloadBoxDismissed) {
dismissReloadBox(previousEditor as Component);
}
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -5187,7 +5196,6 @@ export class InteractiveMode {
this.showStatus("Import cancelled");
return;
}
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
} catch (error: unknown) {
if (error instanceof MissingSessionCwdError) {
@@ -5201,7 +5209,6 @@ export class InteractiveMode {
this.showStatus("Import cancelled");
return;
}
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
return;
}
@@ -5540,7 +5547,6 @@ export class InteractiveMode {
if (result.cancelled) {
return;
}
this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
@@ -41,7 +41,7 @@ describe("InteractiveMode /clone", () => {
await interactiveModePrototype.handleCloneCommand.call(context);
expect(fork).toHaveBeenCalledWith("leaf-123", { position: "at" });
expect(renderCurrentSessionState).toHaveBeenCalled();
expect(renderCurrentSessionState).not.toHaveBeenCalled();
expect(setText).toHaveBeenCalledWith("");
expect(showStatus).toHaveBeenCalledWith("Cloned to new session");
expect(showError).not.toHaveBeenCalled();
@@ -0,0 +1,430 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it, vi } from "vitest";
import type { AgentSessionEvent } from "../../../src/core/agent-session.ts";
import type { ExtensionUIContext } from "../../../src/core/extensions/index.ts";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
import { initTheme, type Theme, theme } from "../../../src/modes/interactive/theme/theme.ts";
import { createHarness } from "../harness.ts";
function createUiContext(
onNotify: (message: string, type: "info" | "warning" | "error" | undefined) => void,
): ExtensionUIContext {
return {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: onNotify,
onTerminalInput: () => () => {},
setStatus: () => {},
setWorkingMessage: () => {},
setWorkingVisible: () => {},
setWorkingIndicator: () => {},
setHiddenThinkingLabel: () => {},
setWidget: () => {},
setFooter: () => {},
setHeader: () => {},
setTitle: () => {},
custom: async <T>() => undefined as T,
pasteToEditor: () => {},
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
addAutocompleteProvider: () => {},
setEditorComponent: () => {},
getEditorComponent: () => undefined,
get theme() {
return theme;
},
getAllThemes: () => [],
getTheme: () => undefined,
setTheme: (_theme: string | Theme) => ({ success: false, error: "Theme switching not available in tests" }),
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
}
type RebindContext = {
unsubscribe?: () => void;
applyRuntimeSettings: () => void;
renderCurrentSessionState: () => void;
bindCurrentSessionExtensions: () => Promise<void>;
subscribeToAgent: () => void;
updateAvailableProviderCount: () => Promise<void>;
updateEditorBorderColor: () => void;
updateTerminalTitle: () => void;
};
type ReloadCommandContext = {
hideThinkingBlock: boolean;
session: {
isStreaming: boolean;
isCompacting: boolean;
reload: (options?: { beforeSessionStart?: () => void | Promise<void> }) => Promise<void>;
resourceLoader: { getThemes: () => { themes: [] } };
extensionRunner: unknown;
modelRegistry: { getError: () => string | undefined };
};
settingsManager: {
getHttpIdleTimeoutMs: () => number;
getHideThinkingBlock: () => boolean;
getEditorPaddingX: () => number;
getAutocompleteMaxVisible: () => number;
getShowHardwareCursor: () => boolean;
getClearOnShrink: () => boolean;
};
keybindings: { reload: () => void };
customHeader?: unknown;
builtInHeader?: unknown;
editorContainer: { clear: () => void; addChild: (component: unknown) => void };
ui: {
setFocus: (component: unknown) => void;
requestRender: (force?: boolean) => void;
setShowHardwareCursor: (enabled: boolean) => void;
setClearOnShrink: (enabled: boolean) => void;
};
editor: unknown;
defaultEditor: { setPaddingX: (padding: number) => void; setAutocompleteMaxVisible: (maxVisible: number) => void };
themeController: { applyFromSettings: () => Promise<void> };
resetExtensionUI: () => void;
rebuildChatFromMessages: () => void;
setupAutocompleteProvider: () => void;
setupExtensionShortcuts: (runner: unknown) => void;
showLoadedResources: (options: unknown) => void;
maybeSaveImplicitProjectTrustAfterReload: () => boolean;
showStatus: (message: string) => void;
showWarning: (message: string) => void;
showError: (message: string) => void;
};
type InteractiveModePrototype = {
rebindCurrentSession(this: RebindContext, options?: { renderBeforeBind?: boolean }): Promise<void>;
handleReloadCommand(this: ReloadCommandContext): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype;
type ReloadCommandContextOverrides = Omit<
Partial<ReloadCommandContext>,
"session" | "settingsManager" | "keybindings" | "editorContainer" | "ui" | "defaultEditor" | "themeController"
> & {
session?: Partial<ReloadCommandContext["session"]>;
settingsManager?: Partial<ReloadCommandContext["settingsManager"]>;
keybindings?: Partial<ReloadCommandContext["keybindings"]>;
editorContainer?: Partial<ReloadCommandContext["editorContainer"]>;
ui?: Partial<ReloadCommandContext["ui"]>;
defaultEditor?: Partial<ReloadCommandContext["defaultEditor"]>;
themeController?: Partial<ReloadCommandContext["themeController"]>;
};
function createReloadCommandContext(overrides: ReloadCommandContextOverrides = {}): ReloadCommandContext {
const editor = overrides.editor ?? {};
return {
hideThinkingBlock: overrides.hideThinkingBlock ?? false,
session: {
isStreaming: false,
isCompacting: false,
reload: async (options) => {
await options?.beforeSessionStart?.();
},
resourceLoader: { getThemes: () => ({ themes: [] }) },
extensionRunner: {},
modelRegistry: { getError: () => undefined },
...overrides.session,
},
settingsManager: {
getHttpIdleTimeoutMs: () => 0,
getHideThinkingBlock: () => false,
getEditorPaddingX: () => 1,
getAutocompleteMaxVisible: () => 10,
getShowHardwareCursor: () => false,
getClearOnShrink: () => false,
...overrides.settingsManager,
},
keybindings: { reload: () => {}, ...overrides.keybindings },
editorContainer: { clear: () => {}, addChild: () => {}, ...overrides.editorContainer },
ui: {
setFocus: () => {},
requestRender: () => {},
setShowHardwareCursor: () => {},
setClearOnShrink: () => {},
...overrides.ui,
},
editor,
defaultEditor: { setPaddingX: () => {}, setAutocompleteMaxVisible: () => {}, ...overrides.defaultEditor },
themeController: { applyFromSettings: async () => {}, ...overrides.themeController },
customHeader: overrides.customHeader,
builtInHeader: overrides.builtInHeader,
resetExtensionUI: overrides.resetExtensionUI ?? (() => {}),
rebuildChatFromMessages: overrides.rebuildChatFromMessages ?? (() => {}),
setupAutocompleteProvider: overrides.setupAutocompleteProvider ?? (() => {}),
setupExtensionShortcuts: overrides.setupExtensionShortcuts ?? (() => {}),
showLoadedResources: overrides.showLoadedResources ?? (() => {}),
maybeSaveImplicitProjectTrustAfterReload: overrides.maybeSaveImplicitProjectTrustAfterReload ?? (() => false),
showStatus: overrides.showStatus ?? (() => {}),
showWarning: overrides.showWarning ?? (() => {}),
showError: overrides.showError ?? (() => {}),
};
}
type MessageEvent = Extract<AgentSessionEvent, { type: "message_start" | "message_end" }>;
function getMessageText(event: MessageEvent): string {
const message = event.message;
if (!("content" in message)) {
return "";
}
const content = message.content;
if (typeof content === "string") {
return content;
}
return content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("");
}
describe("regression #5943: session_start transient UI", () => {
it("renders replacement session state before session_start handlers can notify", async () => {
const events: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", (_event, ctx) => {
ctx.ui.notify("Hello Error", "error");
});
},
],
});
try {
const context: RebindContext = {
applyRuntimeSettings: () => events.push("apply"),
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext((message) => events.push(`notify:${message}`)),
mode: "tui",
});
},
subscribeToAgent: () => events.push("subscribe"),
updateAvailableProviderCount: async () => {},
updateEditorBorderColor: () => {},
updateTerminalTitle: () => {},
};
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
expect(events).toEqual(["apply", "render", "subscribe", "bind", "notify:Hello Error"]);
} finally {
harness.cleanup();
}
});
it("subscribes before replacement session_start handlers send messages", async () => {
const events: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
pi.sendMessage({
customType: "session-start",
content: "custom from start",
display: true,
});
});
},
],
});
try {
const context: RebindContext = {
applyRuntimeSettings: () => {},
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext(() => {}),
mode: "tui",
});
},
subscribeToAgent: () => {
events.push("subscribe");
harness.session.subscribe((event) => {
if (event.type !== "message_start" && event.type !== "message_end") {
return;
}
events.push(`${event.type}:${event.message.role}:${getMessageText(event)}`);
});
},
updateAvailableProviderCount: async () => {},
updateEditorBorderColor: () => {},
updateTerminalTitle: () => {},
};
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
expect(events).toEqual([
"render",
"subscribe",
"bind",
"message_start:custom:custom from start",
"message_end:custom:custom from start",
]);
} finally {
harness.cleanup();
}
});
it("subscribes before replacement session_start handlers send user messages", async () => {
const events: string[] = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", () => {
pi.sendUserMessage("user from start");
});
},
],
});
harness.setResponses([fauxAssistantMessage("assistant from start")]);
try {
const context: RebindContext = {
applyRuntimeSettings: () => {},
renderCurrentSessionState: () => events.push("render"),
bindCurrentSessionExtensions: async () => {
events.push("bind");
await harness.session.bindExtensions({
uiContext: createUiContext(() => {}),
mode: "tui",
});
},
subscribeToAgent: () => {
events.push("subscribe");
harness.session.subscribe((event) => {
if (event.type !== "message_start" && event.type !== "message_end") {
return;
}
events.push(`${event.type}:${event.message.role}:${getMessageText(event)}`);
});
},
updateAvailableProviderCount: async () => {},
updateEditorBorderColor: () => {},
updateTerminalTitle: () => {},
};
await interactiveModePrototype.rebindCurrentSession.call(context, { renderBeforeBind: true });
await harness.session.agent.waitForIdle();
expect(events.slice(0, 3)).toEqual(["render", "subscribe", "bind"]);
expect(events).toContain("message_start:user:user from start");
expect(events).toContain("message_end:user:user from start");
expect(events).toContain("message_end:assistant:assistant from start");
} finally {
harness.cleanup();
}
});
it("runs the reload render hook before reload session_start handlers can notify", async () => {
const events: string[] = [];
const beforeSessionStart = vi.fn(() => {
events.push("render");
});
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_start", (event, ctx) => {
events.push(`start:${event.reason}`);
ctx.ui.notify(`notify:${event.reason}`, "error");
});
},
],
});
try {
await harness.session.bindExtensions({
uiContext: createUiContext((message) => events.push(message)),
mode: "tui",
});
expect(events).toEqual(["start:startup", "notify:startup"]);
events.length = 0;
await harness.session.reload({ beforeSessionStart });
expect(beforeSessionStart).toHaveBeenCalledTimes(1);
expect(events).toEqual(["render", "start:reload", "notify:reload"]);
} finally {
harness.cleanup();
}
});
it("refreshes hideThinkingBlock before rebuilding chat during reload", async () => {
initTheme("dark", false);
const events: string[] = [];
let context: ReloadCommandContext;
context = createReloadCommandContext({
settingsManager: { getHideThinkingBlock: () => true },
session: {
reload: async (options) => {
events.push("reload");
await options?.beforeSessionStart?.();
events.push(`start:${context.hideThinkingBlock}`);
},
},
rebuildChatFromMessages: () => {
events.push(`rebuild:${context.hideThinkingBlock}`);
},
});
await interactiveModePrototype.handleReloadCommand.call(context);
expect(context.hideThinkingBlock).toBe(true);
expect(events).toEqual(["reload", "rebuild:true", "start:true"]);
});
it("keeps the reload blocker focused until async reload completes", async () => {
initTheme("dark", false);
const editor = {};
let focused: unknown;
let chatRestored = false;
let markReloadWaiting!: () => void;
let finishReload!: () => void;
const reloadWaiting = new Promise<void>((resolve) => {
markReloadWaiting = resolve;
});
const reloadFinished = new Promise<void>((resolve) => {
finishReload = resolve;
});
const context = createReloadCommandContext({
editor,
session: {
reload: async (options) => {
await options?.beforeSessionStart?.();
markReloadWaiting();
await reloadFinished;
},
},
ui: {
setFocus: (component) => {
focused = component;
},
},
rebuildChatFromMessages: () => {
chatRestored = true;
},
});
const reloadPromise = interactiveModePrototype.handleReloadCommand.call(context);
await reloadWaiting;
expect(chatRestored).toBe(true);
expect(focused).not.toBe(editor);
finishReload();
await reloadPromise;
expect(focused).toBe(editor);
});
});