From 6a0e53f6dc65e72649f896545cd93f0a384003fd Mon Sep 17 00:00:00 2001 From: luopeihuan Date: Fri, 30 Oct 2026 13:44:03 +0800 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + CHANGELOG.zh-CN.md | 4 + .../server/src/server/agent/agent-manager.ts | 86 ++++++++++++++++++- .../server/plugins/agent-tools.e2e.test.ts | 31 ++++++- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e47984dbf7..7c08b98b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index f87cb58805..f6fab66ba1 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -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 服务器。 diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index a761e723c1..91ee294b6e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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; + tools: Array<{ pluginId: string; pluginInstanceId: string; name: string }>; + mcpServers: NonNullable; +} + +function sessionOpenContribution(input: { + purpose: PluginSessionOpenRequest["purpose"]; + env: Record | 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(); + private readonly sessionOpenContributions = new Map(); private readonly attachedPluginTools = new Map(); 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 { + 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 | 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; } diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts index b652c569fe..4e46121226 100644 --- a/packages/server/src/server/plugins/agent-tools.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -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((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();