fix(task): refreshed agent definitions without restart
Published per-cwd discovery snapshots to existing task tools and refreshed them from TUI, ACP, and Agent Control Center reload paths. Added regressions for existing and future task tools across TUI and ACP reloads. Fixes #7940
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `/reload-plugins` and the Agent Control Center leaving the running session's `task` tool on its create-time agent definitions; refreshed `.omp/agents/*.md` names and descriptions now reach existing tools without restarting ([#7940](https://github.com/can1357/oh-my-pi/issues/7940)).
|
||||
- Fixed proxy discovery preferring the bundled catalog name over the proxy-reported name, so `omp models refresh` now updates stale display names (e.g. a proxy serving `longcat-2.0` as `"LongCat"` no longer shows the raw id).
|
||||
- Fixed the compiled binary build on Windows: `Bun.Glob.scan` yields backslash-separated paths, which the legacy Pi virtual module used verbatim for export keys and generated identifiers, producing invalid JavaScript.
|
||||
- Fixed Ctrl+O (`app.tools.expand`) not expanding truncated tool output while a tool-approval prompt or other selection dialog held keyboard focus, by promoting the shortcut to a global input listener that fires regardless of focus (it still defers to fullscreen overlays and the tree selector's own Ctrl+O filter cycle) ([#7837](https://github.com/can1357/oh-my-pi/issues/7837)).
|
||||
|
||||
@@ -70,6 +70,7 @@ import { SessionManager } from "../../session/session-manager";
|
||||
import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
|
||||
import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands";
|
||||
import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS } from "../../stt/models";
|
||||
import { refreshAgentDiscovery } from "../../task";
|
||||
import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
|
||||
import { normalizeLocalScheme } from "../../tools/path-utils";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
@@ -1930,14 +1931,15 @@ export class AcpAgent implements Agent {
|
||||
/**
|
||||
* Reload plugin/registry state for an ACP session. Mirrors the interactive
|
||||
* `/reload-plugins` and `/move` flows: invalidates the plugin-roots cache,
|
||||
* resets the capability cache, refreshes the session's slash-command state,
|
||||
* then re-advertises commands so the client sees newly installed/disabled
|
||||
* plugins.
|
||||
* refreshes task agents, resets the capability cache, refreshes the
|
||||
* session's slash-command state, then re-advertises commands so the client
|
||||
* sees newly installed/disabled plugins.
|
||||
*/
|
||||
async #reloadPluginState(record: ManagedSessionRecord): Promise<void> {
|
||||
const cwd = record.session.sessionManager.getCwd();
|
||||
const projectPath = await resolveActiveProjectRegistryPath(cwd);
|
||||
clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
|
||||
await refreshAgentDiscovery(cwd);
|
||||
resetCapabilities();
|
||||
await record.session.refreshSkills();
|
||||
const fileCommands = await loadSlashCommands({ cwd });
|
||||
|
||||
@@ -48,6 +48,7 @@ import { Settings } from "../../config/settings";
|
||||
import agentCreationArchitectPrompt from "../../prompts/system/agent-creation-architect.md" with { type: "text" };
|
||||
import agentCreationUserPrompt from "../../prompts/system/agent-creation-user.md" with { type: "text" };
|
||||
import { createAgentSession } from "../../sdk";
|
||||
import { refreshAgentDiscovery } from "../../task";
|
||||
import { discoverAgents } from "../../task/discovery";
|
||||
import { resolveAgentPrewalkDefault } from "../../task/prewalk";
|
||||
import type { AgentDefinition, AgentSource } from "../../task/types";
|
||||
@@ -824,6 +825,7 @@ export class AgentDashboard extends Container {
|
||||
).trimEnd();
|
||||
const content = `---\n${frontmatter}\n---\n\n${spec.systemPrompt.trim()}\n`;
|
||||
await Bun.write(filePath, content);
|
||||
await refreshAgentDiscovery(this.cwd);
|
||||
await this.#reloadData();
|
||||
this.#clearCreateFlow();
|
||||
this.#notice = `Created agent ${spec.identifier} at ${shortenPath(filePath)}`;
|
||||
|
||||
@@ -44,6 +44,7 @@ import type { SessionOAuthAccountList } from "../session/agent-session-types";
|
||||
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
|
||||
import { resolveResumableSession } from "../session/session-listing";
|
||||
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
|
||||
import { refreshAgentDiscovery } from "../task";
|
||||
import type { ComputerTool } from "../tools/computer";
|
||||
import { computerExposureMode } from "../tools/computer/exposure";
|
||||
import { expandTilde, resolveToCwd } from "../tools/path-utils";
|
||||
@@ -3069,14 +3070,15 @@ export const BUILTIN_SLASH_COMMANDS_INTERNAL: ReadonlyArray<SlashCommandSpec> =
|
||||
|
||||
/**
|
||||
* Reload the interactive session's plugin runtime: invalidate fs/plugin-root
|
||||
* caches, rediscover skills and file slash commands, reset the capability
|
||||
* cache, and reconnect MCP servers (rebinding the session's MCP tools). Shared
|
||||
* by `/reload-plugins`'s TUI handler and the `handle`-adapter's `reloadPlugins`
|
||||
* hook so both honor the command's documented MCP reload scope (#7189).
|
||||
* caches, rediscover skills, file slash commands, and task agents, reset the
|
||||
* capability cache, and reconnect MCP servers (rebinding the session's MCP
|
||||
* tools). Shared by `/reload-plugins`'s TUI handler and the `handle`-adapter's
|
||||
* `reloadPlugins` hook so both honor the command's documented reload scope.
|
||||
*/
|
||||
async function reloadTuiPluginState(ctx: InteractiveModeContext): Promise<void> {
|
||||
const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd());
|
||||
clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
|
||||
await refreshAgentDiscovery(ctx.sessionManager.getCwd());
|
||||
await ctx.refreshSkillState();
|
||||
await ctx.refreshSlashCommandState();
|
||||
resetCapabilities();
|
||||
|
||||
@@ -441,16 +441,19 @@ export function composeSpawnAdvisory(args: {
|
||||
class TaskJobError extends Error {}
|
||||
|
||||
/**
|
||||
* Process-level memo for create-time agent discovery, keyed by resolved cwd.
|
||||
* Process-level create-time discovery memo and published reload snapshots,
|
||||
* keyed by resolved cwd.
|
||||
*
|
||||
* `TaskTool.create` runs for every (sub)agent session in this process and the
|
||||
* walk-up + plugin-registry scan in `discoverAgents` is identical for a given
|
||||
* cwd, so repeat creations reuse the first scan. Execution-time discovery
|
||||
* (`#runSpawn`) intentionally stays fresh. The memo also tracks the live
|
||||
* `discoverAgents` binding: test spies swap that binding, which invalidates
|
||||
* the memo automatically.
|
||||
* cwd, so repeat creations reuse the first scan. Explicit plugin reloads
|
||||
* replace the matching snapshot so already-created tools advertise the latest
|
||||
* definitions. Execution-time discovery (`#runSpawn`) intentionally stays
|
||||
* fresh. The memo also tracks the live `discoverAgents` binding: test spies
|
||||
* swap that binding, which invalidates both caches automatically.
|
||||
*/
|
||||
const discoveryMemo = new Map<string, Promise<DiscoveryResult>>();
|
||||
const discoverySnapshots = new Map<string, AgentDefinition[]>();
|
||||
let discoveryMemoFn: typeof discoverAgents | undefined;
|
||||
|
||||
function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
|
||||
@@ -458,6 +461,7 @@ function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
|
||||
if (discoveryMemoFn !== fn) {
|
||||
discoveryMemoFn = fn;
|
||||
discoveryMemo.clear();
|
||||
discoverySnapshots.clear();
|
||||
}
|
||||
const key = path.resolve(cwd);
|
||||
let pending = discoveryMemo.get(key);
|
||||
@@ -471,6 +475,17 @@ function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Rescan one cwd and publish its definitions to existing and future task tools. */
|
||||
export async function refreshAgentDiscovery(cwd: string): Promise<void> {
|
||||
const key = path.resolve(cwd);
|
||||
discoveryMemo.delete(key);
|
||||
const pending = discoverAgentsForCreate(cwd);
|
||||
const { agents } = await pending;
|
||||
if (discoveryMemo.get(key) === pending) {
|
||||
discoverySnapshots.set(key, agents);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tool Class
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -587,7 +602,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
||||
const planMode = this.session.getPlanModeState?.()?.enabled === true;
|
||||
const isolationMode = this.session.settings.get("task.isolation.mode");
|
||||
return renderDescription({
|
||||
agents: this.#discoveredAgents,
|
||||
agents: discoverySnapshots.get(path.resolve(this.session.cwd)) ?? this.#discoveredAgents,
|
||||
isolationEnabled: !planMode && isolationMode !== "none",
|
||||
applyIsolatedChanges: this.session.settings.get("task.isolation.apply"),
|
||||
disabledAgents,
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
import { SILENT_ABORT_MARKER } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS } from "@oh-my-pi/pi-coding-agent/stt/models";
|
||||
import { TaskTool } from "@oh-my-pi/pi-coding-agent/task";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import {
|
||||
DEFAULT_TTS_LOCAL_MODEL_KEY,
|
||||
DEFAULT_TTS_VOICE,
|
||||
@@ -78,6 +80,16 @@ const TEST_MODELS: Model[] = [
|
||||
}),
|
||||
];
|
||||
|
||||
function createTaskSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
settings: Settings.isolated({}),
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
} as unknown as ToolSession;
|
||||
}
|
||||
|
||||
function makeAssistantMessage(text: string, thinking?: string) {
|
||||
const content: Array<{ type: "text"; text: string } | { type: "thinking"; thinking: string }> = [
|
||||
{ type: "text", text },
|
||||
@@ -1670,6 +1682,34 @@ describe("ACP agent", () => {
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("refreshes task agent descriptions on ACP /reload-plugins", async () => {
|
||||
const harness = await createHarness();
|
||||
const agentDir = path.join(harness.cwdA, ".omp", "agents");
|
||||
const agentFile = path.join(agentDir, "acp-reload-agent.md");
|
||||
await fs.promises.mkdir(agentDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
agentFile,
|
||||
"---\nname: acp-reload-agent\ndescription: VERSION_ONE\n---\nACP reload agent.\n",
|
||||
);
|
||||
const taskTool = await TaskTool.create(createTaskSession(harness.cwdA));
|
||||
expect(taskTool.description).toContain("VERSION_ONE");
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
agentFile,
|
||||
"---\nname: acp-reload-agent\ndescription: VERSION_TWO\n---\nACP reload agent.\n",
|
||||
);
|
||||
await harness.agent.prompt({
|
||||
sessionId: created.sessionId,
|
||||
messageId: "00000000-0000-4000-8000-000000000006",
|
||||
prompt: [{ type: "text", text: "/reload-plugins" }],
|
||||
} as PromptRequest);
|
||||
|
||||
expect(taskTool.description).toContain("VERSION_TWO");
|
||||
expect(taskTool.description).not.toContain("VERSION_ONE");
|
||||
harness.abortController.abort();
|
||||
});
|
||||
|
||||
it("advertises ACP-safe builtins and skill commands", async () => {
|
||||
const harness = await createHarness();
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
/**
|
||||
* Regression: `/reload-plugins` is documented to reload MCP, so the TUI handler
|
||||
* must reconnect servers and rebind the session's MCP tool registry — not just
|
||||
* reset skill/command/capability caches. Before the fix it silently skipped
|
||||
* MCP, leaving `.mcp.json` edits inactive until process restart (#7189).
|
||||
* Regressions for `/reload-plugins` runtime surfaces that must update without a
|
||||
* process restart: MCP reconnect/rebinding (#7189) and task-agent descriptions
|
||||
* published to existing tools (#7940).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
|
||||
import type { TuiSlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/types";
|
||||
import { TaskTool } from "@oh-my-pi/pi-coding-agent/task";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { getProjectDir, removeWithRetries, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const originalProjectDir = getProjectDir();
|
||||
|
||||
function agentDefinition(description: string): string {
|
||||
return `---\nname: reload-agent\ndescription: ${description}\n---\nReload agent.\n`;
|
||||
}
|
||||
|
||||
function createTaskSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
settings: Settings.isolated({}),
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
} as unknown as ToolSession;
|
||||
}
|
||||
|
||||
function createFakeCtx(cwd: string, settingsValues: Record<string, unknown> = {}) {
|
||||
const mcpTools = [{ name: "mcp__srv_do" }];
|
||||
const mcpManager = {
|
||||
@@ -39,7 +55,7 @@ function createFakeCtx(cwd: string, settingsValues: Record<string, unknown> = {}
|
||||
return { ctx, mcpManager, session, mcpTools };
|
||||
}
|
||||
|
||||
describe("/reload-plugins MCP reconnect (#7189)", () => {
|
||||
describe("/reload-plugins runtime refresh", () => {
|
||||
let projectDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -78,4 +94,21 @@ describe("/reload-plugins MCP reconnect (#7189)", () => {
|
||||
expect.objectContaining({ enableProjectConfig: false }),
|
||||
);
|
||||
});
|
||||
|
||||
test("republishes edited agents to an existing task tool", async () => {
|
||||
const agentDir = path.join(projectDir, ".omp", "agents");
|
||||
const agentFile = path.join(agentDir, "reload-agent.md");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await Bun.write(agentFile, agentDefinition("VERSION_ONE"));
|
||||
const taskTool = await TaskTool.create(createTaskSession(projectDir));
|
||||
expect(taskTool.description).toContain("VERSION_ONE");
|
||||
|
||||
await Bun.write(agentFile, agentDefinition("VERSION_TWO"));
|
||||
const { ctx } = createFakeCtx(projectDir);
|
||||
const runtime: TuiSlashCommandRuntime = { ctx };
|
||||
await executeBuiltinSlashCommand("/reload-plugins", runtime);
|
||||
|
||||
expect(taskTool.description).toContain("VERSION_TWO");
|
||||
expect(taskTool.description).not.toContain("VERSION_ONE");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { TaskTool } from "@oh-my-pi/pi-coding-agent/task";
|
||||
import { refreshAgentDiscovery, TaskTool } from "@oh-my-pi/pi-coding-agent/task";
|
||||
import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
@@ -13,6 +13,15 @@ const TEST_AGENTS = [
|
||||
},
|
||||
];
|
||||
|
||||
const REFRESHED_AGENTS = [
|
||||
{
|
||||
name: "task",
|
||||
description: "Refreshed task agent",
|
||||
systemPrompt: "You are the refreshed task agent.",
|
||||
source: "bundled" as const,
|
||||
},
|
||||
];
|
||||
|
||||
function createSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
@@ -63,4 +72,22 @@ describe("TaskTool.create discovery memo", () => {
|
||||
expect(tool.description).toContain("task");
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("publishes refreshed definitions to existing and future tools", async () => {
|
||||
const spy = vi
|
||||
.spyOn(discoveryModule, "discoverAgents")
|
||||
.mockResolvedValueOnce({ agents: TEST_AGENTS, projectAgentsDir: null })
|
||||
.mockResolvedValueOnce({ agents: REFRESHED_AGENTS, projectAgentsDir: null });
|
||||
const session = createSession("/tmp/omp-memo-refresh");
|
||||
const existing = await TaskTool.create(session);
|
||||
|
||||
expect(existing.description).toContain("General-purpose task agent");
|
||||
await refreshAgentDiscovery(session.cwd);
|
||||
|
||||
expect(existing.description).toContain("Refreshed task agent");
|
||||
expect(existing.description).not.toContain("General-purpose task agent");
|
||||
const future = await TaskTool.create(session);
|
||||
expect(future.description).toContain("Refreshed task agent");
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user