Files
oh-my-pi/packages/coding-agent/test/task/subagent-lsp.test.ts
T
can1357 5ff277349c refactor(coding-agent): consolidated tool surface onto xd:// devices and hub
- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt.
- Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`.
- Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present.
- Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type.
- Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`.
- Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins.
- Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
2026-07-15 15:16:29 +02:00

288 lines
9.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state";
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk";
import type { AgentSession, AgentSessionEvent, PromptOptions } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { TaskTool } from "@oh-my-pi/pi-coding-agent/task";
import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
import type { AgentDefinition, TaskParams } from "@oh-my-pi/pi-coding-agent/task/types";
import type { IsolationHandle, WorktreeBaseline } from "@oh-my-pi/pi-coding-agent/task/worktree";
import * as worktreeModule from "@oh-my-pi/pi-coding-agent/task/worktree";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import "@oh-my-pi/pi-coding-agent/tools/yield";
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
const TEST_TASK: TaskParams = { agent: "task", name: "CheckLsp", task: "Inspect LSP tools." };
function createAssistantStopMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: text ? [{ type: "text", text }] : [],
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
function createYieldingSession(): AgentSession {
const listeners: Array<(event: AgentSessionEvent) => void> = [];
const state = { messages: [] as AssistantMessage[] };
const emit = (event: AgentSessionEvent) => {
for (const listener of listeners) listener(event);
};
return {
state,
agent: { state: { systemPrompt: ["test"] } },
model: undefined,
extensionRunner: undefined,
sessionManager: {
appendSessionInit: () => {},
},
getActiveToolNames: () => ["yield"],
setActiveToolsByName: async () => {},
subscribe: (listener: (event: AgentSessionEvent) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) listeners.splice(index, 1);
};
},
prompt: async (_text: string, _options?: PromptOptions) => {
state.messages.push(createAssistantStopMessage("done"));
emit({
type: "tool_execution_end",
toolCallId: "yield-call",
toolName: "yield",
result: {
content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } },
},
isError: false,
});
},
waitForIdle: async () => {},
getLastAssistantMessage: () => state.messages[state.messages.length - 1],
abort: async () => {},
dispose: async () => {},
} as unknown as AgentSession;
}
function createSession(
options: {
isolationMode?: "none" | "auto";
parentEnableLsp?: boolean;
planMode?: PlanModeState;
sessionFile?: string | null;
taskEnableLsp?: boolean;
} = {},
): ToolSession {
const modelRegistry = {
authStorage: undefined,
refresh: async () => {},
getAvailable: () => [],
getApiKey: async () => null,
} as unknown as ModelRegistry;
return {
cwd: "/tmp",
hasUI: false,
enableLsp: options.parentEnableLsp,
settings: Settings.isolated({
"async.enabled": false,
"task.isolation.mode": options.isolationMode ?? "none",
...(options.taskEnableLsp !== undefined ? { "task.enableLsp": options.taskEnableLsp } : {}),
}),
getSessionFile: () => options.sessionFile ?? null,
getSessionSpawns: () => "*",
modelRegistry,
getPlanModeState: () => options.planMode,
} as unknown as ToolSession;
}
function mockAgents(agent: AgentDefinition): void {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
agents: [agent],
projectAgentsDir: null,
});
}
function mockCreateAgentSession(): { getOptions: () => CreateAgentSessionOptions | undefined } {
let capturedOptions: CreateAgentSessionOptions | undefined;
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async (options = {}) => {
capturedOptions = options;
return {
session: createYieldingSession(),
extensionsResult: {} as unknown as LoadExtensionsResult,
setToolUIContext: () => {},
eventBus: new EventBus(),
} satisfies CreateAgentSessionResult;
});
return { getOptions: () => capturedOptions };
}
function mockIsolation(): void {
const baseline: WorktreeBaseline = {
root: {
repoRoot: "/repo",
headCommit: "HEAD",
staged: "",
unstaged: "",
untracked: [],
untrackedPatch: "",
},
nested: [],
};
const isolationHandle: IsolationHandle = {
mergedDir: "/tmp/isolated-subagent",
backend: worktreeModule.parseIsolationMode("rcopy")!,
fellBack: false,
fallbackReason: null,
};
vi.spyOn(worktreeModule, "getRepoRoot").mockResolvedValue("/repo");
vi.spyOn(worktreeModule, "captureBaseline").mockResolvedValue(baseline);
vi.spyOn(worktreeModule, "ensureIsolation").mockResolvedValue(isolationHandle);
vi.spyOn(worktreeModule, "captureDeltaPatch").mockResolvedValue({ rootPatch: "", nestedPatches: [] });
vi.spyOn(worktreeModule, "cleanupIsolation").mockResolvedValue();
}
describe("subagent LSP availability", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("disables LSP for subagents by default", async () => {
mockAgents({
name: "task",
description: "Task agent",
systemPrompt: "Use LSP when useful.",
source: "bundled",
tools: ["lsp"],
});
const { getOptions } = mockCreateAgentSession();
const tool = await TaskTool.create(createSession());
await tool.execute("tool-call", TEST_TASK);
expect(getOptions()?.enableLsp).toBe(false);
});
it("enables subagent LSP when task.enableLsp is set", async () => {
mockAgents({
name: "task",
description: "Task agent",
systemPrompt: "Use normal tools.",
source: "bundled",
tools: ["lsp"],
});
const { getOptions } = mockCreateAgentSession();
const tool = await TaskTool.create(createSession({ taskEnableLsp: true }));
await tool.execute("tool-call", TEST_TASK);
expect(getOptions()?.enableLsp).toBe(true);
expect(getOptions()?.toolNames).toContain("lsp");
});
it("keeps subagent LSP disabled when the parent session disables LSP", async () => {
mockAgents({
name: "task",
description: "Task agent",
systemPrompt: "Use normal tools.",
source: "bundled",
tools: ["lsp"],
});
const { getOptions } = mockCreateAgentSession();
const tool = await TaskTool.create(createSession({ parentEnableLsp: false, taskEnableLsp: true }));
await tool.execute("tool-call", TEST_TASK);
expect(getOptions()?.enableLsp).toBe(false);
});
it("disables LSP for isolated subagents by default", async () => {
mockAgents({
name: "task",
description: "Task agent",
systemPrompt: "Use LSP when useful.",
source: "bundled",
tools: ["lsp"],
});
mockIsolation();
const { getOptions } = mockCreateAgentSession();
const tool = await TaskTool.create(createSession({ isolationMode: "auto" }));
await tool.execute("tool-call", { ...TEST_TASK, isolated: true });
expect(getOptions()?.cwd).toBe("/tmp/isolated-subagent");
expect(getOptions()?.enableLsp).toBe(false);
});
it("opens isolated persisted subagent sessions with the worktree cwd", async () => {
mockAgents({
name: "task",
description: "Task agent",
systemPrompt: "Use normal tools.",
source: "bundled",
tools: ["write"],
});
mockIsolation();
const { getOptions } = mockCreateAgentSession();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-isolated-session-cwd-"));
try {
const parentSessionFile = path.join(tempDir, "parent.jsonl");
const tool = await TaskTool.create(createSession({ isolationMode: "auto", sessionFile: parentSessionFile }));
await tool.execute("tool-call", { ...TEST_TASK, isolated: true });
const sessionManager = getOptions()?.sessionManager as { getCwd?: () => string } | undefined;
expect(getOptions()?.cwd).toBe("/tmp/isolated-subagent");
expect(sessionManager?.getCwd?.()).toBe("/tmp/isolated-subagent");
} finally {
await removeWithRetries(tempDir);
}
});
it("applies plan-mode subagent tools, preserves read-only agent tools, and honors task.enableLsp", async () => {
mockAgents({
name: "task",
description: "Reviewer-like task agent",
systemPrompt: "Review with read-only specialty tools.",
source: "bundled",
tools: ["bash", "ast_grep", "memory_edit", "retain", "todo"],
});
const { getOptions } = mockCreateAgentSession();
const planMode = { enabled: true, planFilePath: "local://PLAN.md" };
const tool = await TaskTool.create(createSession({ planMode, taskEnableLsp: true }));
await tool.execute("tool-call", TEST_TASK);
const toolNames = getOptions()?.toolNames;
expect(getOptions()?.enableLsp).toBe(true);
expect(toolNames).toEqual(["read", "grep", "glob", "lsp", "web_search", "ast_grep", "hub"]);
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("memory_edit");
expect(toolNames).not.toContain("retain");
expect(toolNames).not.toContain("todo");
});
});