fix(server): skip provider restart when a session-open plugin changes nothing

Reloading a plugin that registers agent.open.before marked every idle
session stale. The next prompt closed the provider and gave up after
3s, so sends failed even when the hook left env, tools, and MCP
servers unchanged.

Compare that contribution before closing. A real change still reopens
the session on the next idle prompt.
This commit is contained in:
luopeihuan
2026-10-30 13:48:26 +08:00
parent deb1bdc266
commit 6a0e53f6dc
4 changed files with 121 additions and 4 deletions
+4
View File
@@ -11,6 +11,10 @@ Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
Fixed:
- Keep an open agent on its current provider process when a session-open plugin reloads without changing env, tools, or MCP servers ([#8267](https://github.com/getpaseo/paseo/issues/8267))
## 0.14.0 - 2026-10-29
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
+4
View File
@@ -11,6 +11,10 @@
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
修复:
- 会话打开钩子重载后如果没有改变 env、工具或 MCP 服务器,就保持当前 provider 进程,不再为下一条消息关掉它 ([#8267](https://github.com/getpaseo/paseo/issues/8267))
## 0.14.0 - 2026-10-29
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -7,6 +7,7 @@ import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { basename, resolve } from "node:path";
import { stat } from "node:fs/promises";
import {
@@ -102,6 +103,32 @@ import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
interface SessionOpenContribution {
env: Record<string, string>;
tools: Array<{ pluginId: string; pluginInstanceId: string; name: string }>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
}
function sessionOpenContribution(input: {
purpose: PluginSessionOpenRequest["purpose"];
env: Record<string, string> | undefined;
tools: readonly AttachedAgentTool[];
mcpServers: AgentSessionConfig["mcpServers"];
}): SessionOpenContribution {
const interactive = input.purpose === "interactive";
return {
env: { ...input.env },
tools: interactive
? input.tools.map((tool) => ({
pluginId: tool.pluginId,
pluginInstanceId: tool.pluginInstanceId,
name: tool.name,
}))
: [],
mcpServers: interactive ? structuredClone(input.mcpServers ?? {}) : {},
};
}
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 90_000;
const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
@@ -780,6 +807,7 @@ export class AgentManager {
private readonly mcpAuthToken: string | null;
private sessionOpenGeneration = 0;
private readonly sessionOpenGenerations = new Map<string, number>();
private readonly sessionOpenContributions = new Map<string, SessionOpenContribution>();
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
private paseoToolsEnabled = true;
@@ -913,11 +941,49 @@ export class AgentManager {
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
if (!this.needsPluginSessionReload(agentId)) return;
const generation = this.sessionOpenGeneration;
// A hook that only observes the open must not kill the provider process.
// Reload only when env, tools, or MCP servers would actually change.
const unchanged = await this.sessionOpenContributionUnchanged(agentId);
if (!this.needsPluginSessionReload(agentId)) return;
if (unchanged && this.sessionOpenGeneration === generation) {
this.sessionOpenGenerations.set(agentId, generation);
return;
}
await this.reloadAgentSessionInternal(agentId);
}),
);
}
private async sessionOpenContributionUnchanged(agentId: string): Promise<boolean> {
const agent = this.getAgent(agentId);
const previous = this.sessionOpenContributions.get(agentId);
const client = agent ? this.clients.get(agent.provider) : undefined;
if (!agent || !previous || !client || !this.pluginLifecycle) return false;
const transformed = await this.pluginLifecycle.before("agent.open.before", {
agentId,
provider: client.provider,
cwd: agent.cwd,
workspaceId: agent.workspaceId ?? null,
reason: "refresh",
purpose: "interactive",
env: { ...agent.launchEnv },
title: await this.resolveInitialPersistedTitle(agentId, agent.config, null),
labels: agent.labels,
tools: [],
mcpServers: {},
});
return isDeepStrictEqual(
previous,
sessionOpenContribution({
purpose: "interactive",
env: transformed.env,
tools: transformed.tools as AttachedAgentTool[],
mcpServers: transformed.mcpServers,
}),
);
}
private needsPluginSessionReload(agentId: string): boolean {
const agent = this.getAgent(agentId);
return (
@@ -957,6 +1023,7 @@ export class AgentManager {
private detachPluginTools(agentId: string): void {
this.attachedPluginTools.delete(agentId);
this.sessionOpenGenerations.delete(agentId);
this.sessionOpenContributions.delete(agentId);
this.pluginAgentTools?.detachTools(agentId);
}
@@ -5566,6 +5633,13 @@ export class AgentManager {
this.detachPluginTools(agentId);
let mcpServers: AgentSessionConfig["mcpServers"];
let mcpServerOwners: Record<string, string> | undefined;
const purpose = opening?.purpose ?? "interactive";
let contribution = sessionOpenContribution({
purpose,
env,
tools: [],
mcpServers: {},
});
if (this.pluginLifecycle) {
const request = {
agentId,
@@ -5573,7 +5647,7 @@ export class AgentManager {
cwd,
workspaceId: opening?.workspaceId ?? null,
reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive",
purpose,
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
@@ -5582,11 +5656,18 @@ export class AgentManager {
};
const transformed = await this.pluginLifecycle.before("agent.open.before", request);
env = transformed.env;
const tools = transformed.tools as AttachedAgentTool[];
if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
this.attachedPluginTools.set(agentId, tools);
mcpServers = transformed.mcpServers;
mcpServerOwners = transformed.mcpServerOwners;
}
contribution = sessionOpenContribution({
purpose: request.purpose,
env,
tools,
mcpServers: transformed.mcpServers,
});
}
const context: AgentLaunchContext = {
agentId,
@@ -5618,6 +5699,7 @@ export class AgentManager {
}
}
this.sessionOpenGenerations.set(agentId, generation);
this.sessionOpenContributions.set(agentId, contribution);
return context;
}
@@ -667,6 +667,33 @@ test("starting and stopping a session-hook plugin each causes one idle reopen",
}
}, 60_000);
test("an unchanged session-open hook does not reopen an idle provider session", async () => {
const source = (value: string) => `
export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => {
return { ...request, env: { ...request.env, STABLE: ${JSON.stringify(value)} } };
});
return () => {};
}
`;
const h = await setup(false, source("1"));
try {
const agent = await h.create();
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("1");
await h.client.reloadPlugin("tools-test");
await sendAndFinish(h, agent.id, "after the same hook");
expect(h.launches).toHaveLength(1);
await writeFile(path.join(h.directory, "tools-test", "index.server.ts"), source("2"));
await h.client.reloadPlugin("tools-test");
await sendAndFinish(h, agent.id, "after the hook changes");
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("2");
expect(h.events.interrupts).toBe(0);
} finally {
await h.close();
}
}, 60_000);
test("plugins without a session-open hook do not make agent sessions stale", async () => {
const h = await setup(true);
try {
@@ -858,7 +885,7 @@ test("installing and disabling an MCP plugin reopens idle sessions before their
}
}, 60_000);
test("installing and disabling an MCP plugin never interrupts a running turn", async () => {
test("installing and disabling an MCP plugin during a turn leaves the provider session running", async () => {
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
@@ -885,7 +912,7 @@ test("installing and disabling an MCP plugin never interrupts a running turn", a
release();
await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false);
await sendAndFinish(h, a.id, "idle");
expect(h.launches).toHaveLength(2);
expect(h.launches).toHaveLength(1);
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
} finally {
release();