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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/agent/src/acp-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const POSTHOG_NOTIFICATIONS = {
/** Error occurred during task execution */
ERROR: "_posthog/error",

/** Agent runtime failed before its session became ready */
INITIALIZATION_FAILED: "_posthog/initialization_failed",

/** Console/log output from the agent */
CONSOLE: "_posthog/console",

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import {
type AgentSideConnection,
RequestError,
} from "@agentclientprotocol/sdk";
import type { HookInput, Options } from "@anthropic-ai/claude-agent-sdk";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_GATEWAY_MODEL } from "../../gateway-models";
Expand Down Expand Up @@ -453,6 +456,50 @@ describe("ClaudeAcpAgent session creation", () => {
expect(createdQueries[0]?.close).toHaveBeenCalledTimes(1);
});

it("logs diagnostics and closes the query when new-session init times out", async () => {
vi.useFakeTimers();
try {
nextInitPromise = new Promise(() => {});
const agent = makeAgent();
const errorSpy = vi.spyOn(agent.logger, "error");

const promise = agent.newSession({
cwd,
mcpServers: [],
_meta: {
taskRunId: "run-init-timeout-new",
model: "claude-opus-5",
},
});
promise.catch(() => {});

await vi.waitFor(() => {
expect(createdQueries[0]?.initializationResult).toHaveBeenCalledTimes(
1,
);
});
await vi.advanceTimersByTimeAsync(30_001);

await expect(promise).rejects.toBeInstanceOf(RequestError);
expect(createdQueries[0]?.close).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
"Session initialization failed",
expect.objectContaining({
initializationPhase: "sdk_initialization",
timeoutMs: 30_000,
initMs: expect.any(Number),
requestedModel: "claude-opus-5",
gatewayConfigured: false,
errorDetail: expect.objectContaining({
message: "Session initialization timed out after 30000ms",
}),
}),
);
} finally {
vi.useRealTimers();
}
});

it("closes the query and rethrows when resume init fails", async () => {
const failedInit = Promise.reject(new Error("resume boom"));
failedInit.catch(() => {});
Expand Down
13 changes: 11 additions & 2 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2197,10 +2197,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
const initPromise = !isResume
? withTimeout(q.initializationResult(), SESSION_VALIDATION_TIMEOUT_MS)
: undefined;
const requestedModel =
meta?.model || settingsManager.getSettings().model || undefined;

const [rawModelOptions] = await Promise.all([
this.getModelConfigOptions(
meta?.model || settingsManager.getSettings().model || undefined,
requestedModel,
this.options?.gatewayEnv?.anthropicBaseUrl,
this.options?.gatewayEnv?.anthropicAuthToken,
),
Expand Down Expand Up @@ -2252,12 +2254,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
} catch (err) {
settingsManager.dispose();
this.terminateQuery(q, abortController);
const initMs = Date.now() - initStartedAt;
this.logger.error("Session initialization failed", {
sessionId,
taskId,
taskRunId: meta?.taskRunId,
initializationPhase: "sdk_initialization",
timeoutMs: SESSION_VALIDATION_TIMEOUT_MS,
modelConfigMs,
initMs: Date.now() - initStartedAt,
initMs,
requestedModel: requestedModel ?? null,
gatewayConfigured: Boolean(
this.options?.gatewayEnv?.anthropicBaseUrl,
),
errorDetail: serializeError(err),
});
throw err;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,45 @@ function makeFakeClient(
const init = { protocolVersion: 1 } as unknown as InitializeRequest;

describe("CodexAppServerAgent", () => {
it("logs session initialization diagnostics when thread setup fails", async () => {
const stub = makeStubRpc({
"thread/start": new Error("thread setup failed"),
});
const { client } = makeFakeClient();
const agent = new CodexAppServerAgent(client, {
processOptions: {
binaryPath: "/bundle/codex",
apiBaseUrl: "https://gateway.example.com",
},
model: "gpt-5.5",
rpcFactory: stub.factory,
});
const errorSpy = vi.spyOn(agent.logger, "error");

await expect(
agent.newSession({
cwd: "/repo",
_meta: { taskId: "task-1", taskRunId: "run-1" },
} as unknown as NewSessionRequest),
).rejects.toThrow("thread setup failed");

expect(errorSpy).toHaveBeenCalledWith(
"Codex app-server session initialization failed",
expect.objectContaining({
runtimeAdapter: "codex",
initializationPhase: "thread/start",
initMs: expect.any(Number),
requestedModel: "gpt-5.5",
gatewayConfigured: true,
taskId: "task-1",
taskRunId: "run-1",
errorDetail: expect.objectContaining({
message: "thread setup failed",
}),
}),
);
});

it("runs initialize -> thread/start -> turn/start and streams agent text", async () => {
const stub = makeStubRpc({
initialize: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
classifyGatewayLimitError,
mcpToolKey,
posthogToolMeta,
serializeError,
} from "@posthog/shared";
import {
type NativeGoalState,
Expand Down Expand Up @@ -251,6 +252,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
) => Promise<void>;
/** Codex-specific guidance injected at spawn time; replayed per-thread. */
private readonly developerInstructions?: string;
private readonly gatewayConfigured: boolean;
private threadId?: string;
/** JSON schema constraining the final message; set per session via `_meta`. */
private jsonSchema?: Record<string, unknown>;
Expand Down Expand Up @@ -306,6 +308,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
);
this.onStructuredOutput = options.onStructuredOutput;
this.developerInstructions = options.processOptions.developerInstructions;
this.gatewayConfigured = Boolean(options.processOptions.apiBaseUrl);

const handlers: AppServerClientHandlers = {
logger: this.logger,
Expand Down Expand Up @@ -388,7 +391,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
}

async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
const { threadId } = await this.setupThread(
const { threadId } = await this.setupThreadWithDiagnostics(
APP_SERVER_METHODS.THREAD_START,
{
cwd: params.cwd,
Expand All @@ -403,7 +406,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
async resumeSession(
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
await this.setupThread(APP_SERVER_METHODS.THREAD_RESUME, {
await this.setupThreadWithDiagnostics(APP_SERVER_METHODS.THREAD_RESUME, {
cwd: params.cwd,
mcpServers: params.mcpServers,
meta: params._meta as AppServerSessionMeta | undefined,
Expand All @@ -415,7 +418,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {

/** Re-attach to an existing thread without starting a turn: resume it, then replay the transcript. */
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const { thread } = await this.setupThread(
const { thread } = await this.setupThreadWithDiagnostics(
APP_SERVER_METHODS.THREAD_RESUME,
{
cwd: params.cwd,
Expand All @@ -432,7 +435,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
async unstable_forkSession(
params: ForkSessionRequest,
): Promise<ForkSessionResponse> {
const { threadId } = await this.setupThread(
const { threadId } = await this.setupThreadWithDiagnostics(
APP_SERVER_METHODS.THREAD_FORK,
{
cwd: params.cwd,
Expand Down Expand Up @@ -485,6 +488,28 @@ export class CodexAppServerAgent extends BaseAcpAgent {
}
}

private async setupThreadWithDiagnostics(
method: string,
params: Parameters<CodexAppServerAgent["setupThread"]>[1],
): ReturnType<CodexAppServerAgent["setupThread"]> {
const initStartedAt = Date.now();
try {
return await this.setupThread(method, params);
} catch (error) {
this.logger.error("Codex app-server session initialization failed", {
runtimeAdapter: "codex",
initializationPhase: method,
initMs: Date.now() - initStartedAt,
requestedModel: this.config.model,
gatewayConfigured: this.gatewayConfigured,
taskId: params.meta?.taskId,
taskRunId: params.meta?.taskRunId,
errorDetail: serializeError(error),
});
throw error;
}
}

/** Shared thread setup for start/resume/fork. `threadId` present => resume/fork; absent => new thread. */
private async setupThread(
method: string,
Expand Down
38 changes: 38 additions & 0 deletions packages/agent/src/otel-telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,30 @@ describe("OtelRunTelemetry", () => {
body: "run error",
attrs: { error_source: "agent_server", stop_reason: "error" },
},
{
name: "initialization_failed",
entry: makeEntry("_posthog/initialization_failed", {
runtimeAdapter: "codex",
initializationPhase: "session_setup",
initMs: 12_345,
requestedModel: "gpt-5.2-codex",
gatewayConfigured: true,
errorType: "timeout",
timeoutMs: 30_000,
errorDetail: "SECRET provider response",
}),
severityText: "ERROR",
body: "agent initialization failed",
attrs: {
runtime_adapter: "codex",
initialization_phase: "session_setup",
init_ms: 12_345,
requested_model: "gpt-5.2-codex",
gateway_configured: true,
error_type: "timeout",
timeout_ms: 30_000,
},
},
{
name: "progress",
entry: makeEntry("_posthog/progress", {
Expand Down Expand Up @@ -346,6 +370,20 @@ describe("OtelRunTelemetry", () => {
);
});

it("never exports initialization error detail", () => {
const mapped = mapNotificationToLogRecord(
makeEntry("_posthog/initialization_failed", {
runtimeAdapter: "pi",
errorDetail: "SECRET provider response",
}),
);

expect(mapped).not.toBeNull();
expect(JSON.stringify([mapped?.body, mapped?.attributes])).not.toContain(
"SECRET",
);
});

it("caps body length", () => {
const mapped = mapNotificationToLogRecord(
makeEntry("_posthog/progress", {
Expand Down
14 changes: 14 additions & 0 deletions packages/agent/src/otel-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
entryTime,
MAX_BODY_CHARS,
normalizeMethod,
numAttr,
strAttr,
truncate,
usageAttributes,
Expand Down Expand Up @@ -200,6 +201,19 @@ export function mapNotificationToLogRecord(
strAttr(attrs, "stop_reason", params.stopReason);
return record(ERROR, "run error", method, attrs);
}
case POSTHOG_NOTIFICATIONS.INITIALIZATION_FAILED: {
const attrs: Attributes = {};
strAttr(attrs, "runtime_adapter", params.runtimeAdapter);
strAttr(attrs, "initialization_phase", params.initializationPhase);
numAttr(attrs, "init_ms", params.initMs);
strAttr(attrs, "requested_model", params.requestedModel);
if (typeof params.gatewayConfigured === "boolean") {
attrs.gateway_configured = params.gatewayConfigured;
}
strAttr(attrs, "error_type", params.errorType);
numAttr(attrs, "timeout_ms", params.timeoutMs);
return record(ERROR, "agent initialization failed", method, attrs);
}
// POSTHOG_NOTIFICATIONS.CONSOLE is deliberately NOT exported: those are
// free-text agent-server diagnostics that interpolate arbitrary data
// (prompt previews, stringified extension params), so shipping them would
Expand Down
52 changes: 52 additions & 0 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,58 @@ describe("AgentServer HTTP Mode", () => {
);
};

it("exports safe telemetry when session initialization fails", async () => {
const append = vi.fn();
const shutdown = vi.fn(async () => {});
const testServer = createServer({
runtimeAdapter: "codex",
model: "gpt-5.2-codex",
}) as unknown as {
initializingTelemetry: {
append: typeof append;
shutdown: typeof shutdown;
};
_doInitializeSession(
payload: JwtPayload,
controller: null,
): Promise<void>;
initializeSession(payload: JwtPayload, controller: null): Promise<void>;
};
testServer._doInitializeSession = vi.fn(async () => {
testServer.initializingTelemetry = { append, shutdown };
throw new Error("SECRET provider response");
});
const payload = {
task_id: "test-task-id",
run_id: "test-run-id",
team_id: 1,
user_id: 1,
distinct_id: "test-distinct-id",
mode: "interactive" as const,
};

await expect(testServer.initializeSession(payload, null)).rejects.toThrow(
"SECRET provider response",
);

expect(append).toHaveBeenCalledWith(
"test-run-id",
expect.objectContaining({
notification: expect.objectContaining({
method: POSTHOG_NOTIFICATIONS.INITIALIZATION_FAILED,
params: expect.objectContaining({
runtimeAdapter: "codex",
initializationPhase: "session_setup",
requestedModel: "gpt-5.2-codex",
errorType: "error",
}),
}),
}),
);
expect(JSON.stringify(append.mock.calls)).not.toContain("SECRET");
expect(shutdown).toHaveBeenCalledOnce();
});

it("replays ACP notifications emitted before cloud session assignment", () => {
const testServer = createServer() as unknown as {
session: { sseController: null } | null;
Expand Down
Loading
Loading