Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.19.0",
"ajv": "^8.17.1",
"@anthropic-ai/claude-agent-sdk": "0.2.112",
"@anthropic-ai/sdk": "0.89.0",
"@hono/node-server": "^1.19.9",
Expand All @@ -131,6 +130,7 @@
"hono": "^4.11.7",
"jsonwebtoken": "^9.0.2",
"minimatch": "^10.0.3",
"@modelcontextprotocol/sdk": "1.29.0",
"tar": "^7.5.0",
"uuid": "13.0.0",
"yoga-wasm-web": "^0.3.3",
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/adapters/acp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection {
codexProcessOptions: config.codexOptions ?? {},
processCallbacks: config.processCallbacks,
posthogApiConfig: resolveEnricherApiConfig(config),
onStructuredOutput: config.onStructuredOutput,
});
return agent;
}, agentStream);
Expand Down
134 changes: 132 additions & 2 deletions packages/agent/src/adapters/codex/codex-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
LoadSessionResponse,
NewSessionResponse,
} from "@agentclientprotocol/sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mockCodexConnection = {
initialize: vi.fn(),
Expand Down Expand Up @@ -60,7 +60,12 @@ describe("CodexAcpAgent", () => {
vi.clearAllMocks();
});

function createAgent(overrides: Partial<AgentSideConnection> = {}): {
function createAgent(
overrides: Partial<AgentSideConnection> = {},
agentOptions?: {
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
},
): {
agent: CodexAcpAgent;
client: AgentSideConnection & {
extNotification: ReturnType<typeof vi.fn>;
Expand All @@ -80,6 +85,7 @@ describe("CodexAcpAgent", () => {
codexProcessOptions: {
cwd: process.cwd(),
},
onStructuredOutput: agentOptions?.onStructuredOutput,
});
return { agent, client };
}
Expand Down Expand Up @@ -295,6 +301,130 @@ describe("CodexAcpAgent", () => {
).resolves.toEqual({ stopReason: "end_turn" });
});

describe("structured output injection", () => {
const schema = {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
} as const;

beforeEach(() => {
// The resolver insists the script path exists. Point at the node
// binary itself — always present, and the agent only forwards the
// path to codex-acp; nothing in this test actually spawns it.
vi.stubEnv("POSTHOG_STRUCTURED_OUTPUT_MCP_SCRIPT", process.execPath);
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("injects the create_output MCP server and system-prompt note when jsonSchema and callback are present", async () => {
const { agent } = createAgent({}, { onStructuredOutput: vi.fn() });
mockCodexConnection.newSession.mockResolvedValue({
sessionId: "session-1",
modes: { currentModeId: "auto", availableModes: [] },
configOptions: [],
} satisfies Partial<NewSessionResponse>);

await agent.newSession({
cwd: process.cwd(),
mcpServers: [{ name: "existing", command: "echo", args: [], env: [] }],
_meta: { jsonSchema: schema, systemPrompt: "be terse." },
} as never);

const forwarded = mockCodexConnection.newSession.mock.calls[0][0] as {
mcpServers: Array<{ name: string; command: string; env: unknown }>;
_meta: { systemPrompt: string };
};

// Existing MCP server is preserved; ours is appended.
expect(forwarded.mcpServers).toHaveLength(2);
expect(forwarded.mcpServers[0].name).toBe("existing");
expect(forwarded.mcpServers[1].name).toBe("posthog_output");
expect(forwarded.mcpServers[1].command).toBe(process.execPath);

// The schema is forwarded base64-encoded so codex-acp doesn't have
// to escape it through a shell.
const envEntry = (
forwarded.mcpServers[1].env as Array<{ name: string; value: string }>
).find((e) => e.name === "POSTHOG_OUTPUT_SCHEMA");
expect(envEntry).toBeDefined();
const decoded = JSON.parse(
Buffer.from(envEntry?.value ?? "", "base64").toString("utf-8"),
);
expect(decoded).toEqual(schema);

// Existing systemPrompt is preserved with the structured-output
// instruction appended (not overwritten).
expect(forwarded._meta.systemPrompt.startsWith("be terse.")).toBe(true);
expect(forwarded._meta.systemPrompt).toContain("create_output");
});

it("is a no-op when jsonSchema is absent", async () => {
const { agent } = createAgent({}, { onStructuredOutput: vi.fn() });
mockCodexConnection.newSession.mockResolvedValue({
sessionId: "session-1",
modes: { currentModeId: "auto", availableModes: [] },
configOptions: [],
} satisfies Partial<NewSessionResponse>);

await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
} as never);

const forwarded = mockCodexConnection.newSession.mock.calls[0][0] as {
mcpServers: unknown[];
_meta?: { systemPrompt?: string };
};
expect(forwarded.mcpServers).toEqual([]);
expect(forwarded._meta?.systemPrompt).toBeUndefined();
});

it("is a no-op when onStructuredOutput callback is not wired", async () => {
const { agent } = createAgent();
mockCodexConnection.newSession.mockResolvedValue({
sessionId: "session-1",
modes: { currentModeId: "auto", availableModes: [] },
configOptions: [],
} satisfies Partial<NewSessionResponse>);

await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { jsonSchema: schema },
} as never);

const forwarded = mockCodexConnection.newSession.mock.calls[0][0] as {
mcpServers: unknown[];
};
expect(forwarded.mcpServers).toEqual([]);
});

it("also injects on loadSession", async () => {
const { agent } = createAgent({}, { onStructuredOutput: vi.fn() });
mockCodexConnection.loadSession.mockResolvedValue({
modes: { currentModeId: "auto", availableModes: [] },
configOptions: [],
} satisfies Partial<LoadSessionResponse>);

await agent.loadSession({
sessionId: "session-1",
cwd: process.cwd(),
mcpServers: [],
_meta: { jsonSchema: schema },
} as never);

const forwarded = mockCodexConnection.loadSession.mock.calls[0][0] as {
mcpServers: Array<{ name: string }>;
};
expect(forwarded.mcpServers.map((s) => s.name)).toContain(
"posthog_output",
);
});
});

it("broadcasts user prompt as user_message_chunk before delegating to codex-acp", async () => {
const { agent, client } = createAgent();
// Seed an active session so prompt() has the state it expects.
Expand Down
Loading
Loading