diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f54280d4..68fd251e3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index c4d177ca8..ef27632c5 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -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 { 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 }); diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index 3575af407..cb0c2a747 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -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)}`; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 3930bddd7..7a096e3e2 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -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 = /** * 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 { const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd()); clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); + await refreshAgentDiscovery(ctx.sessionManager.getCwd()); await ctx.refreshSkillState(); await ctx.refreshSlashCommandState(); resetCapabilities(); diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 08011f6e7..a2fe5ca8b 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -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>(); +const discoverySnapshots = new Map(); let discoveryMemoFn: typeof discoverAgents | undefined; function discoverAgentsForCreate(cwd: string): Promise { @@ -458,6 +461,7 @@ function discoverAgentsForCreate(cwd: string): Promise { 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 { return pending; } +/** Rescan one cwd and publish its definitions to existing and future task tools. */ +export async function refreshAgentDiscovery(cwd: string): Promise { + 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 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: [] }); diff --git a/packages/coding-agent/test/reload-plugins-mcp.test.ts b/packages/coding-agent/test/reload-plugins-mcp.test.ts index 2af69eb08..f4199d2db 100644 --- a/packages/coding-agent/test/reload-plugins-mcp.test.ts +++ b/packages/coding-agent/test/reload-plugins-mcp.test.ts @@ -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 = {}) { const mcpTools = [{ name: "mcp__srv_do" }]; const mcpManager = { @@ -39,7 +55,7 @@ function createFakeCtx(cwd: string, settingsValues: Record = {} 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"); + }); }); diff --git a/packages/coding-agent/test/task/create-memo.test.ts b/packages/coding-agent/test/task/create-memo.test.ts index ee63123fc..ed3146992 100644 --- a/packages/coding-agent/test/task/create-memo.test.ts +++ b/packages/coding-agent/test/task/create-memo.test.ts @@ -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); + }); });