Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit aebce04

Browse files
Merge branch 'main' into fix/session-init-timeout
2 parents 2a3ccb0 + 64c0754 commit aebce04

102 files changed

Lines changed: 8473 additions & 869 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
CODE_EXECUTION_MODES,
5+
type CodeExecutionMode,
6+
} from "../../execution-mode";
7+
import { createMockQuery, type MockQuery } from "../../test/mocks/claude-sdk";
8+
import { Pushable } from "../../utils/streams";
9+
import { toSdkPermissionMode } from "./tools";
10+
11+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
12+
query: vi.fn(),
13+
}));
14+
15+
vi.mock("./mcp/tool-metadata", () => ({
16+
fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined),
17+
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
18+
setMcpToolApprovalStates: vi.fn(),
19+
isMcpToolReadOnly: vi.fn().mockReturnValue(false),
20+
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
21+
getMcpToolApprovalState: vi.fn().mockReturnValue(undefined),
22+
}));
23+
24+
const { ClaudeAcpAgent } = await import("./claude-agent");
25+
type Agent = InstanceType<typeof ClaudeAcpAgent>;
26+
27+
interface ClientMocks {
28+
sessionUpdate: ReturnType<typeof vi.fn>;
29+
extNotification: ReturnType<typeof vi.fn>;
30+
}
31+
32+
function makeAgent(): { agent: Agent; client: ClientMocks } {
33+
const client: ClientMocks = {
34+
sessionUpdate: vi.fn().mockResolvedValue(undefined),
35+
extNotification: vi.fn().mockResolvedValue(undefined),
36+
};
37+
const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection);
38+
return { agent, client };
39+
}
40+
41+
function installFakeSession(
42+
agent: Agent,
43+
sessionId: string,
44+
permissionMode: CodeExecutionMode = "default",
45+
): MockQuery {
46+
const query = createMockQuery();
47+
const input = new Pushable();
48+
const abortController = new AbortController();
49+
50+
const session = {
51+
query,
52+
queryOptions: { sessionId, cwd: "/tmp/repo", abortController },
53+
buildInProcessMcpServers: () => ({}),
54+
localToolsServerNames: [] as string[],
55+
input,
56+
cancelled: false,
57+
interruptReason: undefined,
58+
settingsManager: { dispose: vi.fn(), getRepoRoot: () => "/tmp/repo" },
59+
permissionMode,
60+
abortController,
61+
accumulatedUsage: {
62+
inputTokens: 0,
63+
outputTokens: 0,
64+
cachedReadTokens: 0,
65+
cachedWriteTokens: 0,
66+
},
67+
sessionResources: new Set(),
68+
configOptions: [],
69+
turnQueue: [],
70+
activeTurn: null,
71+
pendingOrphanResults: 0,
72+
queryGeneration: 0,
73+
cwd: "/tmp/repo",
74+
notificationHistory: [] as unknown[],
75+
taskRunId: "run-1",
76+
lastContextWindowSize: 200_000,
77+
modelId: "claude-sonnet-4-6",
78+
knownSlashCommands: undefined,
79+
};
80+
81+
(agent as unknown as { session: typeof session }).session = session;
82+
(agent as unknown as { sessionId: string }).sessionId = sessionId;
83+
84+
return query;
85+
}
86+
87+
describe("ClaudeAcpAgent.setSessionMode — SDK permission-mode translation", () => {
88+
beforeEach(() => {
89+
vi.clearAllMocks();
90+
});
91+
92+
it.each(CODE_EXECUTION_MODES)(
93+
"maps modeId %s to the SDK's permission mode at the setPermissionMode call site",
94+
async (modeId) => {
95+
const { agent } = makeAgent();
96+
const query = installFakeSession(agent, "s-mode");
97+
98+
await agent.setSessionMode({ sessionId: "s-mode", modeId });
99+
100+
expect(query.setPermissionMode).toHaveBeenCalledWith(
101+
toSdkPermissionMode(modeId),
102+
);
103+
expect(
104+
(agent as unknown as { session: { permissionMode: string } }).session
105+
.permissionMode,
106+
).toBe(modeId);
107+
},
108+
);
109+
110+
it("reverts session.permissionMode to the previous mode when the SDK rejects", async () => {
111+
const { agent } = makeAgent();
112+
const query = installFakeSession(agent, "s-mode", "default");
113+
vi.mocked(query.setPermissionMode).mockRejectedValueOnce(
114+
new Error("sdk rejected"),
115+
);
116+
117+
await expect(
118+
agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }),
119+
).rejects.toThrow("sdk rejected");
120+
121+
expect(
122+
(agent as unknown as { session: { permissionMode: string } }).session
123+
.permissionMode,
124+
).toBe("default");
125+
});
126+
127+
it("falls back to a generic error message when the SDK rejection has none", async () => {
128+
const { agent } = makeAgent();
129+
const query = installFakeSession(agent, "s-mode", "default");
130+
vi.mocked(query.setPermissionMode).mockRejectedValueOnce(new Error());
131+
132+
await expect(
133+
agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }),
134+
).rejects.toThrow("Invalid Mode");
135+
});
136+
137+
it("records modeBeforePlan using the host mode, unaffected by the SDK translation", async () => {
138+
const { agent } = makeAgent();
139+
installFakeSession(agent, "s-mode", "auto");
140+
141+
await agent.setSessionMode({ sessionId: "s-mode", modeId: "plan" });
142+
143+
const session = (
144+
agent as unknown as {
145+
session: { permissionMode: string; modeBeforePlan?: string };
146+
}
147+
).session;
148+
expect(session.permissionMode).toBe("plan");
149+
expect(session.modeBeforePlan).toBe("auto");
150+
});
151+
});

‎packages/agent/src/adapters/claude/claude-agent.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ import {
135135
CODE_EXECUTION_MODES,
136136
type CodeExecutionMode,
137137
getAvailableModes,
138+
toSdkPermissionMode,
138139
} from "./tools";
139140
import type {
140141
BackgroundTerminal,
@@ -1778,7 +1779,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
17781779
this.session.modeBeforePlan = previousMode;
17791780
}
17801781
try {
1781-
await this.session.query.setPermissionMode(modeId as CodeExecutionMode);
1782+
await this.session.query.setPermissionMode(
1783+
toSdkPermissionMode(modeId as CodeExecutionMode),
1784+
);
17821785
} catch (error) {
17831786
this.session.permissionMode = previousMode;
17841787
if (error instanceof Error) {

‎packages/agent/src/adapters/claude/session/options.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ describe("buildSessionOptions", () => {
5757
},
5858
);
5959

60+
it("maps the custom auto mode to the SDK's default mode", () => {
61+
const options = buildSessionOptions({
62+
...makeParams(),
63+
permissionMode: "auto",
64+
});
65+
expect(options.permissionMode).toBe("default");
66+
});
67+
68+
it.each(["default", "acceptEdits", "plan", "bypassPermissions"] as const)(
69+
"passes native SDK mode %s through to options.permissionMode",
70+
(mode) => {
71+
const options = buildSessionOptions({
72+
...makeParams(),
73+
permissionMode: mode,
74+
});
75+
expect(options.permissionMode).toBe(mode);
76+
},
77+
);
78+
6079
it("preserves caller-provided agents alongside defaults", () => {
6180
const params = makeParams();
6281
const options = buildSessionOptions({

‎packages/agent/src/adapters/claude/session/options.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
} from "@anthropic-ai/claude-agent-sdk";
1313
import type { FileEnrichmentDeps } from "../../../enrichment/file-enricher";
1414
import { IS_ROOT } from "../../../utils/common";
15+
import { buildGatewayPropertyHeaders } from "../../../utils/gateway";
1516
import type { Logger } from "../../../utils/logger";
1617
import type { TaskState } from "../conversion/task-state";
1718
import {
@@ -24,7 +25,7 @@ import {
2425
type EnrichedReadCache,
2526
type OnModeChange,
2627
} from "../hooks";
27-
import type { CodeExecutionMode } from "../tools";
28+
import { type CodeExecutionMode, toSdkPermissionMode } from "../tools";
2829
import type { EffortLevel } from "../types";
2930
import { APPENDED_INSTRUCTIONS } from "./instructions";
3031
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
@@ -163,7 +164,7 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
163164
// get_llm_client(team_id=...).
164165
const projectId = gateway?.posthogProjectId ?? process.env.POSTHOG_PROJECT_ID;
165166
if (projectId) {
166-
headerLines.push(`x-posthog-property-team_id: ${projectId}`);
167+
headerLines.push(buildGatewayPropertyHeaders({ team_id: projectId }));
167168
}
168169
// Route to AWS Bedrock as a fallback when Anthropic returns 5xx
169170
headerLines.push("x-posthog-use-bedrock-fallback: true");
@@ -445,7 +446,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
445446
cwd: params.cwd,
446447
includePartialMessages: true,
447448
allowDangerouslySkipPermissions: !IS_ROOT || !!process.env.IS_SANDBOX,
448-
permissionMode: params.permissionMode,
449+
permissionMode: toSdkPermissionMode(params.permissionMode),
449450
canUseTool: params.canUseTool,
450451
tools,
451452
agents,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { CodeExecutionMode } from "../../execution-mode";
3+
import { isToolAllowedForMode, toSdkPermissionMode } from "./tools";
4+
5+
describe("toSdkPermissionMode", () => {
6+
it("maps the custom auto mode to the SDK's default mode", () => {
7+
expect(toSdkPermissionMode("auto")).toBe("default");
8+
});
9+
10+
it.each<CodeExecutionMode>([
11+
"default",
12+
"acceptEdits",
13+
"plan",
14+
"bypassPermissions",
15+
])("passes native SDK mode %s through unchanged", (mode) => {
16+
expect(toSdkPermissionMode(mode)).toBe(mode);
17+
});
18+
});
19+
20+
describe("isToolAllowedForMode stays authoritative for auto", () => {
21+
it.each(["Bash", "Edit", "Write", "NotebookEdit", "BashOutput", "KillShell"])(
22+
"auto-allows %s in auto mode",
23+
(tool) => {
24+
expect(isToolAllowedForMode(tool, "auto")).toBe(true);
25+
},
26+
);
27+
28+
it.each(["Bash", "Edit", "Write"])(
29+
"still gates %s in default mode",
30+
(tool) => {
31+
expect(isToolAllowedForMode(tool, "default")).toBe(false);
32+
},
33+
);
34+
});

‎packages/agent/src/adapters/claude/tools.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
type ModeInfo,
66
} from "../../execution-mode";
77

8+
import type { PermissionMode as SdkPermissionMode } from "@anthropic-ai/claude-agent-sdk";
89
import type { CodeExecutionMode } from "../../execution-mode";
910
import { isMcpToolReadOnly } from "./mcp/tool-metadata";
1011

@@ -55,6 +56,12 @@ const AUTO_ALLOWED_TOOLS: Record<string, Set<string>> = {
5556
plan: new Set(BASE_ALLOWED_TOOLS),
5657
};
5758

59+
export function toSdkPermissionMode(
60+
mode: CodeExecutionMode,
61+
): SdkPermissionMode {
62+
return mode === "auto" ? "default" : mode;
63+
}
64+
5865
export function isToolAllowedForMode(
5966
toolName: string,
6067
mode: CodeExecutionMode,

‎packages/agent/src/adapters/codex-app-server/models.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
formatCodexModelName,
55
getReasoningEffortOptions,
66
modelIdFromConfigOptions,
7+
supportsMaxEffort,
78
supportsXhighEffort,
89
} from "./models";
910

@@ -30,8 +31,8 @@ describe("getReasoningEffortOptions", () => {
3031
"gpt-5.6-luna",
3132
"openai/gpt-5.6-sol",
3233
"GPT-5.6-SOL",
33-
])("offers Extra High for the gpt-5.6 family (%s)", (modelId) => {
34-
expect(values(modelId)).toEqual(["low", "medium", "high", "xhigh"]);
34+
])("offers Max for the gpt-5.6 family (%s)", (modelId) => {
35+
expect(values(modelId)).toEqual(["low", "medium", "high", "xhigh", "max"]);
3536
});
3637

3738
it.each(["gpt-5.3-codex", "gpt-5.1", "o3"])(
@@ -57,6 +58,14 @@ describe("supportsXhighEffort", () => {
5758
});
5859
});
5960

61+
describe("supportsMaxEffort", () => {
62+
it("is true only for the gpt-5.6 family", () => {
63+
expect(supportsMaxEffort("gpt-5.6-sol")).toBe(true);
64+
expect(supportsMaxEffort("GPT-5.6-LUNA")).toBe(true);
65+
expect(supportsMaxEffort("gpt-5.5")).toBe(false);
66+
});
67+
});
68+
6069
describe("modelIdFromConfigOptions", () => {
6170
const modelOption = (currentValue: unknown): SessionConfigOption =>
6271
({

‎packages/agent/src/adapters/codex-app-server/models.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,28 @@ const CODEX_REASONING_EFFORT_OPTIONS: ReasoningEffortOption[] = [
1515
{ value: "high", name: "High" },
1616
];
1717

18-
// OpenAI's `reasoning_effort` exposes an "extra high" tier only on the gpt-5.5
19-
// and gpt-5.6 families, matching what the Codex app offers. Older models top
20-
// out at "high".
18+
// OpenAI's `reasoning_effort` exposes an "extra high" tier on the gpt-5.5 and
19+
// gpt-5.6 families. GPT-5.6 also supports the "max" tier. Older models top out
20+
// at "high".
2121
export function supportsXhighEffort(modelId: string): boolean {
2222
const id = modelId.toLowerCase();
2323
return id.includes("gpt-5.5") || id.includes("gpt-5.6");
2424
}
2525

26+
export function supportsMaxEffort(modelId: string): boolean {
27+
return modelId.toLowerCase().includes("gpt-5.6");
28+
}
29+
2630
export function getReasoningEffortOptions(
2731
modelId: string,
2832
): ReasoningEffortOption[] {
2933
const options = [...CODEX_REASONING_EFFORT_OPTIONS];
3034
if (supportsXhighEffort(modelId)) {
3135
options.push({ value: "xhigh", name: "Extra High" });
3236
}
37+
if (supportsMaxEffort(modelId)) {
38+
options.push({ value: "max", name: "Max" });
39+
}
3340
return options;
3441
}
3542

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import { classifyAgentError } from "./error-classification";
3+
4+
describe("classifyAgentError", () => {
5+
it.each([
6+
["API Error: terminated", "upstream_stream_terminated"],
7+
[
8+
"API Error: Connection closed mid-response. The response above may be incomplete.",
9+
"upstream_stream_terminated",
10+
],
11+
[
12+
"API Error: The socket connection was closed unexpectedly.",
13+
"upstream_stream_terminated",
14+
],
15+
[
16+
"The socket connection was closed unexpectedly. For more information, pass `verbose: true`",
17+
"upstream_stream_terminated",
18+
],
19+
["socket connection closed", "upstream_stream_terminated"],
20+
["API Error: Connection error.", "upstream_connection_error"],
21+
["API Error: Request timed out.", "upstream_timeout"],
22+
["API Error: 429 rate limited", "upstream_provider_failure"],
23+
["API Error: 529 overloaded", "upstream_provider_failure"],
24+
["API Error: 400 invalid request", "agent_error"],
25+
[
26+
"Connection closed mid-response without the API Error prefix",
27+
"agent_error",
28+
],
29+
["some unrelated failure", "agent_error"],
30+
[undefined, "agent_error"],
31+
] as const)("classifies %j as %s", (message, expected) => {
32+
expect(classifyAgentError(message)).toBe(expected);
33+
});
34+
});

0 commit comments

Comments
 (0)