Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
18 changes: 17 additions & 1 deletion packages/agent/e2e/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export const E2E = {
if (adapter === "claude") {
return process.env.POSTHOG_CODE_E2E_CLAUDE_MODEL || "claude-haiku-4-5";
}
if (adapter === "hog") {
return process.env.POSTHOG_CODE_E2E_HOG_MODEL || "claude-haiku-4-5";
}
// gpt-5-mini is on the product block list, but that gate is only enforced in
// Agent.run — the e2e drives createAcpConnection directly, so it's accepted.
return process.env.POSTHOG_CODE_E2E_CODEX_MODEL || "gpt-5-mini";
Expand All @@ -71,6 +74,11 @@ export const E2E = {
process.env.POSTHOG_CODE_E2E_CLAUDE_STRONG_MODEL || "claude-sonnet-4-5"
);
}
if (adapter === "hog") {
return (
process.env.POSTHOG_CODE_E2E_HOG_STRONG_MODEL || "claude-sonnet-4-5"
);
}
return process.env.POSTHOG_CODE_E2E_CODEX_STRONG_MODEL || "gpt-5.5";
},

Expand All @@ -85,7 +93,7 @@ export const E2E = {

/** Point the adapter at the gateway as the host's `configureEnvironment` does. */
configureEnv(adapter: Adapter): void {
if (adapter === "claude") {
if (adapter === "claude" || adapter === "hog") {
process.env.ANTHROPIC_BASE_URL = GATEWAY_URL;
process.env.ANTHROPIC_AUTH_TOKEN = TOKEN;
return;
Expand All @@ -94,6 +102,14 @@ export const E2E = {
process.env.OPENAI_API_KEY = TOKEN;
},

/** The hogGateway config the hog arm passes through `createAcpConnection`. */
hogGateway(): { gatewayUrl: string; apiKey: string } {
return {
gatewayUrl: GATEWAY_URL,
apiKey: TOKEN,
};
},

/** The codexOptions the codex arm passes through `createAcpConnection`. */
codexOptions(
cwd: string,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/e2e/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function openConnection(opts: {
adapter: Adapter;
cwd: string;
codexOptions?: Record<string, unknown>;
hogGateway?: { gatewayUrl: string; apiKey: string };
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
}): E2EConnection {
const { adapter, cwd } = opts;
Expand Down Expand Up @@ -142,6 +143,7 @@ export function openConnection(opts: {
const acp = createAcpConnection({
adapter,
codexOptions: opts.codexOptions as any,
hogGateway: opts.hogGateway,
onStructuredOutput: opts.onStructuredOutput,
logger,
});
Expand Down Expand Up @@ -196,6 +198,7 @@ export async function openSession(opts: {
adapter: Adapter;
cwd: string;
codexOptions?: Record<string, unknown>;
hogGateway?: { gatewayUrl: string; apiKey: string };
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
meta: Record<string, unknown>;
}): Promise<OpenSession> {
Expand Down
84 changes: 50 additions & 34 deletions packages/agent/e2e/session-lifecycle.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
* invariants + the on-disk edit, never model prose. Opt-in: each arm self-skips
* unless `POSTHOG_CODE_E2E_GATEWAY_PERSONAL_API_KEY` is set (codex also needs the native binary).
*/
const ADAPTERS: Adapter[] = ["claude", "codex"];
const ADAPTERS: Adapter[] = ["claude", "codex", "hog"];

const EDIT_PROMPT =
"Do exactly these steps and nothing else: 1) Read the file target.txt. " +
Expand All @@ -31,8 +31,10 @@ const EDIT_PROMPT =
for (const adapter of ADAPTERS) {
const skip = E2E.skipReason(adapter);
const title = `session lifecycle (${adapter})${skip ? ` — SKIPPED (${skip})` : ""}`;
// Codex-only; skipped on the claude arm so the gap is visible.
// Adapter-specific coverage where the other harnesses do not implement the feature.
const itCodex = adapter === "codex" ? it : it.skip;
const itWithModes = adapter !== "hog" ? it : it.skip;
const itNativeSteer = adapter === "codex" || adapter === "hog" ? it : it.skip;
// Read-only profile only tightens per-turn on macOS + non-cloud (elsewhere the
// spawn is danger-full-access / no profile), so gate to where it actually applies.
const itCodexSandbox =
Expand All @@ -46,6 +48,7 @@ for (const adapter of ADAPTERS) {
let repo: string;
const codexOptions = () =>
adapter === "codex" ? E2E.codexOptions(repo) : undefined;
const hogGateway = () => (adapter === "hog" ? E2E.hogGateway() : undefined);
const meta = (extra: Record<string, unknown> = {}) => ({
systemPrompt: "You are a coding assistant in a tiny test repo.",
model: E2E.model(adapter),
Expand All @@ -69,6 +72,7 @@ for (const adapter of ADAPTERS) {
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
meta: meta(),
});
sessionId = s.sessionId;
Expand Down Expand Up @@ -153,6 +157,7 @@ for (const adapter of ADAPTERS) {
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
meta: meta(),
});
try {
Expand Down Expand Up @@ -190,37 +195,42 @@ for (const adapter of ADAPTERS) {
}
}, 90_000);

// Cloud host switches mode only via setSessionConfigOption(configId:"mode"), so exercise both arms.
it("emits current_mode_update when the mode is switched via setSessionConfigOption", async () => {
const s = await openSession({
adapter,
cwd: repo,
codexOptions: codexOptions(),
meta: meta(),
});
try {
// codex synthesizes modes; claude exposes a "mode" configOption — pick an alternate value.
let value = "read-only";
if (adapter === "claude") {
const modeOpt = (s.newSession.configOptions ?? []).find(
(o) => o.id === "mode",
);
value =
(modeOpt?.options?.find((v) => v.value !== modeOpt.currentValue)
?.value as string) ?? "plan";
}
await s.conn.setSessionConfigOption({
sessionId: s.sessionId,
configId: "mode",
value,
// Cloud host switches mode only via setSessionConfigOption(configId:"mode"), so exercise every harness that exposes a mode picker.
itWithModes(
"emits current_mode_update when the mode is switched via setSessionConfigOption",
async () => {
const s = await openSession({
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
meta: meta(),
});
expect(s.capture.updates("current_mode_update").length).toBeGreaterThan(
0,
);
} finally {
await s.cleanup();
}
}, 60_000);
try {
// codex synthesizes modes; claude exposes a "mode" configOption — pick an alternate value.
let value = "read-only";
if (adapter === "claude") {
const modeOpt = (s.newSession.configOptions ?? []).find(
(o) => o.id === "mode",
);
value =
(modeOpt?.options?.find((v) => v.value !== modeOpt.currentValue)
?.value as string) ?? "plan";
}
await s.conn.setSessionConfigOption({
sessionId: s.sessionId,
configId: "mode",
value,
});
expect(
s.capture.updates("current_mode_update").length,
).toBeGreaterThan(0);
} finally {
await s.cleanup();
}
},
60_000,
);

// Proves the mode picker isn't cosmetic: read-only maps to an OS-level
// :read-only profile that blocks the write even though the host auto-approves.
Expand Down Expand Up @@ -322,6 +332,7 @@ for (const adapter of ADAPTERS) {
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
meta: meta(),
});
try {
Expand Down Expand Up @@ -351,6 +362,7 @@ for (const adapter of ADAPTERS) {
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
meta: meta(),
});
try {
Expand All @@ -377,7 +389,7 @@ for (const adapter of ADAPTERS) {
}
}, 120_000);

itCodex(
itNativeSteer(
"folds a mid-turn prompt into the running turn via steering",
async () => {
const s = await openSession({
Expand Down Expand Up @@ -431,13 +443,14 @@ for (const adapter of ADAPTERS) {
120_000,
);

itCodex(
itNativeSteer(
"lists the session and forks it",
async () => {
const b = openConnection({
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
});
try {
await b.conn.initialize(INIT_PARAMS);
Expand Down Expand Up @@ -468,6 +481,7 @@ for (const adapter of ADAPTERS) {
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
meta: meta(),
});
try {
Expand Down Expand Up @@ -509,6 +523,7 @@ for (const adapter of ADAPTERS) {
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
});
try {
await b.conn.initialize(INIT_PARAMS);
Expand All @@ -530,6 +545,7 @@ for (const adapter of ADAPTERS) {
adapter,
cwd: repo,
codexOptions: codexOptions(),
hogGateway: hogGateway(),
});
try {
await b.conn.initialize(INIT_PARAMS);
Expand Down
1 change: 1 addition & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
"jsonwebtoken": "^9.0.2",
"minimatch": "^10.0.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@posthog/harness": "workspace:*",
"tar": "^7.5.0",
"uuid": "13.0.0",
"yoga-wasm-web": "^0.3.3",
Expand Down
92 changes: 92 additions & 0 deletions packages/agent/src/adapters/acp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import type { GatewayEnv } from "./claude/session/options";
import { nativeCodexBinaryPath } from "./codex-app-server/binary-path";
import { CodexAppServerAgent } from "./codex-app-server/codex-app-server-agent";
import type { CodexOptions } from "./codex-app-server/spawn";
import { HarnessAcpAgent } from "./harness/harness-agent";

export interface HogGatewayOptions {
gatewayUrl?: string;
apiKey?: string;
}

export type AcpConnectionConfig = {
adapter?: Adapter;
Expand All @@ -33,6 +39,8 @@ export type AcpConnectionConfig = {
enricherEnabled?: boolean;
/** Explicit gateway config for the Claude adapter — prevents global process.env mutation. */
claudeGatewayEnv?: GatewayEnv;
/** Explicit gateway config for the hog adapter. */
hogGateway?: HogGatewayOptions;
};

export type AcpConnection = {
Expand All @@ -58,6 +66,10 @@ export function createAcpConnection(
return createCodexConnection(config);
}

if (adapterType === "hog") {
return createHogConnection(config);
}

return createClaudeConnection(config);
}

Expand All @@ -68,6 +80,86 @@ function resolveEnricherApiConfig(
return enabled ? config.posthogApiConfig : undefined;
}

function createHogConnection(config: AcpConnectionConfig): AcpConnection {
const logger =
config.logger?.child("HogConnection") ??
new Logger({ debug: true, prefix: "[HogConnection]" });
const streams = createBidirectionalStreams();

const { logWriter } = config;

let agentWritable = streams.agent.writable;
let clientWritable = streams.client.writable;

if (config.taskRunId && logWriter) {
if (!logWriter.isRegistered(config.taskRunId)) {
logWriter.register(config.taskRunId, {
taskId: config.taskId ?? config.taskRunId,
runId: config.taskRunId,
deviceType: config.deviceType,
});
}

const taskRunId = config.taskRunId;
agentWritable = createTappedWritableStream(streams.agent.writable, {
onMessage: (line) => {
logWriter.appendRawLine(taskRunId, line);
},
logger,
});

clientWritable = createTappedWritableStream(streams.client.writable, {
onMessage: (line) => {
logWriter.appendRawLine(taskRunId, line);
},
logger,
});
} else {
logger.info("Tapped streams NOT enabled for Hog", {
hasTaskRunId: !!config.taskRunId,
hasLogWriter: !!logWriter,
});
}

const agentStream = ndJsonStream(agentWritable, streams.agent.readable);

let agent: HarnessAcpAgent | null = null;
const agentConnection = new AgentSideConnection((client) => {
agent = new HarnessAcpAgent(client, {
allowedModelIds: config.allowedModelIds,
gatewayUrl: config.hogGateway?.gatewayUrl,
apiKey: config.hogGateway?.apiKey,
});
return agent;
}, agentStream);

return {
agentConnection,
clientStreams: {
readable: streams.client.readable,
writable: clientWritable,
},
cleanup: async () => {
logger.info("Cleaning up Hog connection");

if (agent) {
await agent.closeSession();
}

try {
await streams.client.writable.close();
} catch {
// Stream may already be closed
}
try {
await streams.agent.writable.close();
} catch {
// Stream may already be closed
}
},
};
}

function createClaudeConnection(config: AcpConnectionConfig): AcpConnection {
const logger =
config.logger?.child("AcpConnection") ??
Expand Down
Loading
Loading