From 7c103aaa0eab939ae31a92da4ea5c394a61d11c7 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 8 Jul 2026 14:06:10 +0200 Subject: [PATCH 1/3] feat(agent): wire up hog (pi) as a selectable runtime adapter --- packages/agent/e2e/config.ts | 18 +- packages/agent/e2e/driver.ts | 3 + .../agent/e2e/session-lifecycle.e2e.test.ts | 84 ++- packages/agent/package.json | 1 + packages/agent/src/adapters/acp-connection.ts | 92 +++ .../src/adapters/harness/harness-agent.ts | 515 ++++++++++++++++ .../agent/src/adapters/reasoning-effort.ts | 3 + packages/agent/src/agent.test.ts | 39 ++ packages/agent/src/agent.ts | 26 +- .../core/src/task-detail/taskCreationSaga.ts | 11 +- packages/harness/package.json | 4 + packages/harness/src/acp.test.ts | 100 +++ packages/harness/src/acp.ts | 580 ++++++++++++++++++ .../posthog-provider/gateway-auth.ts | 5 +- .../extensions/posthog-provider/gateway.ts | 7 + .../src/extensions/posthog-provider/models.ts | 33 +- .../extensions/posthog-provider/provider.ts | 20 +- packages/harness/src/session.ts | 32 +- packages/harness/tsup.config.ts | 1 + packages/shared/src/adapter.ts | 28 +- .../home/config/ActionEditorPanel.tsx | 8 +- .../home/hooks/useRunWorkstreamAction.ts | 4 +- .../components/ConfigureAgentsSection.tsx | 9 +- .../inbox/hooks/useInboxCloudTaskRunner.ts | 4 +- .../components/UnifiedModelSelector.test.tsx | 19 +- .../components/UnifiedModelSelector.tsx | 8 +- .../ui/src/features/settings/settingsStore.ts | 14 +- .../task-detail/components/TaskInput.tsx | 6 +- .../src/services/agent/agent.ts | 76 ++- .../src/services/agent/schemas.ts | 9 +- .../src/services/auth-proxy/auth-proxy.ts | 14 +- pnpm-lock.yaml | 35 +- 32 files changed, 1692 insertions(+), 116 deletions(-) create mode 100644 packages/agent/src/adapters/harness/harness-agent.ts create mode 100644 packages/harness/src/acp.test.ts create mode 100644 packages/harness/src/acp.ts diff --git a/packages/agent/e2e/config.ts b/packages/agent/e2e/config.ts index 66d8a8b950..733ffe4a58 100644 --- a/packages/agent/e2e/config.ts +++ b/packages/agent/e2e/config.ts @@ -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"; @@ -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"; }, @@ -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; @@ -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, diff --git a/packages/agent/e2e/driver.ts b/packages/agent/e2e/driver.ts index d0b4c81d50..1a71e08caf 100644 --- a/packages/agent/e2e/driver.ts +++ b/packages/agent/e2e/driver.ts @@ -86,6 +86,7 @@ export function openConnection(opts: { adapter: Adapter; cwd: string; codexOptions?: Record; + hogGateway?: { gatewayUrl: string; apiKey: string }; onStructuredOutput?: (output: Record) => Promise; }): E2EConnection { const { adapter, cwd } = opts; @@ -142,6 +143,7 @@ export function openConnection(opts: { const acp = createAcpConnection({ adapter, codexOptions: opts.codexOptions as any, + hogGateway: opts.hogGateway, onStructuredOutput: opts.onStructuredOutput, logger, }); @@ -196,6 +198,7 @@ export async function openSession(opts: { adapter: Adapter; cwd: string; codexOptions?: Record; + hogGateway?: { gatewayUrl: string; apiKey: string }; onStructuredOutput?: (output: Record) => Promise; meta: Record; }): Promise { diff --git a/packages/agent/e2e/session-lifecycle.e2e.test.ts b/packages/agent/e2e/session-lifecycle.e2e.test.ts index 695de7ed2a..432ffc0996 100644 --- a/packages/agent/e2e/session-lifecycle.e2e.test.ts +++ b/packages/agent/e2e/session-lifecycle.e2e.test.ts @@ -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. " + @@ -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 = @@ -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 = {}) => ({ systemPrompt: "You are a coding assistant in a tiny test repo.", model: E2E.model(adapter), @@ -69,6 +72,7 @@ for (const adapter of ADAPTERS) { adapter, cwd: repo, codexOptions: codexOptions(), + hogGateway: hogGateway(), meta: meta(), }); sessionId = s.sessionId; @@ -153,6 +157,7 @@ for (const adapter of ADAPTERS) { adapter, cwd: repo, codexOptions: codexOptions(), + hogGateway: hogGateway(), meta: meta(), }); try { @@ -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. @@ -322,6 +332,7 @@ for (const adapter of ADAPTERS) { adapter, cwd: repo, codexOptions: codexOptions(), + hogGateway: hogGateway(), meta: meta(), }); try { @@ -351,6 +362,7 @@ for (const adapter of ADAPTERS) { adapter, cwd: repo, codexOptions: codexOptions(), + hogGateway: hogGateway(), meta: meta(), }); try { @@ -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({ @@ -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); @@ -468,6 +481,7 @@ for (const adapter of ADAPTERS) { adapter, cwd: repo, codexOptions: codexOptions(), + hogGateway: hogGateway(), meta: meta(), }); try { @@ -509,6 +523,7 @@ for (const adapter of ADAPTERS) { adapter, cwd: repo, codexOptions: codexOptions(), + hogGateway: hogGateway(), }); try { await b.conn.initialize(INIT_PARAMS); @@ -530,6 +545,7 @@ for (const adapter of ADAPTERS) { adapter, cwd: repo, codexOptions: codexOptions(), + hogGateway: hogGateway(), }); try { await b.conn.initialize(INIT_PARAMS); diff --git a/packages/agent/package.json b/packages/agent/package.json index a3d4665a7c..139e81806a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -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", diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index 90b2de44e6..4627dd2c05 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -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; @@ -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 = { @@ -58,6 +66,10 @@ export function createAcpConnection( return createCodexConnection(config); } + if (adapterType === "hog") { + return createHogConnection(config); + } + return createClaudeConnection(config); } @@ -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") ?? diff --git a/packages/agent/src/adapters/harness/harness-agent.ts b/packages/agent/src/adapters/harness/harness-agent.ts new file mode 100644 index 0000000000..e8fdbd8cc0 --- /dev/null +++ b/packages/agent/src/adapters/harness/harness-agent.ts @@ -0,0 +1,515 @@ +import { readFileSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import type { + Agent, + AgentSideConnection, + AuthenticateRequest, + CancelNotification, + ForkSessionRequest, + ForkSessionResponse, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + ResumeSessionRequest, + ResumeSessionResponse, + SessionConfigOption, +} from "@agentclientprotocol/sdk"; +import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; +import type { + AgentSession, + EditToolInput, +} from "@earendil-works/pi-coding-agent"; +import { + acpPromptToPi, + buildEditDiffUpdate, + buildHarnessModelSurface, + createHarnessAcpTranslator, + type PiToolResult, + replayPiMessages, +} from "@posthog/harness/acp"; +import { + createHarnessSession, + findHarnessSessionPath, +} from "@posthog/harness/session"; +import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; +import { Logger } from "../../utils/logger"; + +const HOG_ADAPTER = "hog"; + +interface HarnessSessionMeta { + taskRunId?: string; +} + +interface HarnessAcpAgentOptions { + allowedModelIds?: Set; + gatewayUrl?: string; + apiKey?: string; +} + +type ModelState = ReturnType; + +export class HarnessAcpAgent implements Agent { + readonly adapterName = HOG_ADAPTER; + private logger: Logger; + private sessionId: string | null = null; + private session: AgentSession | null = null; + private cancelled = false; + private cwd: string | null = null; + + constructor( + private client: AgentSideConnection, + private options: HarnessAcpAgentOptions = {}, + ) { + this.logger = new Logger({ debug: true, prefix: "[HarnessAcpAgent]" }); + } + + async initialize(_params: InitializeRequest): Promise { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + _meta: { posthog: { steering: "native" } }, + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: true, + }, + sessionCapabilities: { + list: {}, + fork: {}, + resume: {}, + }, + }, + authMethods: [], + }; + } + + async newSession(params: NewSessionRequest): Promise { + const session = await this.openSession(params.cwd, this.meta(params)); + return { sessionId: session.sessionId, ...this.buildModelState() }; + } + + async resumeSession( + params: ResumeSessionRequest, + ): Promise { + await this.openSession(params.cwd, this.meta(params), { + requestedSessionId: params.sessionId, + }); + return this.buildModelState(); + } + + async loadSession(params: LoadSessionRequest): Promise { + const session = await this.openSession(params.cwd, this.meta(params), { + requestedSessionId: params.sessionId, + }); + for (const update of replayPiMessages(session.messages)) { + await this.client.sessionUpdate({ + sessionId: session.sessionId, + update, + }); + } + return this.buildModelState(); + } + + async listSessions( + params: ListSessionsRequest, + ): Promise { + const infos = params.cwd + ? await findSessionsForCwd(params.cwd) + : await findAllSessions(); + return { + sessions: infos.map((info) => ({ + sessionId: info.id, + cwd: info.cwd, + title: info.name ?? null, + updatedAt: info.modified.toISOString(), + })), + }; + } + + async unstable_forkSession( + params: ForkSessionRequest, + ): Promise { + const sourcePath = await findHarnessSessionPath( + params.cwd, + params.sessionId, + ); + if (!sourcePath) { + throw new Error( + `No harness session ${params.sessionId} found under ${params.cwd}`, + ); + } + + const source = await createHarnessSession({ + cwd: params.cwd, + gatewayUrl: this.options.gatewayUrl, + apiKey: this.options.apiKey, + loadFromPath: sourcePath, + }); + + try { + const leafId = source.sessionManager.getLeafId(); + const forkedPath = leafId + ? source.sessionManager.createBranchedSession(leafId) + : undefined; + if (!forkedPath) { + const session = await this.openSession(params.cwd, this.meta(params)); + return { sessionId: session.sessionId, ...this.buildModelState() }; + } + const forked = await this.openSession(params.cwd, this.meta(params), { + loadFromPath: forkedPath, + }); + return { sessionId: forked.sessionId, ...this.buildModelState() }; + } finally { + source.dispose(); + } + } + + private async openSession( + cwd: string, + meta: HarnessSessionMeta | undefined, + options: { requestedSessionId?: string; loadFromPath?: string } = {}, + ): Promise { + let loadFromPath = options.loadFromPath; + if (!loadFromPath && options.requestedSessionId) { + loadFromPath = await findHarnessSessionPath( + cwd, + options.requestedSessionId, + ); + if (!loadFromPath) { + throw new Error( + `No harness session ${options.requestedSessionId} found under ${cwd}`, + ); + } + } + + const session = await createHarnessSession({ + cwd, + gatewayUrl: this.options.gatewayUrl, + apiKey: this.options.apiKey, + ...(loadFromPath ? { loadFromPath } : {}), + }); + this.session?.dispose(); + this.session = session; + this.sessionId = session.sessionId; + this.cwd = cwd; + + this.logger.info( + loadFromPath ? "Resumed harness session" : "Created harness session", + { + sessionId: this.sessionId, + ...(loadFromPath + ? { path: loadFromPath, messageCount: session.messages.length } + : {}), + }, + ); + + if (meta?.taskRunId) { + await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, { + taskRunId: meta.taskRunId, + sessionId: this.sessionId, + adapter: HOG_ADAPTER, + }); + } + + return session; + } + + async prompt(params: PromptRequest): Promise { + const session = this.requireActive(params.sessionId); + this.cancelled = false; + + const translate = createHarnessAcpTranslator({ + resolveContextWindow: (modelId: string) => + session.modelRegistry.find("posthog", modelId)?.contextWindow, + }); + let finalStopReason: PromptResponse["stopReason"] | null = null; + let lastUsage: + | { + inputTokens: number; + outputTokens: number; + cachedReadTokens: number; + cachedWriteTokens: number; + totalTokens: number; + } + | undefined; + + const editArgsByToolCallId = new Map(); + + const unsubscribe = session.subscribe((event) => { + if (event.type === "tool_execution_start" && event.toolName === "edit") { + editArgsByToolCallId.set(event.toolCallId, event.args as EditToolInput); + } + if (event.type === "turn_end") { + const message = event.message; + if (message && "role" in message && message.role === "assistant") { + lastUsage = { + inputTokens: message.usage?.input ?? 0, + outputTokens: message.usage?.output ?? 0, + cachedReadTokens: message.usage?.cacheRead ?? 0, + cachedWriteTokens: message.usage?.cacheWrite ?? 0, + totalTokens: + (message.usage?.input ?? 0) + + (message.usage?.output ?? 0) + + (message.usage?.cacheRead ?? 0) + + (message.usage?.cacheWrite ?? 0), + }; + } + } + const { update, stopReason } = translate(event); + if (update) { + void this.client.sessionUpdate({ + sessionId: params.sessionId, + update, + }); + } + if (stopReason) finalStopReason = stopReason; + + if (event.type === "tool_execution_end" && event.toolName === "edit") { + const editArgs = editArgsByToolCallId.get(event.toolCallId); + editArgsByToolCallId.delete(event.toolCallId); + if (editArgs && !event.isError) { + const resultContent = + (event.result as PiToolResult | undefined)?.content ?? []; + this.enrichEditDiff( + params.sessionId, + event.toolCallId, + editArgs, + resultContent, + ); + } + } + }); + + try { + for (const block of params.prompt) { + if (block.type === "text") { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: block.text }, + }, + }); + } else if (block.type === "image") { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { + type: "image", + data: block.data, + mimeType: block.mimeType, + }, + }, + }); + } + } + + const { text, images } = await acpPromptToPi(params.prompt, { + readTextFile: async (path: string) => { + const res = await this.client.readTextFile({ + sessionId: params.sessionId, + path, + }); + return res.content; + }, + }); + + const meta = + (params._meta as + | { prContext?: unknown; steer?: unknown } + | undefined) ?? {}; + const prContext = + typeof meta.prContext === "string" && meta.prContext.trim().length > 0 + ? `${meta.prContext.trim()}\n\n` + : ""; + const promptText = `${prContext}${text}`; + const promptOptions = images.length > 0 ? { images } : undefined; + + const wasStreaming = session.isStreaming; + const shouldSteer = !!meta.steer || wasStreaming; + if (shouldSteer) { + await session.prompt(promptText, { + ...promptOptions, + streamingBehavior: "steer", + }); + } else { + await session.prompt(promptText, promptOptions); + } + + const stopReason = this.cancelled + ? "cancelled" + : (finalStopReason ?? "end_turn"); + + if (lastUsage && !wasStreaming) { + await this.client.extNotification(POSTHOG_NOTIFICATIONS.TURN_COMPLETE, { + sessionId: params.sessionId, + stopReason, + usage: lastUsage, + }); + } + + return { stopReason }; + } catch (err) { + this.logger.error("Harness prompt failed", { + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } finally { + unsubscribe(); + } + } + + async cancel(params: CancelNotification): Promise { + if (params.sessionId !== this.sessionId || !this.session) return; + this.cancelled = true; + this.logger.info("Cancel requested, aborting harness session"); + try { + await this.session.abort(); + } catch (err) { + this.logger.warn("Harness abort failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + async setSessionConfigOption(params: { + sessionId: string; + configId: string; + value?: unknown; + }): Promise<{ configOptions: SessionConfigOption[] }> { + this.requireActive(params.sessionId); + if (params.configId !== "model") { + return { configOptions: this.buildModelState().configOptions ?? [] }; + } + await this.setModelById(String(params.value)); + return { configOptions: this.buildModelState().configOptions ?? [] }; + } + + async authenticate(_params: AuthenticateRequest): Promise { + throw new Error("Method not implemented."); + } + + async closeSession(): Promise { + this.session?.dispose(); + this.session = null; + this.sessionId = null; + } + + private requireActive(sessionId: string): AgentSession { + if (!this.session || sessionId !== this.sessionId) { + throw new Error("No active harness session"); + } + return this.session; + } + + /** + * Upgrades an `edit` tool call's reported content from a raw diff string + * to a proper `{type: "diff"}` block once the post-edit file content is + * available, sent as a follow-up `tool_call_update` for the same + * `toolCallId`. Best-effort: silently no-ops if the file can't be read, if + * the edit can't be safely reconstructed (see `reconstructEditOldText`), + * or if the session has since moved on — the initial update already + * carried a usable (if less rich) diff, so there's nothing to fall back + * to here. + * + * The read is deliberately synchronous (`readFileSync`), called directly + * from the `tool_execution_end` handler rather than from an awaited async + * function. Node's single JS thread can't run any other code — including + * a subsequent edit to the same file — between when this event fires and + * when the sync read returns, which is what keeps this race-free. An + * awaited async read here would yield back to the event loop first, + * leaving a window where a second edit to the same file could land + * before the read runs, producing a diff that mixes in that later edit's + * changes. + */ + private enrichEditDiff( + sessionId: string, + toolCallId: string, + editArgs: EditToolInput, + resultContent: PiToolResult["content"], + ): void { + if (!this.cwd) return; + let postEditContent: string; + try { + postEditContent = readFileSync( + resolvePath(this.cwd, editArgs.path), + "utf-8", + ); + } catch { + return; + } + if (sessionId !== this.sessionId) return; + const update = buildEditDiffUpdate( + toolCallId, + editArgs.path, + editArgs.edits, + postEditContent, + resultContent, + ); + if (!update) return; + void this.client.sessionUpdate({ sessionId, update }).catch((err) => { + this.logger.warn("Failed to send edit diff upgrade", { + toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + private meta(params: { _meta?: unknown }): HarnessSessionMeta | undefined { + return params._meta as HarnessSessionMeta | undefined; + } + + private buildModelState(): ModelState { + if (!this.session) return {}; + const all = this.session.modelRegistry.getAvailable(); + const filtered = this.options.allowedModelIds + ? all.filter((model) => this.options.allowedModelIds?.has(model.id)) + : all; + return buildHarnessModelSurface(filtered, this.session.model?.id); + } + + private async setModelById(modelId: string): Promise { + const session = this.requireActive(this.sessionId ?? ""); + const model = session.modelRegistry.find("posthog", modelId); + if (!model) { + throw new Error( + `Model ${modelId} not in registry or has no auth configured`, + ); + } + await session.setModel(model); + this.logger.info("Set harness session model", { modelId }); + } +} + +async function findSessionsForCwd( + cwd: string, +): Promise< + Awaited< + ReturnType< + typeof import("@earendil-works/pi-coding-agent")["SessionManager"]["list"] + > + > +> { + const { SessionManager } = await import("@earendil-works/pi-coding-agent"); + return SessionManager.list(cwd); +} + +async function findAllSessions(): Promise< + Awaited< + ReturnType< + typeof import("@earendil-works/pi-coding-agent")["SessionManager"]["listAll"] + > + > +> { + const { SessionManager } = await import("@earendil-works/pi-coding-agent"); + return SessionManager.listAll(); +} diff --git a/packages/agent/src/adapters/reasoning-effort.ts b/packages/agent/src/adapters/reasoning-effort.ts index 590bb3be86..edcf6eedac 100644 --- a/packages/agent/src/adapters/reasoning-effort.ts +++ b/packages/agent/src/adapters/reasoning-effort.ts @@ -18,6 +18,9 @@ export function getReasoningEffortOptions( adapter: Adapter, modelId: string, ): ReasoningEffortOption[] | null { + // The hog/pi adapter doesn't expose a reasoning-effort/thinking-level configOption yet. + if (adapter === "hog") return null; + const options = adapter === "codex" ? getCodexReasoningEffortOptions(modelId) diff --git a/packages/agent/src/agent.test.ts b/packages/agent/src/agent.test.ts index 58ad848186..59851b9c21 100644 --- a/packages/agent/src/agent.test.ts +++ b/packages/agent/src/agent.test.ts @@ -55,4 +55,43 @@ describe("Agent", () => { }), ); }); + + it("passes gateway config and model allow-list through to the hog adapter", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: "claude-haiku-4-5", owned_by: "anthropic" }, + { id: "gpt-5.5", owned_by: "openai" }, + ], + }), + }); + + const agent = new Agent({ + posthog: { + apiUrl: "https://us.posthog.com", + getApiKey: vi.fn().mockResolvedValue("token"), + projectId: 1, + }, + skipLogPersistence: true, + }); + + await agent.run("task-1", "run-1", { + adapter: "hog", + model: "gpt-5.5", + repositoryPath: "/tmp/repo", + }); + + expect(createAcpConnectionMock).toHaveBeenCalledTimes(1); + const [[config]] = createAcpConnectionMock.mock.calls as unknown as [ + [AcpConnectionConfig], + ]; + expect(config.hogGateway).toEqual( + expect.objectContaining({ + gatewayUrl: "https://gateway.us.posthog.com/posthog_code", + apiKey: "token", + }), + ); + expect(config.allowedModelIds).toEqual(new Set(["gpt-5.5"])); + }); }); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 69ae25c34c..b15e022687 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -106,13 +106,30 @@ export class Agent { ? DEFAULT_CODEX_MODEL : codexModelIds[0]; } + } else if (options.adapter === "hog" && gatewayConfig) { + const models = await fetchModelsList({ + gatewayUrl: gatewayConfig.gatewayUrl, + }); + const hogModelIds = models + .filter((model) => !isBlockedModelId(model.id)) + .map((model) => model.id); + + if (hogModelIds.length > 0) { + allowedModelIds = new Set(hogModelIds); + } + + if (!sanitizedModel || !allowedModelIds?.has(sanitizedModel)) { + sanitizedModel = hogModelIds.includes(DEFAULT_GATEWAY_MODEL) + ? DEFAULT_GATEWAY_MODEL + : hogModelIds[0]; + } } if (!sanitizedModel && options.adapter !== "codex") { sanitizedModel = DEFAULT_GATEWAY_MODEL; } const claudeGatewayEnv: GatewayEnv | undefined = - options.adapter !== "codex" && gatewayConfig + options.adapter === "claude" && gatewayConfig ? { anthropicBaseUrl: gatewayConfig.gatewayUrl, anthropicAuthToken: gatewayConfig.apiKey, @@ -148,6 +165,13 @@ export class Agent { additionalDirectories: options.additionalDirectories, } : undefined, + hogGateway: + options.adapter === "hog" && gatewayConfig + ? { + gatewayUrl: gatewayConfig.gatewayUrl, + apiKey: gatewayConfig.apiKey, + } + : undefined, }); return this.acpConnection; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index f0b8d1528e..91d56b85fd 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -14,6 +14,7 @@ import { type SagaLogger, type TaskCreationInput, type TaskCreationOutput, + toCloudAdapter, type Workspace, } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; @@ -391,7 +392,9 @@ export class TaskCreationSaga extends Saga< // A cloud run always needs an explicit runtime adapter — the API rejects // `initial_permission_mode` unless `runtime_adapter` is set. Callers that don't pick one // (e.g. canvas generation) default to claude, matching the local-connect default below. - const cloudAdapter = input.adapter ?? "claude"; + // "hog" (pi) is local-only — the cloud sandbox has no runtime for it, so a + // cloud run silently falls back to claude instead of rejecting. + const cloudAdapter = toCloudAdapter(input.adapter ?? "claude"); const taskRun = await this.deps.posthogClient.createTaskRun(task.id, { environment: "cloud", mode: "interactive", @@ -674,7 +677,7 @@ export class TaskCreationSaga extends Saga< ? this.deps.host.takeWarmTaskLease({ repository: input.repository, branch: input.branch ?? null, - runtimeAdapter: input.adapter ?? null, + runtimeAdapter: input.adapter ? toCloudAdapter(input.adapter) : null, model: input.model ?? null, reasoningEffort: input.reasoningLevel ?? null, }) @@ -755,7 +758,9 @@ export class TaskCreationSaga extends Saga< : undefined, runtime_adapter: input.workspaceMode === "cloud" - ? (input.adapter ?? null) + ? input.adapter + ? toCloudAdapter(input.adapter) + : null : undefined, model: input.workspaceMode === "cloud" ? (input.model ?? null) : undefined, diff --git a/packages/harness/package.json b/packages/harness/package.json index 5d16a0c4f9..48655275c4 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -15,6 +15,10 @@ "types": "./dist/session.d.ts", "import": "./dist/session.js" }, + "./acp": { + "types": "./dist/acp.d.ts", + "import": "./dist/acp.js" + }, "./spawn": { "types": "./dist/spawn.d.ts", "import": "./dist/spawn.js" diff --git a/packages/harness/src/acp.test.ts b/packages/harness/src/acp.test.ts new file mode 100644 index 0000000000..55243f5350 --- /dev/null +++ b/packages/harness/src/acp.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { buildEditDiffUpdate, reconstructEditOldText } from "./acp"; + +describe("reconstructEditOldText", () => { + it("reverses a single unambiguous edit against the post-edit content", () => { + const postEdit = "line1\nHELLO WORLD\nline3\n"; + const result = reconstructEditOldText(postEdit, [ + { oldText: "hello world", newText: "HELLO WORLD" }, + ]); + expect(result).toBe("line1\nhello world\nline3\n"); + }); + + it("returns undefined for multi-edit calls (can't safely reverse without pi's resolved offsets)", () => { + const postEdit = "AAA\nBBB\n"; + const result = reconstructEditOldText(postEdit, [ + { oldText: "aaa", newText: "AAA" }, + { oldText: "bbb", newText: "BBB" }, + ]); + expect(result).toBeUndefined(); + }); + + it("returns undefined when newText doesn't appear in the post-edit content", () => { + const result = reconstructEditOldText("unrelated content", [ + { oldText: "old", newText: "new" }, + ]); + expect(result).toBeUndefined(); + }); + + it("returns undefined when newText is ambiguous (appears more than once)", () => { + const postEdit = "dup\ndup\n"; + const result = reconstructEditOldText(postEdit, [ + { oldText: "orig", newText: "dup" }, + ]); + expect(result).toBeUndefined(); + }); +}); + +describe("buildEditDiffUpdate", () => { + it("builds a diff tool_call_update when reconstruction succeeds", () => { + const update = buildEditDiffUpdate( + "call-1", + "/repo/file.ts", + [{ oldText: "foo", newText: "bar" }], + "const x = bar;", + [], + ); + expect(update).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + content: [ + { + type: "diff", + path: "/repo/file.ts", + oldText: "const x = foo;", + newText: "const x = bar;", + }, + ], + }); + }); + + it("carries the tool result's own content alongside the diff block", () => { + const update = buildEditDiffUpdate( + "call-1", + "/repo/file.ts", + [{ oldText: "foo", newText: "bar" }], + "const x = bar;", + [{ type: "text", text: "Edited successfully" }], + ); + expect(update).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + content: [ + { + type: "diff", + path: "/repo/file.ts", + oldText: "const x = foo;", + newText: "const x = bar;", + }, + { + type: "content", + content: { type: "text", text: "Edited successfully" }, + }, + ], + }); + }); + + it("returns undefined when reconstruction fails", () => { + const update = buildEditDiffUpdate( + "call-1", + "/repo/file.ts", + [ + { oldText: "a", newText: "b" }, + { oldText: "c", newText: "d" }, + ], + "irrelevant", + [], + ); + expect(update).toBeUndefined(); + }); +}); diff --git a/packages/harness/src/acp.ts b/packages/harness/src/acp.ts new file mode 100644 index 0000000000..0414c60b96 --- /dev/null +++ b/packages/harness/src/acp.ts @@ -0,0 +1,580 @@ +import type { + ContentBlock, + ModelInfo, + SessionConfigOption, + SessionModelState, + SessionUpdate, + StopReason, + ToolCallContent, + ToolCallLocation, + ToolKind, +} from "@agentclientprotocol/sdk"; +import type { + Api, + ImageContent, + Model, + StopReason as PiStopReason, + TextContent, +} from "@earendil-works/pi-ai"; +import type { + AgentSession, + AgentSessionEvent, + BashToolInput, + EditToolDetails, + EditToolInput, + GrepToolInput, + LsToolInput, + ReadToolInput, + WriteToolInput, +} from "@earendil-works/pi-coding-agent"; +import { POSTHOG_PROVIDER_NAME } from "./extensions/posthog-provider/provider"; + +export interface PiToolResult { + content: (TextContent | ImageContent)[]; + details?: TDetails; + isError?: boolean; +} + +const LATEST_SUFFIX = /\s*\(latest\)\s*$/i; +const LATEST_MARKER = /\(latest\)/i; + +function stripLatestSuffix(name: string): string { + return name.replace(LATEST_SUFFIX, ""); +} + +function dedupeToLatest>(models: T[]): T[] { + const byName = new Map(); + for (const model of models) { + const base = stripLatestSuffix(model.name); + const existing = byName.get(base); + if ( + !existing || + (LATEST_MARKER.test(model.name) && !LATEST_MARKER.test(existing.name)) + ) { + byName.set(base, model); + } + } + return Array.from(byName.values()); +} + +export function buildHarnessModelSurface( + available: Array>, + currentModelId: string | undefined, +): { models?: SessionModelState; configOptions?: SessionConfigOption[] } { + const deduped = dedupeToLatest( + available.filter((model) => model.provider === POSTHOG_PROVIDER_NAME), + ); + if (deduped.length === 0) return {}; + + const items = deduped.map((model) => ({ + id: model.id, + name: stripLatestSuffix(model.name), + })); + const currentId = + currentModelId && items.some((item) => item.id === currentModelId) + ? currentModelId + : items[0]?.id; + if (!currentId) return {}; + + const availableModels: ModelInfo[] = items.map(({ id, name }) => ({ + modelId: id, + name, + })); + const modelOption: SessionConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: currentId, + options: items.map(({ id, name }) => ({ value: id, name })), + category: "model", + description: "Choose which model the agent should use", + }; + + return { + models: { availableModels, currentModelId: currentId }, + configOptions: [modelOption], + }; +} + +export interface AcpPromptToPiOptions { + readTextFile?: (path: string) => Promise; +} + +function fileUriToPath(uri: string): string | undefined { + if (!uri.startsWith("file://")) return undefined; + try { + return decodeURIComponent(new URL(uri).pathname); + } catch { + return undefined; + } +} + +function wrapFile(pathOrUri: string, content: string): string { + return `\n${content}\n\n`; +} + +export async function acpPromptToPi( + prompt: ContentBlock[], + options: AcpPromptToPiOptions = {}, +): Promise<{ text: string; images: ImageContent[] }> { + const textParts: string[] = []; + const images: ImageContent[] = []; + + for (const block of prompt) { + if (block.type === "text") { + textParts.push(block.text); + continue; + } + + if (block.type === "image") { + images.push({ + type: "image", + data: block.data, + mimeType: block.mimeType, + }); + continue; + } + + if (block.type === "resource") { + const resource = block.resource; + if ("text" in resource) { + textParts.push(wrapFile(resource.uri, resource.text)); + } + continue; + } + + if (block.type === "resource_link") { + const path = fileUriToPath(block.uri); + if (path && options.readTextFile) { + try { + const content = await options.readTextFile(path); + textParts.push(wrapFile(path, content)); + continue; + } catch { + // Fall through to an inline URI reference. + } + } + textParts.push(`\n`); + } + } + + return { text: textParts.join(""), images }; +} + +const KIND_BY_TOOL: Record = { + read: "read", + ls: "read", + bash: "execute", + edit: "edit", + write: "edit", + grep: "search", + find: "search", +}; + +function locationsFor( + toolName: string, + args: unknown, +): ToolCallLocation[] | undefined { + switch (toolName) { + case "read": { + const input = args as ReadToolInput; + return [ + { + path: input.path, + ...(input.offset ? { line: input.offset } : {}), + }, + ]; + } + case "edit": + return [{ path: (args as EditToolInput).path }]; + case "write": + return [{ path: (args as WriteToolInput).path }]; + case "ls": { + const path = (args as LsToolInput).path; + return path ? [{ path }] : undefined; + } + default: + return undefined; + } +} + +function titleFor(toolName: string, args: unknown): string { + switch (toolName) { + case "read": + return `Read ${(args as ReadToolInput).path}`; + case "ls": + return `List ${(args as LsToolInput).path}`; + case "edit": + return `Edit ${(args as EditToolInput).path}`; + case "write": + return `Write ${(args as WriteToolInput).path}`; + case "bash": { + const command = (args as BashToolInput).command; + return command.split("\n")[0]?.slice(0, 80) ?? "bash"; + } + case "grep": + return `Grep ${(args as GrepToolInput).pattern}`; + case "find": { + const input = args as { pattern?: string; name?: string }; + return `Find ${input.pattern ?? input.name ?? ""}`.trim(); + } + default: + return toolName; + } +} + +function buildToolCallStart( + toolCallId: string, + toolName: string, + args: unknown, +): SessionUpdate { + const locations = locationsFor(toolName, args); + return { + sessionUpdate: "tool_call", + toolCallId, + title: titleFor(toolName, args), + kind: KIND_BY_TOOL[toolName] ?? "other", + status: "in_progress", + rawInput: args, + ...(locations ? { locations } : {}), + }; +} + +function piContentToAcp( + content: (TextContent | ImageContent)[], +): ToolCallContent[] { + return content.map((item) => + item.type === "text" + ? { type: "content", content: { type: "text", text: item.text } } + : { + type: "content", + content: { + type: "image", + data: item.data, + mimeType: item.mimeType, + }, + }, + ); +} + +function endContent( + toolName: string, + args: unknown, + result: PiToolResult, +): ToolCallContent[] { + const items: ToolCallContent[] = []; + + switch (toolName) { + case "write": { + const input = args as WriteToolInput; + items.push({ + type: "diff", + path: input.path, + oldText: null, + newText: input.content, + }); + break; + } + case "edit": { + const details = result.details as EditToolDetails | undefined; + if (details?.diff) { + items.push({ + type: "content", + content: { type: "text", text: details.diff }, + }); + } + break; + } + } + + items.push(...piContentToAcp(result.content)); + return items; +} + +/** + * Reverses a single edit against the post-edit file content to recover the + * pre-edit full file text, so the client can render a proper before/after + * diff instead of a raw patch string. + * + * Pi's actual edit tool matches `oldText` with fuzzy normalization and + * applies replacements at resolved offsets against the original content (see + * `applyEditsToNormalizedContent` in pi-coding-agent) rather than by + * re-searching text sequentially. Blindly replaying multiple edits in + * reverse over the same content string doesn't have access to those + * resolved offsets and can pick the wrong occurrence, so this only attempts + * the single-edit, unambiguous case (exactly one `{oldText, newText}` pair + * whose `newText` appears exactly once in the post-edit content). Anything + * else returns `undefined` and callers should keep the existing raw-diff + * fallback. + */ +export function reconstructEditOldText( + postEditContent: string, + edits: EditToolInput["edits"], +): string | undefined { + if (edits.length !== 1) return undefined; + const { oldText, newText } = edits[0]; + const firstIndex = postEditContent.indexOf(newText); + if (firstIndex === -1) return undefined; + if (postEditContent.indexOf(newText, firstIndex + 1) !== -1) { + return undefined; + } + return ( + postEditContent.slice(0, firstIndex) + + oldText + + postEditContent.slice(firstIndex + newText.length) + ); +} + +/** + * Builds a follow-up `tool_call_update` upgrading an `edit` tool call's + * content from the raw diff text emitted at completion time to a proper + * `{type: "diff"}` block, once the post-edit file content is available. + * Callers read the file synchronously right when `tool_execution_end` fires + * (see `enrichEditDiff` in harness-agent.ts — using `readFileSync` there, + * not an awaited read, is what keeps this race-free against a second edit + * to the same file) and send this as a second update for the same + * `toolCallId` — the same pattern already used for `tool_execution_update` + * partial-progress content. + * + * The client replaces a tool call's `content` wholesale on each update + * (see `buildConversationItems.ts`'s `Object.assign`), so `resultContent` + * — the same `result.content` the initial completion update included via + * `piContentToAcp` — must be carried forward here too, or the upgrade + * silently erases any content the edit tool itself returned. + * + * Returns `undefined` when the edit can't be safely reconstructed (see + * {@link reconstructEditOldText}), in which case callers should skip sending + * an upgrade and leave the raw-diff-as-text content from the initial update. + */ +export function buildEditDiffUpdate( + toolCallId: string, + path: string, + edits: EditToolInput["edits"], + postEditContent: string, + resultContent: PiToolResult["content"], +): SessionUpdate | undefined { + const oldText = reconstructEditOldText(postEditContent, edits); + if (oldText === undefined) return undefined; + return { + sessionUpdate: "tool_call_update", + toolCallId, + content: [ + { type: "diff", path, oldText, newText: postEditContent }, + ...piContentToAcp(resultContent), + ], + }; +} + +function buildToolCallEnd( + toolCallId: string, + toolName: string, + args: unknown, + result: PiToolResult, + isError: boolean, +): SessionUpdate { + return { + sessionUpdate: "tool_call_update", + toolCallId, + status: isError ? "failed" : "completed", + content: endContent(toolName, args, result), + }; +} + +function piStopReasonToAcp(reason: PiStopReason): StopReason { + switch (reason) { + case "stop": + return "end_turn"; + case "length": + return "max_tokens"; + case "toolUse": + return "max_turn_requests"; + case "aborted": + return "cancelled"; + case "error": + return "refusal"; + } +} + +export type ContextWindowResolver = (modelId: string) => number | undefined; + +export interface HarnessTranslateResult { + update?: SessionUpdate; + stopReason?: StopReason; +} + +export function createHarnessAcpTranslator(options: { + resolveContextWindow: ContextWindowResolver; +}): (event: AgentSessionEvent) => HarnessTranslateResult { + const { resolveContextWindow } = options; + const argsByToolCallId = new Map(); + let cumulativeCostUsd = 0; + + return (event: AgentSessionEvent): HarnessTranslateResult => { + if (event.type === "message_update") { + const inner = event.assistantMessageEvent; + if (inner.type === "text_delta") { + return { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: inner.delta }, + }, + }; + } + if (inner.type === "thinking_delta") { + return { + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: inner.delta }, + }, + }; + } + return {}; + } + + if (event.type === "tool_execution_start") { + argsByToolCallId.set(event.toolCallId, event.args); + return { + update: buildToolCallStart( + event.toolCallId, + event.toolName, + event.args, + ), + }; + } + + if (event.type === "tool_execution_update") { + const partial = event.partialResult as PiToolResult | null | undefined; + if (!partial?.content?.length) return {}; + return { + update: { + sessionUpdate: "tool_call_update", + toolCallId: event.toolCallId, + content: piContentToAcp(partial.content), + }, + }; + } + + if (event.type === "tool_execution_end") { + const args = argsByToolCallId.get(event.toolCallId); + argsByToolCallId.delete(event.toolCallId); + return { + update: buildToolCallEnd( + event.toolCallId, + event.toolName, + args, + event.result as PiToolResult, + event.isError, + ), + }; + } + + if (event.type === "turn_end") { + const message = event.message; + if (!message || !("role" in message) || message.role !== "assistant") { + return {}; + } + if (!message.usage) return {}; + cumulativeCostUsd += message.usage.cost?.total ?? 0; + const size = resolveContextWindow(message.model); + if (size === undefined) return {}; + return { + update: { + sessionUpdate: "usage_update", + size, + used: (message.usage.input ?? 0) + (message.usage.output ?? 0), + cost: { amount: cumulativeCostUsd, currency: "USD" }, + }, + }; + } + + if (event.type === "agent_end") { + for (let i = event.messages.length - 1; i >= 0; i--) { + const message = event.messages[i]; + if (message && "role" in message && message.role === "assistant") { + return { stopReason: piStopReasonToAcp(message.stopReason) }; + } + } + } + + return {}; + }; +} + +export function replayPiMessages( + messages: AgentSession["messages"], +): SessionUpdate[] { + const updates: SessionUpdate[] = []; + const argsByToolCallId = new Map(); + + for (const message of messages) { + if (!("role" in message)) continue; + + if (message.role === "user") { + const items = + typeof message.content === "string" + ? message.content + ? [{ type: "text" as const, text: message.content }] + : [] + : message.content; + + for (const item of items) { + if (item.type === "text" && item.text) { + updates.push({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: item.text }, + }); + } else if (item.type === "image") { + updates.push({ + sessionUpdate: "user_message_chunk", + content: { + type: "image", + data: item.data, + mimeType: item.mimeType, + }, + }); + } + } + continue; + } + + if (message.role === "assistant") { + for (const item of message.content) { + if (item.type === "text" && item.text) { + updates.push({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: item.text }, + }); + } else if (item.type === "thinking" && item.thinking) { + updates.push({ + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: item.thinking }, + }); + } else if (item.type === "toolCall") { + argsByToolCallId.set(item.id, item.arguments); + updates.push(buildToolCallStart(item.id, item.name, item.arguments)); + } + } + continue; + } + + if (message.role === "toolResult") { + const args = argsByToolCallId.get(message.toolCallId); + argsByToolCallId.delete(message.toolCallId); + updates.push( + buildToolCallEnd( + message.toolCallId, + message.toolName, + args, + { + content: message.content, + details: message.details, + isError: message.isError, + }, + message.isError, + ), + ); + } + } + + return updates; +} diff --git a/packages/harness/src/extensions/posthog-provider/gateway-auth.ts b/packages/harness/src/extensions/posthog-provider/gateway-auth.ts index 3e37b16ef7..ccbbe95239 100644 --- a/packages/harness/src/extensions/posthog-provider/gateway-auth.ts +++ b/packages/harness/src/extensions/posthog-provider/gateway-auth.ts @@ -1,5 +1,5 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { getLlmGatewayUrl, resolveRegion } from "./gateway"; +import { resolveGatewayUrl } from "./gateway"; import { POSTHOG_PROVIDER_NAME, type PosthogProviderOptions } from "./provider"; export interface GatewayAuth { @@ -24,8 +24,7 @@ export async function resolveGatewayAuth( options: PosthogProviderOptions, ctx: ExtensionContext, ): Promise { - const region = resolveRegion(options.region); - const baseUrl = getLlmGatewayUrl(region); + const baseUrl = resolveGatewayUrl(options); const apiKey = options.apiKey ?? diff --git a/packages/harness/src/extensions/posthog-provider/gateway.ts b/packages/harness/src/extensions/posthog-provider/gateway.ts index bb56be3fdd..171f91c0a8 100644 --- a/packages/harness/src/extensions/posthog-provider/gateway.ts +++ b/packages/harness/src/extensions/posthog-provider/gateway.ts @@ -16,6 +16,13 @@ export function getLlmGatewayUrl(region: CloudRegion): string { return `${getGatewayBaseUrl(region)}/${GATEWAY_PRODUCT}`; } +/** Resolves the gateway URL, honoring an explicit override (e.g. for tests or non-standard gateway targets). */ +export function resolveGatewayUrl( + options: { region?: CloudRegion; gatewayUrl?: string } = {}, +): string { + return options.gatewayUrl ?? getLlmGatewayUrl(resolveRegion(options.region)); +} + /** * Returns the region only when one was actually configured (an explicit * option or a valid `POSTHOG_REGION`), or `undefined` when the caller should diff --git a/packages/harness/src/extensions/posthog-provider/models.ts b/packages/harness/src/extensions/posthog-provider/models.ts index 4c091a656a..c33e5698b6 100644 --- a/packages/harness/src/extensions/posthog-provider/models.ts +++ b/packages/harness/src/extensions/posthog-provider/models.ts @@ -35,14 +35,21 @@ function detectFamily(model: GatewayModel): ModelFamily { * product root. */ export function gatewayBaseUrlForApi(api: string, region: CloudRegion): string { - return api === "openai-responses" - ? `${getLlmGatewayUrl(region)}/v1` - : getLlmGatewayUrl(region); + return gatewayBaseUrlForApiWithGatewayUrl(api, getLlmGatewayUrl(region)); +} + +export function gatewayBaseUrlForApiWithGatewayUrl( + api: string, + gatewayUrl: string, +): string { + const normalized = gatewayUrl.replace(/\/+$/, ""); + return api === "openai-responses" ? `${normalized}/v1` : normalized; } function toModelConfig( model: GatewayModel, region: CloudRegion, + gatewayUrl?: string, ): ProviderModelConfig { const family = detectFamily(model); const name = model.display_name ?? model.id; @@ -56,7 +63,9 @@ function toModelConfig( id: model.id, name, api: "openai-responses", - baseUrl: gatewayBaseUrlForApi("openai-responses", region), + baseUrl: gatewayUrl + ? gatewayBaseUrlForApiWithGatewayUrl("openai-responses", gatewayUrl) + : gatewayBaseUrlForApi("openai-responses", region), reasoning: true, input, cost: ZERO_COST, @@ -157,18 +166,23 @@ const FALLBACK_GATEWAY_MODELS: GatewayModel[] = [ export function fallbackModelConfigs( region: CloudRegion, + gatewayUrl?: string, ): ProviderModelConfig[] { - return FALLBACK_GATEWAY_MODELS.map((model) => toModelConfig(model, region)); + return FALLBACK_GATEWAY_MODELS.map((model) => + toModelConfig(model, region, gatewayUrl), + ); } async function fetchGatewayModels( region: CloudRegion, + gatewayUrl?: string, ): Promise { if (process.env.PI_OFFLINE || process.env.HARNESS_STATIC_MODELS) { return []; } try { - const response = await fetch(`${getLlmGatewayUrl(region)}/v1/models`, { + const baseUrl = gatewayUrl?.replace(/\/+$/, "") ?? getLlmGatewayUrl(region); + const response = await fetch(`${baseUrl}/v1/models`, { signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), }); if (!response.ok) { @@ -183,12 +197,13 @@ async function fetchGatewayModels( export async function resolveModelConfigs( region: CloudRegion, + gatewayUrl?: string, ): Promise { - const live = await fetchGatewayModels(region); + const live = await fetchGatewayModels(region, gatewayUrl); if (live.length === 0) { - return fallbackModelConfigs(region); + return fallbackModelConfigs(region, gatewayUrl); } return live .filter((model) => Boolean(model.id)) - .map((model) => toModelConfig(model, region)); + .map((model) => toModelConfig(model, region, gatewayUrl)); } diff --git a/packages/harness/src/extensions/posthog-provider/provider.ts b/packages/harness/src/extensions/posthog-provider/provider.ts index 856b8834bc..ace9779147 100644 --- a/packages/harness/src/extensions/posthog-provider/provider.ts +++ b/packages/harness/src/extensions/posthog-provider/provider.ts @@ -5,8 +5,8 @@ import type { } from "@earendil-works/pi-coding-agent"; import type { CloudRegion } from "@posthog/shared"; import { - getLlmGatewayUrl, resolveExplicitRegion, + resolveGatewayUrl, resolveRegion, } from "./gateway"; import { gatewayBaseUrlForApi, resolveModelConfigs } from "./models"; @@ -17,6 +17,8 @@ export const POSTHOG_PROVIDER_NAME = "posthog"; export interface PosthogProviderOptions { region?: CloudRegion; apiKey?: string; + /** Full gateway product URL override, e.g. `https://gateway.us.posthog.com/posthog_code` or `http://localhost:3308/ci`. */ + gatewayUrl?: string; } /** @@ -48,18 +50,24 @@ export function buildPosthogProvider( const explicitRegion = resolveExplicitRegion(options.region); const config: ProviderConfig = { name: "PostHog", - baseUrl: getLlmGatewayUrl(region), + baseUrl: resolveGatewayUrl(options), api: "anthropic-messages", models, - oauth: { + }; + + // A static gatewayUrl override (e.g. headless/tests) skips OAuth entirely — + // there's no interactive login flow to run against a fixed target. + if (!options.gatewayUrl) { + config.oauth = { name: "PostHog", login: (callbacks) => loginPosthog(callbacks, explicitRegion), refreshToken: (credentials) => refreshPosthog(region, credentials), getApiKey: (credentials) => String(credentials.access), modifyModels: (models, credentials) => remapModelsToCredentialRegion(models, credentials, region), - }, - }; + }; + } + if (options.apiKey) { config.apiKey = options.apiKey; } @@ -70,6 +78,6 @@ export async function resolvePosthogProvider( options: PosthogProviderOptions = {}, ): Promise { const region = resolveRegion(options.region); - const models = await resolveModelConfigs(region); + const models = await resolveModelConfigs(region, options.gatewayUrl); return buildPosthogProvider(models, options); } diff --git a/packages/harness/src/session.ts b/packages/harness/src/session.ts index d23c6fad7a..a2befbddfb 100644 --- a/packages/harness/src/session.ts +++ b/packages/harness/src/session.ts @@ -5,6 +5,7 @@ import { DefaultResourceLoader, getAgentDir, ModelRegistry, + SessionManager, } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MODEL } from "./extensions/posthog-provider/models"; import { @@ -12,17 +13,20 @@ import { type PosthogProviderOptions, resolvePosthogProvider, } from "./extensions/posthog-provider/provider"; -import { mcpAdapterExtensionFile } from "./spawn"; +import { createWebAccessExtension } from "./extensions/web-access/extension"; export interface HarnessSessionOptions extends PosthogProviderOptions { cwd?: string; model?: string; + loadFromPath?: string; + agentDir?: string; } export async function createHarnessSession( options: HarnessSessionOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); + const agentDir = options.agentDir ?? getAgentDir(); const authStorage = AuthStorage.create(); const modelRegistry = ModelRegistry.create(authStorage); @@ -38,12 +42,15 @@ export async function createHarnessSession( const resourceLoader = new DefaultResourceLoader({ cwd, - agentDir: getAgentDir(), - extensionFactories: [], - // `pi-mcp-adapter` ships raw TypeScript with no compiled entry point, so - // it must be loaded by file path (see mcpAdapterExtensionFile) rather - // than as an extension factory. - additionalExtensionPaths: [mcpAdapterExtensionFile()], + agentDir, + // Only the model provider (registered above) + web tools — not the full + // harness extension registry, and no `pi-mcp-adapter` (additionalExtensionPaths). + // hog-branding (TUI chrome), subagent (spawns child pi processes), and + // pi-mcp-adapter (interactive MCP setup UI/OAuth flows) all assume a real + // CLI/TUI runtime and aren't safe for an embedded SDK session — this + // adapter doesn't forward ACP `mcpServers` into pi anyway, so there's + // nothing for pi-mcp-adapter to manage here. + extensionFactories: [createWebAccessExtension(options)], }); await resourceLoader.reload(); @@ -53,7 +60,18 @@ export async function createHarnessSession( resourceLoader, cwd, ...(model ? { model } : {}), + ...(options.loadFromPath + ? { sessionManager: SessionManager.open(options.loadFromPath) } + : {}), }); return session; } + +export async function findHarnessSessionPath( + cwd: string, + sessionId: string, +): Promise { + const infos = await SessionManager.list(cwd); + return infos.find((info) => info.id === sessionId)?.path; +} diff --git a/packages/harness/tsup.config.ts b/packages/harness/tsup.config.ts index c8faae5bae..aa529dec7d 100644 --- a/packages/harness/tsup.config.ts +++ b/packages/harness/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ entry: [ "src/cli.ts", "src/session.ts", + "src/acp.ts", "src/spawn.ts", "src/extensions/registry.ts", "src/extensions/hog-branding/extension.ts", diff --git a/packages/shared/src/adapter.ts b/packages/shared/src/adapter.ts index 66de614a71..cda5cbb4e7 100644 --- a/packages/shared/src/adapter.ts +++ b/packages/shared/src/adapter.ts @@ -1 +1,27 @@ -export type Adapter = "claude" | "codex"; +/** + * Every agent runtime this codebase can drive. "hog" (the pi distribution + * wrapped by @posthog/harness) is local-only — cloud has no runtime for it. + * This is the single source of truth for the union; import it instead of + * redeclaring `"claude" | "codex"` (or `"claude" | "codex" | "hog"`) locally. + */ +export type Adapter = "claude" | "codex" | "hog"; + +/** Runtime values for {@link Adapter}, e.g. `z.enum(ADAPTER_VALUES)`. */ +export const ADAPTER_VALUES = [ + "claude", + "codex", + "hog", +] as const satisfies readonly Adapter[]; + +/** The adapters selectable from the cloud sandbox. "hog" is local-only. */ +export const CLOUD_ADAPTER_VALUES = [ + "claude", + "codex", +] as const satisfies readonly Adapter[]; + +export type CloudAdapter = (typeof CLOUD_ADAPTER_VALUES)[number]; + +/** Maps a local-only adapter to its cloud fallback. "hog" has no cloud runtime. */ +export function toCloudAdapter(adapter: Adapter): CloudAdapter { + return adapter === "hog" ? "claude" : adapter; +} diff --git a/packages/ui/src/features/home/config/ActionEditorPanel.tsx b/packages/ui/src/features/home/config/ActionEditorPanel.tsx index a9b91f1581..b9bf715f16 100644 --- a/packages/ui/src/features/home/config/ActionEditorPanel.tsx +++ b/packages/ui/src/features/home/config/ActionEditorPanel.tsx @@ -5,6 +5,7 @@ import { type WorkflowAction, } from "@posthog/core/workflow/schemas"; import { Button } from "@posthog/quill"; +import { toCloudAdapter } from "@posthog/shared"; import { useSkillsForPicker } from "@posthog/ui/features/home/hooks/useSkillsForPicker"; import { useWorkflowEditorStore } from "@posthog/ui/features/home/stores/workflowEditorStore"; import { UnifiedModelSelector } from "@posthog/ui/features/sessions/components/UnifiedModelSelector"; @@ -49,7 +50,8 @@ export function ActionEditorPanel({ const selectedSkill = skills.find((s) => s.name === action.skillId) ?? null; const lastUsedAdapter = useSettingsStore((s) => s.lastUsedAdapter); - const adapterForModel = action.adapter ?? lastUsedAdapter; + // Home quick actions run in the cloud only — "hog" (pi) is local-only. + const adapterForModel = toCloudAdapter(action.adapter ?? lastUsedAdapter); const { modelOption, isLoading: modelLoading } = usePreviewConfig(adapterForModel); // Show the action's pinned model in the picker, else the adapter's default. @@ -176,7 +178,9 @@ export function ActionEditorPanel({ patch({ adapter: a, model: undefined })} + onAdapterChange={(a) => + patch({ adapter: toCloudAdapter(a), model: undefined }) + } onModelChange={(m) => patch({ model: m, adapter: adapterForModel }) } diff --git a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts index 2a2e71465a..366751f5e8 100644 --- a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts +++ b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts @@ -11,6 +11,7 @@ import { ANALYTICS_EVENTS, getCloudUrlFromRegion, type TaskCreationInput, + toCloudAdapter, } from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { homeKeys } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; @@ -102,7 +103,8 @@ export function useRunWorkstreamAction(): RunWorkstreamAction { // then the adapter's server default. The preferred candidate is only // honoured if the gateway still offers it (the resolver validates it), // so a stale persisted/pinned id can't reach the run and 403. - const adapter = action.adapter ?? lastUsedAdapter; + // Workstream quick actions run in the cloud only — "hog" (pi) is local-only. + const adapter = toCloudAdapter(action.adapter ?? lastUsedAdapter); const preferredModel = action.model ?? lastUsedModel ?? undefined; let model = preferredModel; if (cloudRegion) { diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index e63809c47c..1afb3ab530 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -11,7 +11,11 @@ import { } from "@posthog/core/task-detail/taskService"; import { useService } from "@posthog/di/react"; import { Button } from "@posthog/quill"; -import { ANALYTICS_EVENTS, getCloudUrlFromRegion } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + getCloudUrlFromRegion, + toCloudAdapter, +} from "@posthog/shared"; import { SELF_DRIVING_SETUP_TASK_FLAG } from "@posthog/shared/constants"; import { useTrackAgentsViewed } from "@posthog/ui/features/agents/hooks/useTrackAgentsViewed"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; @@ -300,7 +304,8 @@ function SetupTaskSection() { try { const settings = useSettingsStore.getState(); - const adapter = settings.lastUsedAdapter ?? "claude"; + // Self-driving setup runs in the cloud only — "hog" (pi) is local-only. + const adapter = toCloudAdapter(settings.lastUsedAdapter ?? "claude"); const apiHost = getCloudUrlFromRegion(cloudRegion); const resolvedModel = await resolveDefaultModel( queryClient, diff --git a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts index 5578055962..a81f0b8182 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts @@ -14,6 +14,7 @@ import { type Adapter, ANALYTICS_EVENTS, getCloudUrlFromRegion, + toCloudAdapter, } from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast"; @@ -163,7 +164,8 @@ export function useInboxCloudTaskRunner({ const toastId = toast.loading(copy.loadingTitle, reportTitle ?? undefined); const settings = useSettingsStore.getState(); - const adapter = settings.lastUsedAdapter ?? "claude"; + // Inbox report agents run in the cloud only — "hog" (pi) is local-only. + const adapter = toCloudAdapter(settings.lastUsedAdapter ?? "claude"); const apiHost = getCloudUrlFromRegion(cloudRegion); // Pass the persisted model as a *preference*, not a hard selection: the diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx index 129e4fa04d..5777ce4b69 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.test.tsx @@ -112,17 +112,30 @@ describe("UnifiedModelSelector", () => { ); }); - it("switches adapter via the 'Switch to Claude' item", async () => { + it("switches adapter via the 'Switch to Hog' item (codex -> hog in the 3-way cycle)", async () => { const user = userEvent.setup(); const onAdapterChange = vi.fn(); renderSelector({ onAdapterChange }); await user.click(screen.getByRole("button", { name: "Model" })); await user.click( - await screen.findByRole("menuitem", { name: /switch to claude/i }), + await screen.findByRole("menuitem", { name: /switch to hog/i }), ); - expect(onAdapterChange).toHaveBeenCalledExactlyOnceWith("claude"); + expect(onAdapterChange).toHaveBeenCalledExactlyOnceWith("hog"); + }); + + it("cycles claude -> codex -> hog -> claude", async () => { + const user = userEvent.setup(); + const onAdapterChange = vi.fn(); + renderSelector({ adapter: "claude", onAdapterChange }); + + await user.click(screen.getByRole("button", { name: "Model" })); + await user.click( + await screen.findByRole("menuitem", { name: /switch to codex/i }), + ); + + expect(onAdapterChange).toHaveBeenCalledExactlyOnceWith("codex"); }); it("renders a disabled loading button with no menu while connecting", () => { diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx index 3b71f14d4b..fd9f0bd2eb 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx @@ -6,6 +6,7 @@ import { ArrowsClockwise, CaretDown, Cpu, + Flask, Robot, Spinner, } from "@phosphor-icons/react"; @@ -28,15 +29,20 @@ import { Fragment, useMemo, useRef, useState } from "react"; const ADAPTER_ICONS: Record = { claude: , codex: , + hog: , }; const ADAPTER_LABELS: Record = { claude: "Claude Code", codex: "Codex", + hog: "Hog", }; +const ADAPTER_CYCLE: AgentAdapter[] = ["claude", "codex", "hog"]; + function getOtherAdapter(adapter: AgentAdapter): AgentAdapter { - return adapter === "claude" ? "codex" : "claude"; + const index = ADAPTER_CYCLE.indexOf(adapter); + return ADAPTER_CYCLE[(index + 1) % ADAPTER_CYCLE.length]; } interface UnifiedModelSelectorProps { diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 84ef9fc71c..bf8cafe582 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -1,5 +1,10 @@ import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/repositories"; -import type { Adapter, ExecutionMode, WorkspaceMode } from "@posthog/shared"; +import { + ADAPTER_VALUES, + type Adapter, + type ExecutionMode, + type WorkspaceMode, +} from "@posthog/shared"; import { COLLAPSE_MODE_DEFAULT, type CollapseMode, @@ -487,6 +492,13 @@ export const useSettingsStore = create()( ) { (merged as Record).completionSound = "none"; } + // A dev build persisted the pre-rename "harness" adapter id (now "hog") or + // some other stale value — fall back rather than shipping an invalid + // adapter to the workspace-server (which rejects anything outside + // ADAPTER_VALUES). + if (!ADAPTER_VALUES.includes(merged.lastUsedAdapter as Adapter)) { + merged.lastUsedAdapter = "claude"; + } return merged; }, }, diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 1b4cacc500..c1dd1898a0 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -9,7 +9,7 @@ import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import { useServiceOptional } from "@posthog/di/react"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { ButtonGroup } from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { ANALYTICS_EVENTS, toCloudAdapter } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; @@ -649,7 +649,9 @@ export function TaskInput({ githubIntegrationId: orgGithubIntegrationId, branch: workspaceMode === "cloud" ? selectedBranch : null, editorIsEmpty, - runtimeAdapter: adapter ?? null, + // Warm-task pre-provisioning always targets the cloud sandbox; "hog" (pi) + // is local-only, so it falls back to claude like other cloud call sites. + runtimeAdapter: adapter ? toCloudAdapter(adapter) : null, model: effectiveModel, reasoningEffort: effectiveReasoningLevel, }); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index a7fcdc7d5a..1d91e539c4 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -950,9 +950,10 @@ If a repository IS genuinely required, attach one in this priority order: // Imported Claude Code CLI session: the transcript JSONL was copied // into CLAUDE_CONFIG_DIR at import time, so load it directly and let - // the adapter replay its history to the client. On failure, fall + // the adapter replay its history to the client. Claude-only (Codex and + // the hog/pi adapter have their own session stores). On failure, fall // through to a fresh session so the task still starts. - if (!isReconnect && config.importedSessionId && adapter !== "codex") { + if (!isReconnect && config.importedSessionId && adapter === "claude") { const importedSessionId = config.importedSessionId; try { const loadResponse = await connection.loadSession({ @@ -998,7 +999,8 @@ If a repository IS genuinely required, attach one in this priority order: if (isReconnect && config.sessionId) { const existingSessionId = config.sessionId; - if (adapter !== "codex") { + // Claude-only: hydrates the Claude SDK's own JSONL session store. + if (adapter === "claude") { const posthogAPI = agent.getPosthogAPI(); if (posthogAPI) { const hasSession = await hydrateSessionJsonl({ @@ -2235,12 +2237,18 @@ For git operations while detached: // The Claude adapter can also drive Cloudflare `@cf/` models the gateway serves over its // Anthropic-Messages surface, so the preview/default-model path must offer them too — otherwise an - // advertised `@cf/*` model is dropped here and the pre-session run falls back to Opus. + // advertised `@cf/*` model is dropped here and the pre-session run falls back to Opus. The hog/pi + // adapter routes both Anthropic and OpenAI models through the gateway, so it offers the full catalogue. const modelFilter = adapter === "codex" ? isOpenAIModel - : (model: GatewayModel) => - isAnthropicModel(model) || isCloudflareModel(model); + : adapter === "hog" + ? (model: GatewayModel) => + isAnthropicModel(model) || + isCloudflareModel(model) || + isOpenAIModel(model) + : (model: GatewayModel) => + isAnthropicModel(model) || isCloudflareModel(model); const modelOptions = gatewayModels .filter((model) => modelFilter(model)) @@ -2250,10 +2258,10 @@ For git operations while detached: description: `Context: ${model.context_window.toLocaleString()} tokens`, })); - // The gateway returns models in an arbitrary order. Sort Claude models - // oldest-to-newest so the picker is deterministic and the newest model - // lands at the end of the list, closest to the trigger. - if (adapter === "claude") { + // The gateway returns models in an arbitrary order. Sort Claude and hog + // models oldest-to-newest so the picker is deterministic and the newest + // model lands at the end of the list, closest to the trigger. + if (adapter !== "codex") { modelOptions.sort( (a, b) => getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), @@ -2279,17 +2287,20 @@ For git operations while detached: }); } - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const modeOptions = modes.map((mode) => ({ - value: mode.id, - name: mode.name, - description: mode.description ?? undefined, - })); - const defaultMode = adapter === "codex" ? "auto" : "plan"; - - const configOptions: SessionConfigOption[] = [ - { + const configOptions: SessionConfigOption[] = []; + + // The hog/pi adapter has no execution-mode picker yet — only the model + // picker. The UI hides unset option categories gracefully. + if (adapter !== "hog") { + const modes = + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); + const modeOptions = modes.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description ?? undefined, + })); + const defaultMode = adapter === "codex" ? "auto" : "plan"; + configOptions.push({ id: "mode", name: "Approval Preset", type: "select", @@ -2298,17 +2309,18 @@ For git operations while detached: category: "mode", description: "Choose an approval and sandboxing preset for your session", - }, - { - id: "model", - name: "Model", - type: "select", - currentValue: resolvedModelId, - options: modelOptions, - category: "model", - description: "Choose which model Claude should use", - }, - ]; + }); + } + + configOptions.push({ + id: "model", + name: "Model", + type: "select", + currentValue: resolvedModelId, + options: modelOptions, + category: "model", + description: "Choose which model the agent should use", + }); const effortOpts = getReasoningEffortOptions(adapter, resolvedModelId); if (effortOpts) { diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 647f2e9e8e..6412deee24 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -2,6 +2,7 @@ import type { RequestPermissionRequest, PermissionOption as SdkPermissionOption, } from "@agentclientprotocol/sdk"; +import { ADAPTER_VALUES } from "@posthog/shared"; import { effortLevelSchema } from "@posthog/shared/domain-types"; import { z } from "zod"; @@ -25,7 +26,7 @@ export const sessionConfigSchema = z.object({ logUrl: z.string().optional(), /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(ADAPTER_VALUES).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ @@ -51,7 +52,7 @@ export const startSessionInput = z.object({ permissionMode: z.string().optional(), autoProgress: z.boolean().optional(), runMode: z.enum(["local", "cloud"]).optional(), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(ADAPTER_VALUES).optional(), additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), /** @@ -198,7 +199,7 @@ export const reconnectSessionInput = z.object({ projectId: z.number(), logUrl: z.string().optional(), sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), + adapter: z.enum(ADAPTER_VALUES).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), permissionMode: z.string().optional(), @@ -328,7 +329,7 @@ export const getGatewayModelsOutput = z.array(modelOptionSchema); export const getPreviewConfigOptionsInput = z.object({ apiHost: z.string(), - adapter: z.enum(["claude", "codex"]), + adapter: z.enum(ADAPTER_VALUES), }); export const getPreviewConfigOptionsOutput = z.array(sessionConfigOptionSchema); diff --git a/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts b/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts index 30cbd22411..6d23479c68 100644 --- a/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts +++ b/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts @@ -136,12 +136,24 @@ export class AuthProxyService { "anthropic-auth-token", "proxy-authorization", ]); + // Framing headers describe the incoming request's body, which we + // discard and re-buffer ourselves below (`Buffer.concat(chunks)`). + // Forwarding a stale `content-length` (e.g. a client that computed it + // from a JS string's UTF-16 length instead of its UTF-8 byte length) + // makes undici reject the re-sent request with + // "invalid content-length header". Let fetch compute framing headers + // fresh from the buffer we actually send. + const strippedFramingHeaders = new Set([ + "content-length", + "transfer-encoding", + ]); const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { if ( key === "host" || key === "connection" || - strippedAuthHeaders.has(key) + strippedAuthHeaders.has(key) || + strippedFramingHeaders.has(key) ) { continue; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e258e91eb..27f61f2a5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -718,6 +718,9 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.28.0 version: 1.39.0 + '@posthog/harness': + specifier: workspace:* + version: link:../harness '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -21403,7 +21406,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -29146,6 +29149,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/ui': 4.1.8(vitest@4.1.8) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.2.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 From c8c453382be63b9b36f8cbd2f83ef33e37d888de Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 8 Jul 2026 14:15:56 +0200 Subject: [PATCH 2/3] fix(harness): avoid polynomial-regex ReDoS risk in model name/URL trimming --- packages/harness/src/acp.test.ts | 36 ++++++++++++++++++- packages/harness/src/acp.ts | 9 +++-- .../posthog-provider/models.test.ts | 22 ++++++++++++ .../src/extensions/posthog-provider/models.ts | 19 ++++++++-- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/harness/src/acp.test.ts b/packages/harness/src/acp.test.ts index 55243f5350..f4bf8c53f5 100644 --- a/packages/harness/src/acp.test.ts +++ b/packages/harness/src/acp.test.ts @@ -1,5 +1,14 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; -import { buildEditDiffUpdate, reconstructEditOldText } from "./acp"; +import { + buildEditDiffUpdate, + buildHarnessModelSurface, + reconstructEditOldText, +} from "./acp"; + +function fakeModel(id: string, name: string): Model { + return { id, name, provider: "posthog" } as Model; +} describe("reconstructEditOldText", () => { it("reverses a single unambiguous edit against the post-edit content", () => { @@ -98,3 +107,28 @@ describe("buildEditDiffUpdate", () => { expect(update).toBeUndefined(); }); }); + +describe("buildHarnessModelSurface", () => { + it("strips a '(latest)' suffix, including surrounding whitespace, from model names", () => { + const { models } = buildHarnessModelSurface( + [fakeModel("claude-opus-4-8", "Claude Opus (latest)")], + "claude-opus-4-8", + ); + expect(models?.availableModels).toEqual([ + { modelId: "claude-opus-4-8", name: "Claude Opus" }, + ]); + }); + + it("prefers the '(latest)'-tagged entry when deduping same-named models", () => { + const { models } = buildHarnessModelSurface( + [ + fakeModel("claude-opus-4-8-old", "Claude Opus"), + fakeModel("claude-opus-4-8", "Claude Opus (latest)"), + ], + "claude-opus-4-8", + ); + expect(models?.availableModels).toEqual([ + { modelId: "claude-opus-4-8", name: "Claude Opus" }, + ]); + }); +}); diff --git a/packages/harness/src/acp.ts b/packages/harness/src/acp.ts index 0414c60b96..2b9d1287ec 100644 --- a/packages/harness/src/acp.ts +++ b/packages/harness/src/acp.ts @@ -35,11 +35,16 @@ export interface PiToolResult { isError?: boolean; } -const LATEST_SUFFIX = /\s*\(latest\)\s*$/i; +// No leading `\s*` here: paired with the unanchored search .replace() does by +// default, a leading quantifier in front of a literal makes the engine retry +// every possible split of \s* at every start position when the literal isn't +// found, which is quadratic on a run of whitespace. The trailing .trimEnd() +// below gets the same "trim surrounding whitespace" behavior without it. +const LATEST_SUFFIX = /\(latest\)\s*$/i; const LATEST_MARKER = /\(latest\)/i; function stripLatestSuffix(name: string): string { - return name.replace(LATEST_SUFFIX, ""); + return name.replace(LATEST_SUFFIX, "").trimEnd(); } function dedupeToLatest>(models: T[]): T[] { diff --git a/packages/harness/src/extensions/posthog-provider/models.test.ts b/packages/harness/src/extensions/posthog-provider/models.test.ts index 048e088e8d..52a5a5832f 100644 --- a/packages/harness/src/extensions/posthog-provider/models.test.ts +++ b/packages/harness/src/extensions/posthog-provider/models.test.ts @@ -3,6 +3,7 @@ import { getLlmGatewayUrl } from "./gateway"; import { fallbackModelConfigs, gatewayBaseUrlForApi, + gatewayBaseUrlForApiWithGatewayUrl, resolveModelConfigs, } from "./models"; @@ -23,6 +24,27 @@ describe("gatewayBaseUrlForApi", () => { }); }); +describe("gatewayBaseUrlForApiWithGatewayUrl", () => { + it.each([ + ["https://gateway.example.com", "https://gateway.example.com"], + ["https://gateway.example.com/", "https://gateway.example.com"], + ["https://gateway.example.com///", "https://gateway.example.com"], + ])("strips trailing slashes from %s", (input, expected) => { + expect( + gatewayBaseUrlForApiWithGatewayUrl("anthropic-messages", input), + ).toBe(expected); + }); + + it("appends /v1 for openai-responses after stripping trailing slashes", () => { + expect( + gatewayBaseUrlForApiWithGatewayUrl( + "openai-responses", + "https://gateway.example.com/", + ), + ).toBe("https://gateway.example.com/v1"); + }); +}); + describe("resolveModelConfigs", () => { const originalFetch = global.fetch; const originalOffline = process.env.PI_OFFLINE; diff --git a/packages/harness/src/extensions/posthog-provider/models.ts b/packages/harness/src/extensions/posthog-provider/models.ts index c33e5698b6..89ee4a41c9 100644 --- a/packages/harness/src/extensions/posthog-provider/models.ts +++ b/packages/harness/src/extensions/posthog-provider/models.ts @@ -18,6 +18,19 @@ type ModelFamily = "anthropic" | "openai" | "cloudflare"; const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; +/** + * Strips trailing slashes without a regex. `.replace(/\/+$/, "")` is + * unanchored at the start, so the engine retries every possible split of + * the `+` quantifier at every position when the string doesn't end where + * expected — quadratic on a long run of slashes. A plain loop is O(n) and + * has no such worst case. + */ +function stripTrailingSlashes(url: string): string { + let end = url.length; + while (end > 0 && url[end - 1] === "/") end--; + return url.slice(0, end); +} + function detectFamily(model: GatewayModel): ModelFamily { if (model.owned_by === "openai" || model.id.startsWith("gpt-")) { return "openai"; @@ -42,7 +55,7 @@ export function gatewayBaseUrlForApiWithGatewayUrl( api: string, gatewayUrl: string, ): string { - const normalized = gatewayUrl.replace(/\/+$/, ""); + const normalized = stripTrailingSlashes(gatewayUrl); return api === "openai-responses" ? `${normalized}/v1` : normalized; } @@ -181,7 +194,9 @@ async function fetchGatewayModels( return []; } try { - const baseUrl = gatewayUrl?.replace(/\/+$/, "") ?? getLlmGatewayUrl(region); + const baseUrl = gatewayUrl + ? stripTrailingSlashes(gatewayUrl) + : getLlmGatewayUrl(region); const response = await fetch(`${baseUrl}/v1/models`, { signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), }); From 3e1aae57be68314efd81836c89597577a3244943 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 8 Jul 2026 14:28:30 +0200 Subject: [PATCH 3/3] fix(harness): address greptile review findings - packages/harness/src/acp.ts: stop mapping pi's internal-error stop reason to ACP's "refusal" (which implies a deliberate content-policy decline); map to "end_turn" instead since ACP has no dedicated failure state - packages/workspace-server: clarify the hog-adapter model sort comment (OpenAI models have no comparable recency function and cluster together, which is expected, not a bug) - packages/ui: rename getOtherAdapter -> getNextAdapter now that it cycles through a three-element ring instead of returning a binary other --- packages/harness/src/acp.ts | 8 +++++++- .../features/sessions/components/UnifiedModelSelector.tsx | 8 ++++---- packages/workspace-server/src/services/agent/agent.ts | 7 ++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/harness/src/acp.ts b/packages/harness/src/acp.ts index 2b9d1287ec..6687bf3eee 100644 --- a/packages/harness/src/acp.ts +++ b/packages/harness/src/acp.ts @@ -396,7 +396,13 @@ function piStopReasonToAcp(reason: PiStopReason): StopReason { case "aborted": return "cancelled"; case "error": - return "refusal"; + // Pi's "error" means an internal failure (gateway 5xx, malformed + // response, tool crash) carried on `AssistantMessage.errorMessage`, + // not a model content-policy refusal. ACP has no dedicated failure + // stop reason, so mapping to "refusal" would misrepresent a crash as + // a deliberate "model declined to answer". `end_turn` is the neutral + // choice among what ACP actually offers. + return "end_turn"; } } diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx index fd9f0bd2eb..a8acc9a6b5 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx @@ -40,7 +40,7 @@ const ADAPTER_LABELS: Record = { const ADAPTER_CYCLE: AgentAdapter[] = ["claude", "codex", "hog"]; -function getOtherAdapter(adapter: AgentAdapter): AgentAdapter { +function getNextAdapter(adapter: AgentAdapter): AgentAdapter { const index = ADAPTER_CYCLE.indexOf(adapter); return ADAPTER_CYCLE[(index + 1) % ADAPTER_CYCLE.length]; } @@ -86,7 +86,7 @@ export function UnifiedModelSelector({ const currentLabel = options.find((opt) => opt.value === currentValue)?.name ?? currentValue; - const otherAdapter = getOtherAdapter(adapter); + const nextAdapter = getNextAdapter(adapter); // Collapse to a bare loading button only while the menu is closed (initial // load). When the menu is open we keep it mounted and surface the loading @@ -181,10 +181,10 @@ export function UnifiedModelSelector({ onAdapterChange(otherAdapter)} + onClick={() => onAdapterChange(nextAdapter)} > - Switch to {ADAPTER_LABELS[otherAdapter]} + Switch to {ADAPTER_LABELS[nextAdapter]} diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 1d91e539c4..76fb949024 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -2258,9 +2258,10 @@ For git operations while detached: description: `Context: ${model.context_window.toLocaleString()} tokens`, })); - // The gateway returns models in an arbitrary order. Sort Claude and hog - // models oldest-to-newest so the picker is deterministic and the newest - // model lands at the end of the list, closest to the trigger. + // The gateway returns models in an arbitrary order. Sort Claude models + // oldest-to-newest so the picker is deterministic; OpenAI models offered + // by the hog adapter have no comparable recency function and cluster at + // the same sort key, keeping their gateway order relative to each other. if (adapter !== "codex") { modelOptions.sort( (a, b) =>