Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] ed8fcc17e4 test(plugins): reproduce reload timeout and repeated open hooks
Diagnostic tests only. The unchanged-hook test fails on the pre-fix manager with a slow-closing provider; the changed-hook test fails on the proposed contribution probe because it evaluates a hook twice for one replacement process.
2026-10-31 15:26:24 +00:00
luke e1e48d38fd Merge branch 'main' into fix/session-open-unchanged-reload 2026-10-30 14:54:49 +08:00
luopeihuan 61ac3aa6c0 docs(changelog): cite the session-open reload pull request
Point the unreleased entry at #8269 now that the pull request exists.
2026-10-30 13:48:26 +08:00
luopeihuan 6a0e53f6dc 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.
2026-10-30 13:48:26 +08:00
4 changed files with 175 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 ([#8269](https://github.com/getpaseo/paseo/pull/8269))
## 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 进程,不再为下一条消息关掉它 ([#8269](https://github.com/getpaseo/paseo/pull/8269))
## 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();
@@ -1040,3 +1067,57 @@ test("session-open MCP configs are validated before a provider launches", async
await h.close();
}
}, 60_000);
test("diagnostic: unchanged observer reload keeps a slow-closing provider usable", async () => {
let closes = 0;
const h = await setup(
false,
`
export default function contribute(server) {
return server.addHook("agent.open.before", async () => {});
}
`,
undefined,
{
closeSession: async () => {
closes++;
await new Promise((resolve) => setTimeout(resolve, 3700));
},
},
);
try {
const agent = await h.create();
await sendAndFinish(h, agent.id, "first");
await h.client.reloadPlugin("tools-test");
await sendAndFinish(h, agent.id, "after reload");
expect(closes).toBe(0);
expect(h.launches).toHaveLength(1);
} finally {
await h.close();
}
}, 60_000);
test("diagnostic: a changed session-open hook runs once for the replacement process", async () => {
const source = (value: string) => `
let calls = 0;
export default function contribute(server) {
return server.addHook("agent.open.before", async ({ request }) => ({
...request,
env: { ...request.env, STABLE: ${JSON.stringify(value)}, HOOK_CALL: String(++calls) },
}));
}
`;
const h = await setup(false, source("1"));
try {
const agent = await h.create();
expect(h.launches.at(-1)!.context!.env!.HOOK_CALL).toBe("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 changed hook");
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("2");
expect(h.launches.at(-1)!.context!.env!.HOOK_CALL).toBe("1");
} finally {
await h.close();
}
}, 60_000);