Skip to content

Commit 7b05c69

Browse files
feat(agent): structured output for codex adapter via stdio MCP (#1885)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c1bd12e commit 7b05c69

10 files changed

Lines changed: 607 additions & 40 deletions

packages/agent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@
117117
},
118118
"dependencies": {
119119
"@agentclientprotocol/sdk": "0.19.0",
120-
"ajv": "^8.17.1",
121120
"@anthropic-ai/claude-agent-sdk": "0.2.112",
122121
"@anthropic-ai/sdk": "0.89.0",
123122
"@hono/node-server": "^1.19.9",
@@ -131,6 +130,7 @@
131130
"hono": "^4.11.7",
132131
"jsonwebtoken": "^9.0.2",
133132
"minimatch": "^10.0.3",
133+
"@modelcontextprotocol/sdk": "1.29.0",
134134
"tar": "^7.5.0",
135135
"uuid": "13.0.0",
136136
"yoga-wasm-web": "^0.3.3",

packages/agent/src/adapters/acp-connection.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection {
205205
codexProcessOptions: config.codexOptions ?? {},
206206
processCallbacks: config.processCallbacks,
207207
posthogApiConfig: resolveEnricherApiConfig(config),
208+
onStructuredOutput: config.onStructuredOutput,
208209
});
209210
return agent;
210211
}, agentStream);

packages/agent/src/adapters/codex/codex-agent.test.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,24 @@ vi.mock("./settings", () => ({
5353
})),
5454
}));
5555

56+
vi.mock("node:fs", async (importActual) => {
57+
const actual = await importActual<typeof import("node:fs")>();
58+
return { ...actual, existsSync: vi.fn(actual.existsSync) };
59+
});
60+
5661
import { CodexAcpAgent } from "./codex-agent";
5762

5863
describe("CodexAcpAgent", () => {
5964
beforeEach(() => {
6065
vi.clearAllMocks();
6166
});
6267

63-
function createAgent(overrides: Partial<AgentSideConnection> = {}): {
68+
function createAgent(
69+
overrides: Partial<AgentSideConnection> = {},
70+
agentOptions?: {
71+
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
72+
},
73+
): {
6474
agent: CodexAcpAgent;
6575
client: AgentSideConnection & {
6676
extNotification: ReturnType<typeof vi.fn>;
@@ -80,6 +90,7 @@ describe("CodexAcpAgent", () => {
8090
codexProcessOptions: {
8191
cwd: process.cwd(),
8292
},
93+
onStructuredOutput: agentOptions?.onStructuredOutput,
8394
});
8495
return { agent, client };
8596
}
@@ -295,6 +306,128 @@ describe("CodexAcpAgent", () => {
295306
).resolves.toEqual({ stopReason: "end_turn" });
296307
});
297308

309+
describe("structured output injection", () => {
310+
const schema = {
311+
type: "object",
312+
properties: { answer: { type: "string" } },
313+
required: ["answer"],
314+
} as const;
315+
316+
beforeEach(async () => {
317+
// The resolver checks existsSync to find the compiled MCP script.
318+
// In unit tests the dist asset isn't on the walk-up path, so we
319+
// make the first candidate succeed. Nothing in this test actually
320+
// spawns the script — the agent only forwards the path to codex-acp.
321+
const fs = await import("node:fs");
322+
vi.mocked(fs.existsSync).mockReturnValue(true);
323+
});
324+
325+
it("injects the create_output MCP server and system-prompt note when jsonSchema and callback are present", async () => {
326+
const { agent } = createAgent({}, { onStructuredOutput: vi.fn() });
327+
mockCodexConnection.newSession.mockResolvedValue({
328+
sessionId: "session-1",
329+
modes: { currentModeId: "auto", availableModes: [] },
330+
configOptions: [],
331+
} satisfies Partial<NewSessionResponse>);
332+
333+
await agent.newSession({
334+
cwd: process.cwd(),
335+
mcpServers: [{ name: "existing", command: "echo", args: [], env: [] }],
336+
_meta: { jsonSchema: schema, systemPrompt: "be terse." },
337+
} as never);
338+
339+
const forwarded = mockCodexConnection.newSession.mock.calls[0][0] as {
340+
mcpServers: Array<{ name: string; command: string; env: unknown }>;
341+
_meta: { systemPrompt: string };
342+
};
343+
344+
// Existing MCP server is preserved; ours is appended.
345+
expect(forwarded.mcpServers).toHaveLength(2);
346+
expect(forwarded.mcpServers[0].name).toBe("existing");
347+
expect(forwarded.mcpServers[1].name).toBe("posthog_output");
348+
expect(forwarded.mcpServers[1].command).toBe(process.execPath);
349+
350+
// The schema is forwarded base64-encoded so codex-acp doesn't have
351+
// to escape it through a shell.
352+
const envEntry = (
353+
forwarded.mcpServers[1].env as Array<{ name: string; value: string }>
354+
).find((e) => e.name === "POSTHOG_OUTPUT_SCHEMA");
355+
expect(envEntry).toBeDefined();
356+
const decoded = JSON.parse(
357+
Buffer.from(envEntry?.value ?? "", "base64").toString("utf-8"),
358+
);
359+
expect(decoded).toEqual(schema);
360+
361+
// Existing systemPrompt is preserved with the structured-output
362+
// instruction appended (not overwritten).
363+
expect(forwarded._meta.systemPrompt.startsWith("be terse.")).toBe(true);
364+
expect(forwarded._meta.systemPrompt).toContain("create_output");
365+
});
366+
367+
it("is a no-op when jsonSchema is absent", async () => {
368+
const { agent } = createAgent({}, { onStructuredOutput: vi.fn() });
369+
mockCodexConnection.newSession.mockResolvedValue({
370+
sessionId: "session-1",
371+
modes: { currentModeId: "auto", availableModes: [] },
372+
configOptions: [],
373+
} satisfies Partial<NewSessionResponse>);
374+
375+
await agent.newSession({
376+
cwd: process.cwd(),
377+
mcpServers: [],
378+
} as never);
379+
380+
const forwarded = mockCodexConnection.newSession.mock.calls[0][0] as {
381+
mcpServers: unknown[];
382+
_meta?: { systemPrompt?: string };
383+
};
384+
expect(forwarded.mcpServers).toEqual([]);
385+
expect(forwarded._meta?.systemPrompt).toBeUndefined();
386+
});
387+
388+
it("is a no-op when onStructuredOutput callback is not wired", async () => {
389+
const { agent } = createAgent();
390+
mockCodexConnection.newSession.mockResolvedValue({
391+
sessionId: "session-1",
392+
modes: { currentModeId: "auto", availableModes: [] },
393+
configOptions: [],
394+
} satisfies Partial<NewSessionResponse>);
395+
396+
await agent.newSession({
397+
cwd: process.cwd(),
398+
mcpServers: [],
399+
_meta: { jsonSchema: schema },
400+
} as never);
401+
402+
const forwarded = mockCodexConnection.newSession.mock.calls[0][0] as {
403+
mcpServers: unknown[];
404+
};
405+
expect(forwarded.mcpServers).toEqual([]);
406+
});
407+
408+
it("also injects on loadSession", async () => {
409+
const { agent } = createAgent({}, { onStructuredOutput: vi.fn() });
410+
mockCodexConnection.loadSession.mockResolvedValue({
411+
modes: { currentModeId: "auto", availableModes: [] },
412+
configOptions: [],
413+
} satisfies Partial<LoadSessionResponse>);
414+
415+
await agent.loadSession({
416+
sessionId: "session-1",
417+
cwd: process.cwd(),
418+
mcpServers: [],
419+
_meta: { jsonSchema: schema },
420+
} as never);
421+
422+
const forwarded = mockCodexConnection.loadSession.mock.calls[0][0] as {
423+
mcpServers: Array<{ name: string }>;
424+
};
425+
expect(forwarded.mcpServers.map((s) => s.name)).toContain(
426+
"posthog_output",
427+
);
428+
});
429+
});
430+
298431
it("broadcasts user prompt as user_message_chunk before delegating to codex-acp", async () => {
299432
const { agent, client } = createAgent();
300433
// Seed an active session so prompt() has the state it expects.

0 commit comments

Comments
 (0)