Files
oh-my-pi/packages/coding-agent/test/sdk-mcp-instructions.test.ts
T
can1357 d214e2f022 Merge branch 'pr-2182'
# Conflicts:
#	packages/coding-agent/package.json
#	packages/coding-agent/src/config/model-registry.ts
#	packages/coding-agent/src/main.ts
#	packages/coding-agent/src/modes/components/assistant-message.ts
#	packages/coding-agent/src/sdk.ts
#	packages/tui/src/components/markdown.ts
2026-06-12 11:24:26 +02:00

117 lines
4.6 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { AuthStorage } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake } from "@oh-my-pi/pi-utils";
import { SERVER_INSTRUCTIONS } from "./fixtures/instructions-mcp";
// Contract: a deferred interactive (`hasUI`) session runs MCP discovery off the
// first-paint path, so an MCP server's `instructions` are not available when the
// prompt is first built. Once the background connection completes — and the
// resulting `refreshMCPTools` rebuilds the system prompt — that server's
// instructions MUST join the prompt for the rest of the session. Regression
// guard: a prior version gated instruction inclusion on `!deferMCPDiscoveryForUI`,
// which dropped server instructions permanently for every UI session.
const FIXTURE_PATH = path.join(import.meta.dir, "fixtures", "instructions-mcp.ts");
describe("createAgentSession MCP server instructions (deferred UI)", () => {
let registryDir: string;
let tempDir: string;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
// Discovery resolves user-level MCP config from `os.homedir()`; redirect it
// to an empty dir so the test connects ONLY to the fixture server and never
// spawns the developer's real MCP servers.
let isolatedHome: string;
beforeAll(async () => {
registryDir = path.join(os.tmpdir(), `pi-sdk-mcp-instr-registry-${Snowflake.next()}`);
fs.mkdirSync(registryDir, { recursive: true });
isolatedHome = path.join(os.tmpdir(), `pi-sdk-mcp-instr-home-${Snowflake.next()}`);
fs.mkdirSync(isolatedHome, { recursive: true });
authStorage = await AuthStorage.create(path.join(registryDir, "auth.db"));
modelRegistry = new ModelRegistry(authStorage);
});
afterAll(() => {
authStorage.close();
for (const dir of [registryDir, isolatedHome]) {
if (dir && fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-sdk-mcp-instr-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
spyOn(os, "homedir").mockReturnValue(isolatedHome);
fs.writeFileSync(
path.join(tempDir, ".mcp.json"),
JSON.stringify({
mcpServers: {
instr: { type: "stdio", command: process.execPath, args: [FIXTURE_PATH] },
},
}),
);
});
afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
mock.restore();
});
it("folds server instructions into the prompt once deferred discovery connects", async () => {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({}),
model: getBundledModel("openai", "gpt-4o-mini"),
disableExtensionDiscovery: true,
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableLsp: false,
skipPythonPreflight: true,
enableMCP: true,
hasUI: true,
});
try {
// First paint: discovery is still in flight, so the server's
// instructions are not yet present.
expect(session.systemPrompt.join("\n")).not.toContain(SERVER_INSTRUCTIONS);
// Background connect + `refreshMCPTools` rebuild must surface the
// instructions. This is a genuine integration wait: discovery spawns
// the fixture as a real subprocess and connects asynchronously, and
// the SDK fires that work fire-and-forget with no completion promise
// or event exposed to await — so fake timers cannot drive it and we
// poll the live prompt with a generous ceiling, exiting the instant
// the rebuilt prompt carries the instructions.
const deadline = Date.now() + 12_000;
let prompt = session.systemPrompt.join("\n");
while (!prompt.includes(SERVER_INSTRUCTIONS) && Date.now() < deadline) {
await Bun.sleep(50);
prompt = session.systemPrompt.join("\n");
}
expect(prompt).toContain(SERVER_INSTRUCTIONS);
// The instructions are framed under the MCP section, not pasted raw.
expect(prompt).toContain("MCP Server Instructions");
} finally {
await session.dispose();
}
}, 20_000);
});