Add MCP event mapping parity e2e tests

This commit is contained in:
Mohamed Boudra
2025-12-24 17:28:11 +07:00
parent 797425c4e2
commit 4d75bd45e8
2 changed files with 232 additions and 1 deletions

View File

@@ -0,0 +1,230 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type {
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
} from "../agent-sdk-types.js";
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
function tmpCwd(): string {
return mkdtempSync(path.join(os.tmpdir(), "codex-mcp-e2e-"));
}
function useTempCodexSessionDir(): () => void {
const prevSessionDir = process.env.CODEX_SESSION_DIR;
const prevHome = process.env.CODEX_HOME;
return () => {
if (prevSessionDir === undefined) {
delete process.env.CODEX_SESSION_DIR;
} else {
process.env.CODEX_SESSION_DIR = prevSessionDir;
}
if (prevHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = prevHome;
}
};
}
async function loadCodexMcpAgentClient(): Promise<{
new (): {
createSession: (config: AgentSessionConfig) => Promise<{
stream: (prompt: string) => AsyncGenerator<AgentStreamEvent>;
close: () => Promise<void>;
}>;
};
}> {
try {
return (await import("./codex-mcp-agent.js")) as {
CodexMcpAgentClient: new () => {
createSession: (config: AgentSessionConfig) => Promise<{
stream: (prompt: string) => AsyncGenerator<AgentStreamEvent>;
close: () => Promise<void>;
}>;
};
};
} catch (error) {
throw new Error(
`Failed to import codex-mcp-agent: ${(error as Error).message}`
);
}
}
function providerFromEvent(event: AgentStreamEvent): string | undefined {
return (event as { provider?: string }).provider;
}
function extractExitCode(output: unknown): number | undefined {
if (!output || typeof output !== "object") {
return undefined;
}
const outputRecord = output as Record<string, unknown>;
const direct = outputRecord.exitCode ?? outputRecord.exit_code;
if (typeof direct === "number") {
return direct;
}
const metadata = outputRecord.metadata;
if (metadata && typeof metadata === "object") {
const metadataRecord = metadata as Record<string, unknown>;
const metaCode = metadataRecord.exit_code ?? metadataRecord.exitCode;
if (typeof metaCode === "number") {
return metaCode;
}
}
return undefined;
}
describe("CodexMcpAgentClient (MCP integration)", () => {
test(
"maps MCP stream events into timeline items with stable call ids",
async () => {
const cwd = tmpCwd();
const restoreSessionDir = useTempCodexSessionDir();
const { CodexMcpAgentClient } = await loadCodexMcpAgentClient();
const client = new CodexMcpAgentClient();
const config = {
provider: "codex-mcp",
cwd,
modeId: "full-access",
} as AgentSessionConfig;
let session: Awaited<ReturnType<typeof client.createSession>> | null =
null;
const toolCalls: ToolCallItem[] = [];
const rawCommandEvents: unknown[] = [];
let sawAssistant = false;
let sawReasoning = false;
try {
session = await client.createSession(config);
const prompt = [
"1. Run the command `pwd` using your shell tool and wait for it to finish.",
"2. Use apply_patch (not the shell) to create a file named mcp-event.log containing the single line 'ok'.",
"3. After the patch succeeds, reply DONE and stop.",
].join("\n");
for await (const event of session.stream(prompt)) {
const provider = providerFromEvent(event);
if (event.type === "timeline" && provider === "codex-mcp") {
if (event.item.type === "assistant_message") {
sawAssistant = true;
}
if (event.item.type === "reasoning") {
sawReasoning = true;
}
if (event.item.type === "tool_call" && event.item.server !== "permission") {
toolCalls.push(event.item);
}
}
const rawEvent = (event as { type?: string; raw?: unknown }).type === "provider_event"
? (event as { raw?: unknown }).raw
: null;
if (rawEvent && typeof rawEvent === "object") {
const itemType = (rawEvent as { item?: { type?: string } }).item?.type;
if (itemType === "command_execution") {
rawCommandEvents.push(rawEvent);
}
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
expect(sawAssistant).toBe(true);
expect(sawReasoning).toBe(true);
expect(toolCalls.length).toBeGreaterThan(0);
const uniqueIds = new Set(
toolCalls.map((item) => (typeof item.callId === "string" ? item.callId : ""))
);
expect(uniqueIds.size).toBeGreaterThan(0);
for (const toolCall of toolCalls) {
expect(typeof toolCall.callId).toBe("string");
expect((toolCall.callId ?? "").trim().length).toBeGreaterThan(0);
}
const commandToolCall = toolCalls.find(
(item) => item.server === "command"
);
expect(commandToolCall).toBeTruthy();
const exitCode = extractExitCode(commandToolCall?.output);
if (exitCode === undefined) {
throw new Error(
`Missing exit code in command output. Raw events:\n${JSON.stringify(
rawCommandEvents[0] ?? null,
null,
2
)}`
);
}
} finally {
await session?.close();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
180_000
);
test(
"emits an error timeline item for failed MCP turns",
async () => {
const cwd = tmpCwd();
const restoreSessionDir = useTempCodexSessionDir();
const { CodexMcpAgentClient } = await loadCodexMcpAgentClient();
const client = new CodexMcpAgentClient();
const config = {
provider: "codex-mcp",
cwd,
modeId: "full-access",
} as AgentSessionConfig;
let session: Awaited<ReturnType<typeof client.createSession>> | null =
null;
let sawErrorTimeline = false;
const errorEvents: AgentStreamEvent[] = [];
try {
session = await client.createSession(config);
const prompt = [
"Run the command `bash -lc \"exit 7\"` using your shell tool.",
"After the command finishes (even if it fails), reply DONE and stop.",
].join("\n");
for await (const event of session.stream(prompt)) {
const provider = providerFromEvent(event);
if (event.type === "timeline" && provider === "codex-mcp") {
if (event.item.type === "error") {
sawErrorTimeline = true;
}
}
if (event.type === "turn_failed") {
errorEvents.push(event);
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
expect(sawErrorTimeline).toBe(true);
expect(errorEvents.length).toBeGreaterThan(0);
} finally {
await session?.close();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
180_000
);
});

View File

@@ -40,10 +40,11 @@ Reference implementation: `/Users/moboudra/dev/voice-dev/.tmp/happy-cli` (see `s
- Elicitation appears to be exec-only; verify if patch/file change approvals surface separately.
- **Done (2025-12-24 17:18)**: Added MCP behavior matrix/mapping, provider selection notes, and new E2E test tasks for event mapping, persistence, and runtime info.
- [ ] **Test (E2E)**: MCP event payload mapping parity.
- [x] **Test (E2E)**: MCP event payload mapping parity.
- Assert `codex/event` stream maps to assistant/reasoning/tool_call/error timeline items with stable call ids.
- Capture raw event shape for command output/exit code to decide mapping fallback.
- **Done (2025-12-24 17:27)**: Added MCP event mapping parity tests; vitest run fails because `codex-mcp-agent.ts` is not implemented yet.
- [ ] **Test (E2E)**: MCP persistence + resume semantics.