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();