Skip to content

Commit a659a13

Browse files
bleleveclaude
andcommitted
fix(shared,web,terraform): address CodeRabbit feedback on #672
Must-fix: - model-defaults.ts: per-field fallback chain (2.1) — defaultModel and defaultPlanModel now resolve independently as `DB > env > shared constant`. The previous all-or-nothing gate required BOTH fields in the CP response; when only one was present, both fields fell back together to env/constants, discarding the partial DB value. Should-fix: - models-settings.tsx: setDirty only fires on an actual state change (2.2). Previously toggling a model off would set dirty even when the toggle was blocked by the default-model guard, enabling Save on a no-op. The fix tracks a `changed` flag inside the updater and only calls setDirty when the underlying Set actually mutated. - models-settings.tsx: SWR data sync moved from render phase into useEffect (2.3). The previous in-render setState would surface a React warning under StrictMode and risked an unnecessary re-render. - terraform/environments/production/workers-{linear,slack}.tf: align DEFAULT_PLAN_MODEL to "anthropic/claude-opus-4-6" (2.4) to match the format used by workers-control-plane.tf and workers-github.tf. The bare "claude-opus-4-6" string was not a fully-qualified model ID and would be rejected by isValidModel. Targeted regression test: - model-defaults.test.ts: two new cases verifying that when the CP response contains only defaultModel (resp. defaultPlanModel), the other field independently falls back to env without the present field being discarded. Verification: npm run build -w @open-inspect/shared && npm run lint && npm run typecheck && npm test (shared 16/16, control-plane 66/66, web 32/32 — all green). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d82266b commit a659a13

5 files changed

Lines changed: 74 additions & 18 deletions

File tree

packages/shared/src/model-defaults.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,46 @@ describe("fetchModelDefaults", () => {
8888
defaultPlanModel: DEFAULT_PLAN_MODEL,
8989
});
9090
});
91+
92+
it("applies per-field fallback when only defaultModel is in the CP response", async () => {
93+
// Regression test for CodeRabbit #672 item 2.1: previously, an
94+
// all-or-nothing gate required BOTH fields in the CP response.
95+
// Missing one would discard the other and force both fields to fall
96+
// back together. Now they resolve independently — DB > env > constant.
97+
const fetcher = vi.fn().mockResolvedValue(
98+
jsonResponse({
99+
enabledModels: ["anthropic/claude-haiku-4-5"],
100+
defaultModel: "anthropic/claude-haiku-4-5",
101+
// defaultPlanModel omitted
102+
})
103+
);
104+
105+
const result = await fetchModelDefaults({
106+
CONTROL_PLANE: { fetch: fetcher } as never,
107+
DEFAULT_MODEL: "env-should-not-win",
108+
DEFAULT_PLAN_MODEL: "env-plan-wins-here",
109+
});
110+
111+
expect(result.defaultModel).toBe("anthropic/claude-haiku-4-5"); // from DB
112+
expect(result.defaultPlanModel).toBe("env-plan-wins-here"); // from env, since DB omitted it
113+
});
114+
115+
it("applies per-field fallback when only defaultPlanModel is in the CP response", async () => {
116+
const fetcher = vi.fn().mockResolvedValue(
117+
jsonResponse({
118+
enabledModels: ["anthropic/claude-opus-4-6"],
119+
defaultPlanModel: "anthropic/claude-opus-4-6",
120+
// defaultModel omitted
121+
})
122+
);
123+
124+
const result = await fetchModelDefaults({
125+
CONTROL_PLANE: { fetch: fetcher } as never,
126+
DEFAULT_MODEL: "env-model-wins-here",
127+
DEFAULT_PLAN_MODEL: "env-should-not-win",
128+
});
129+
130+
expect(result.defaultModel).toBe("env-model-wins-here"); // from env, since DB omitted it
131+
expect(result.defaultPlanModel).toBe("anthropic/claude-opus-4-6"); // from DB
132+
});
91133
});

packages/shared/src/model-defaults.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ interface ModelPreferencesResponse {
4141
* to the env-var path, so bots stay functional during a CP outage.
4242
*/
4343
export async function fetchModelDefaults(env: FetchModelDefaultsEnv): Promise<ModelDefaults> {
44+
// Per-field fallback chain — each field independently resolves
45+
// `DB row > env var > shared constant`. The previous all-or-nothing
46+
// gate discarded a present `defaultModel` from the DB whenever
47+
// `defaultPlanModel` was null (or vice versa), forcing both fields
48+
// to fall back to env/constants together.
49+
let dbDefaultModel: string | undefined;
50+
let dbDefaultPlanModel: string | undefined;
4451
try {
4552
const headers = await buildInternalAuthHeaders(env.INTERNAL_CALLBACK_SECRET);
4653
const res = await env.CONTROL_PLANE.fetch("https://internal/model-preferences", {
@@ -49,18 +56,14 @@ export async function fetchModelDefaults(env: FetchModelDefaultsEnv): Promise<Mo
4956
});
5057
if (res.ok) {
5158
const data = (await res.json()) as ModelPreferencesResponse;
52-
if (data.defaultModel && data.defaultPlanModel) {
53-
return {
54-
defaultModel: data.defaultModel,
55-
defaultPlanModel: data.defaultPlanModel,
56-
};
57-
}
59+
dbDefaultModel = data.defaultModel ?? undefined;
60+
dbDefaultPlanModel = data.defaultPlanModel ?? undefined;
5861
}
5962
} catch {
6063
// Service-binding / network failure — fall through to env-or-shared
6164
}
6265
return {
63-
defaultModel: env.DEFAULT_MODEL || DEFAULT_MODEL,
64-
defaultPlanModel: env.DEFAULT_PLAN_MODEL || DEFAULT_PLAN_MODEL,
66+
defaultModel: dbDefaultModel || env.DEFAULT_MODEL || DEFAULT_MODEL,
67+
defaultPlanModel: dbDefaultPlanModel || env.DEFAULT_PLAN_MODEL || DEFAULT_PLAN_MODEL,
6568
};
6669
}

packages/web/src/components/settings/models-settings.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useMemo, useState } from "react";
3+
import { useEffect, useMemo, useState } from "react";
44
import useSWR, { mutate } from "swr";
55
import { toast } from "sonner";
66
import {
@@ -32,16 +32,19 @@ export function ModelsSettings() {
3232
const [dirty, setDirty] = useState(false);
3333
const [toggleError, setToggleError] = useState<string | null>(null);
3434

35-
// Sync SWR data into local state once on initial load
36-
if (data && !initialized) {
35+
// Sync SWR data into local state once on initial load. Effect (not render-
36+
// phase setState) so React doesn't warn about an in-render update.
37+
useEffect(() => {
38+
if (!data || initialized) return;
3739
setEnabledModels(new Set(data.enabledModels));
3840
if (data.defaultModel) setDefaultModel(data.defaultModel);
3941
if (data.defaultPlanModel) setDefaultPlanModel(data.defaultPlanModel);
4042
setInitialized(true);
41-
}
43+
}, [data, initialized]);
4244

4345
const toggleModel = (modelId: string) => {
4446
setToggleError(null);
47+
let changed = false;
4548
setEnabledModels((prev) => {
4649
const next = new Set(prev);
4750
if (next.has(modelId)) {
@@ -56,25 +59,33 @@ export function ModelsSettings() {
5659
} else {
5760
next.add(modelId);
5861
}
62+
changed = true;
5963
return next;
6064
});
61-
setDirty(true);
65+
if (changed) setDirty(true);
6266
};
6367

6468
const toggleCategory = (category: (typeof MODEL_OPTIONS)[number], enable: boolean) => {
6569
setToggleError(null);
70+
let changed = false;
6671
setEnabledModels((prev) => {
6772
const next = new Set(prev);
6873
let blockedDefault: string | null = null;
6974
for (const model of category.models) {
7075
if (enable) {
71-
next.add(model.id);
76+
if (!next.has(model.id)) {
77+
next.add(model.id);
78+
changed = true;
79+
}
7280
} else {
7381
if (model.id === defaultModel || model.id === defaultPlanModel) {
7482
blockedDefault = model.id;
7583
continue;
7684
}
77-
next.delete(model.id);
85+
if (next.has(model.id)) {
86+
next.delete(model.id);
87+
changed = true;
88+
}
7889
}
7990
}
8091
if (next.size === 0) return prev;
@@ -85,7 +96,7 @@ export function ModelsSettings() {
8596
}
8697
return next;
8798
});
88-
setDirty(true);
99+
if (changed) setDirty(true);
89100
};
90101

91102
const handleDefaultChange = (which: "model" | "plan", value: string) => {

terraform/environments/production/workers-linear.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ module "linear_bot_worker" {
4646
{ name = "DEPLOYMENT_NAME", value = var.deployment_name },
4747
{ name = "APP_NAME", value = var.app_name },
4848
{ name = "DEFAULT_MODEL", value = "claude-sonnet-4-6" },
49-
{ name = "DEFAULT_PLAN_MODEL", value = "claude-opus-4-6" },
49+
{ name = "DEFAULT_PLAN_MODEL", value = "anthropic/claude-opus-4-6" },
5050
{ name = "LINEAR_CLIENT_ID", value = var.linear_client_id },
5151
{ name = "WORKER_URL", value = "https://open-inspect-linear-bot-${local.name_suffix}.${var.cloudflare_worker_subdomain}.workers.dev" },
5252
]

terraform/environments/production/workers-slack.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ module "slack_bot_worker" {
4848
{ name = "DEPLOYMENT_NAME", value = var.deployment_name },
4949
{ name = "APP_NAME", value = var.app_name },
5050
{ name = "DEFAULT_MODEL", value = "claude-haiku-4-5" },
51-
{ name = "DEFAULT_PLAN_MODEL", value = "claude-opus-4-6" },
51+
{ name = "DEFAULT_PLAN_MODEL", value = "anthropic/claude-opus-4-6" },
5252
{ name = "CLASSIFICATION_MODEL", value = "claude-haiku-4-5" },
5353
]
5454

0 commit comments

Comments
 (0)