Skip to content

Commit 6452849

Browse files
committed
harden codex mcp probe and tidy tests
1 parent 21f4bc8 commit 6452849

6 files changed

Lines changed: 116 additions & 54 deletions

File tree

packages/agent/src/adapters/claude/claude-agent.resume-model.test.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as os from "node:os";
33
import * as path from "node:path";
44
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
55
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
6+
import { DEFAULT_GATEWAY_MODEL } from "../../gateway-models";
67

78
type SdkQueryHandle = {
89
interrupt: ReturnType<typeof vi.fn>;
@@ -153,40 +154,43 @@ describe("ClaudeAcpAgent session model on resume", () => {
153154
},
154155
);
155156

156-
it("does not call setModel for a new session on the default model", async () => {
157-
const agent = makeAgent();
158-
159-
await agent.newSession({
160-
cwd,
161-
mcpServers: [],
162-
_meta: { taskRunId: "run-3" },
163-
});
164-
165-
// New sessions already pass options.model to the SDK at spawn.
166-
expect(createdQueries).toHaveLength(1);
167-
expect(createdQueries[0].setModel).not.toHaveBeenCalled();
168-
});
169-
170-
// Guards the desync that surfaced as "picked gpt-5.5, session ran Opus": a
171-
// Codex model id paired with the Claude adapter must not silently masquerade
172-
// as a deliberate Opus session. It falls back AND warns.
173-
it("warns and falls back to the default model when a Codex model reaches the Claude adapter", async () => {
157+
// New sessions pass the model to the SDK at spawn, never via setModel. The
158+
// Codex-model row guards the desync that surfaced as "picked gpt-5.5, session
159+
// ran Opus": a non-Anthropic id on the Claude adapter must fall back to the
160+
// default AND warn rather than silently masquerade as a deliberate Opus run.
161+
it.each([
162+
{
163+
name: "uses the gateway default and never calls setModel without a requested model",
164+
model: undefined,
165+
expectsWarn: false,
166+
},
167+
{
168+
name: "warns and falls back to the default when a Codex model reaches the Claude adapter",
169+
model: "gpt-5.5",
170+
expectsWarn: true,
171+
},
172+
])("newSession $name", async ({ model, expectsWarn }) => {
174173
const agent = makeAgent();
175174
const warnSpy = vi.spyOn(agent.logger, "warn");
176175

177176
const response = await agent.newSession({
178177
cwd,
179178
mcpServers: [],
180-
_meta: { taskRunId: "run-codex-on-claude", model: "gpt-5.5" },
179+
_meta: { taskRunId: "run-new", ...(model ? { model } : {}) },
181180
});
182181

182+
expect(createdQueries[0].setModel).not.toHaveBeenCalled();
183183
expect(getModelConfigOption(response)?.currentValue).toBe(
184-
"claude-opus-4-8",
185-
);
186-
expect(warnSpy).toHaveBeenCalledWith(
187-
expect.stringContaining("Non-Anthropic model"),
188-
expect.objectContaining({ requestedModel: "gpt-5.5" }),
184+
DEFAULT_GATEWAY_MODEL,
189185
);
186+
if (expectsWarn) {
187+
expect(warnSpy).toHaveBeenCalledWith(
188+
expect.stringContaining("Non-Anthropic model"),
189+
expect.objectContaining({ requestedModel: model }),
190+
);
191+
} else {
192+
expect(warnSpy).not.toHaveBeenCalled();
193+
}
190194
});
191195

192196
// The timeout *message* (RequestError "... timed out after ...") is covered

packages/agent/src/adapters/codex/settings.test.ts

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,40 @@ describe("CodexSettingsManager MCP server names", () => {
1515
// server, so the spawn emitted `mcp_servers.<name>.env.enabled=false`, which
1616
// sets a boolean on codex's string-typed env map. codex-acp then rejected the
1717
// whole config, crashed the session, and the host silently ran Claude/Opus.
18-
it("collapses nested mcp_servers sub-tables to the parent server name", () => {
19-
expect(
20-
serverNamesFor(
21-
[
22-
"[mcp_servers.node_repl]",
23-
'command = "node"',
24-
"[mcp_servers.node_repl.env]",
25-
'FOO = "bar"',
26-
"[mcp_servers.other]",
27-
'command = "x"',
28-
].join("\n"),
29-
),
30-
).toEqual(["node_repl", "other"]);
31-
});
32-
33-
it("keeps the inner name for a quoted dotted server key", () => {
34-
expect(
35-
serverNamesFor(['[mcp_servers."my.server"]', 'command = "x"'].join("\n")),
36-
).toEqual(["my.server"]);
37-
});
38-
39-
it("returns no servers when none are declared", () => {
40-
expect(serverNamesFor('model = "gpt-5.5"')).toEqual([]);
18+
it.each([
19+
{
20+
name: "collapses a nested sub-table to its parent server and dedupes",
21+
toml: [
22+
"[mcp_servers.node_repl]",
23+
'command = "node"',
24+
"[mcp_servers.node_repl.env]",
25+
'FOO = "bar"',
26+
"[mcp_servers.other]",
27+
'command = "x"',
28+
],
29+
expected: ["node_repl", "other"],
30+
},
31+
{
32+
name: "collapses a deeply nested sub-table to its parent server",
33+
toml: ["[mcp_servers.foo.bar.baz]", 'k = "v"'],
34+
expected: ["foo"],
35+
},
36+
{
37+
name: "keeps the inner name for a double-quoted dotted server key",
38+
toml: ['[mcp_servers."my.server"]', 'command = "x"'],
39+
expected: ["my.server"],
40+
},
41+
{
42+
name: "keeps the inner name for a single-quoted dotted server key",
43+
toml: ["[mcp_servers.'my.server']", 'command = "x"'],
44+
expected: ["my.server"],
45+
},
46+
{
47+
name: "returns no servers when none are declared",
48+
toml: ['model = "gpt-5.5"'],
49+
expected: [],
50+
},
51+
])("$name", ({ toml, expected }) => {
52+
expect(serverNamesFor(toml.join("\n"))).toEqual(expected);
4153
});
4254
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { Logger } from "../../utils/logger";
3+
4+
const spawnMock = vi.hoisted(() => vi.fn());
5+
vi.mock("node:child_process", () => ({ spawn: spawnMock }));
6+
7+
const { spawnCodexProcess } = await import("./spawn");
8+
9+
function makeFakeChild() {
10+
return {
11+
stdin: { destroy: vi.fn() },
12+
stdout: { on: vi.fn(), destroy: vi.fn() },
13+
stderr: { on: vi.fn(), destroy: vi.fn() },
14+
on: vi.fn(),
15+
kill: vi.fn(),
16+
pid: 1234,
17+
};
18+
}
19+
20+
describe("spawnCodexProcess MCP disable args", () => {
21+
it("disables bare-key servers but skips names codex's -c parser cannot express", () => {
22+
spawnMock.mockReturnValue(makeFakeChild());
23+
24+
spawnCodexProcess({
25+
logger: new Logger({ debug: false }),
26+
settings: {
27+
mcpServerNames: ["simple", "with-dash", "my.server", "weird name"],
28+
},
29+
});
30+
31+
const args: string[] = spawnMock.mock.calls[0][1];
32+
expect(args).toContain("mcp_servers.simple.enabled=false");
33+
expect(args).toContain("mcp_servers.with-dash.enabled=false");
34+
// A dotted or otherwise non-bare name would emit an override codex rejects,
35+
// which crashes the whole session, so it is skipped (the server stays
36+
// enabled, which is harmless).
37+
expect(args.some((arg) => arg.includes("my.server"))).toBe(false);
38+
expect(args.some((arg) => arg.includes("weird name"))).toBe(false);
39+
});
40+
});

packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,9 @@ export function usePreviewConfig(
5959
// resolving the model. Otherwise lastUsedModel and lastUsedAdapter read as
6060
// their pre-hydration defaults, the restore below is skipped, and the
6161
// selector silently falls back to the server default (Opus for Claude).
62-
if (!hasHydrated) {
63-
setIsLoading(true);
64-
return;
65-
}
62+
// isLoading initializes to true, so the picker stays loading until hydration
63+
// lands and the fetch below resolves.
64+
if (!hasHydrated) return;
6665

6766
abortRef.current?.abort();
6867
const abort = new AbortController();

packages/workspace-server/src/services/agent/agent.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ describe("AgentService", () => {
278278
);
279279
});
280280

281-
it("passes identical MCP servers regardless of adapter", async () => {
281+
it("passes identical MCP servers to both adapters when all servers are reachable", async () => {
282282
await service.startSession({
283283
...baseSessionParams,
284284
taskRunId: "run-claude",

packages/workspace-server/src/services/agent/agent.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -993,7 +993,7 @@ When creating pull requests, add the following footer at the end of the PR descr
993993
url: string;
994994
headers: Array<{ name: string; value: string }>;
995995
}): Promise<boolean> {
996-
const PROBE_TIMEOUT_MS = 4_000;
996+
const PROBE_TIMEOUT_MS = 2_000;
997997
try {
998998
const headers: Record<string, string> = {
999999
"content-type": "application/json",
@@ -1017,7 +1017,14 @@ When creating pull requests, add the following footer at the end of the PR descr
10171017
}),
10181018
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
10191019
});
1020-
await response.body?.cancel();
1020+
// Release the body without draining it. A cancel rejection (e.g. an
1021+
// already-disturbed stream) is a cleanup detail, not a reachability
1022+
// signal, so it must not flip the result to unreachable.
1023+
try {
1024+
await response.body?.cancel();
1025+
} catch {
1026+
// ignore body cleanup failures
1027+
}
10211028
// Any HTTP response means the endpoint is reachable. codex-acp only treats
10221029
// transport failures (connection refused, DNS, timeout) as fatal; HTTP or
10231030
// JSON-RPC error responses are handled gracefully.

0 commit comments

Comments
 (0)