41b9567525
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.
292 lines
9.5 KiB
TypeScript
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");
|
|
});
|
|
});
|