Files
oh-my-pi/packages/coding-agent/test/task/subagent-lsp.test.ts
T
Bin Jin 41b9567525 fix(coding-agent): open isolated subagent sessions in worktree cwd
Persisted isolated subagents created their fresh JSONL session through
SessionManager.open(), which fell back to getProjectDir() when the file had no
header. createAgentSession still received the isolated worktree cwd, but built-in
tools resolve paths through sessionManager.getCwd(), so file tools could target
the parent repository while patch capture saw no isolated delta.

Allow SessionManager.open() to take an initial cwd for empty/missing session
files and pass the isolated worktree cwd from task execution. Non-empty resumes
still use the persisted header cwd. Add regression coverage asserting persisted
isolated subagent sessions expose the worktree cwd through their session
manager.
2026-06-15 13:59:44 +08:00

292 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 "@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",
id: "CheckLsp",
description: "Check LSP availability",
assignment: "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 fs.rm(tempDir, { recursive: true, force: true });
}
});
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", "report_finding", "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", "search", "find", "lsp", "web_search", "ast_grep", "report_finding", "irc"]);
expect(toolNames).not.toContain("bash");
expect(toolNames).not.toContain("memory_edit");
expect(toolNames).not.toContain("retain");
expect(toolNames).not.toContain("todo");
});
});