Add MCP event mapping parity e2e tests
This commit is contained in:
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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.
|
- 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.
|
- **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.
|
- 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.
|
- 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.
|
- [ ] **Test (E2E)**: MCP persistence + resume semantics.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user